@mrrisega/dsh-remote 0.6.3 → 0.6.4-beta.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.3",
3
+ "version": "0.6.4-beta.2",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,516 @@
1
+ // 「登录后自动闭环」回归(node 半,0.6.4)。
2
+ //
3
+ // 现场(2026-09 生产数据,id 42~52 共 11 个真实注册用户):只有 3 人最终绑定了设备,其余 8 人的
4
+ // 电脑端 bridge 从未连上过中继;多人反复点「生成访问链接」(auth.key_create 10~23 次)而
5
+ // device.bind 始终为 0。典型路径:注册 → 手机端 /api/devices 拿到空列表 → 反复轮询 → 放弃。
6
+ //
7
+ // 缺口是「登录 → 设备已在中继注册成功」这条链路既不可见也不自动。本用例锁死:
8
+ // ① 零操作闭环:GET /dsh-remote/bridge-status 在返回前自动补装运行环境 / 拉起 bridge(带退避);
9
+ // ② 阶段可查询且区分「进程在跑(starting)」与「已注册到中继(online)」——后者才是「能用了」;
10
+ // ③ 中继注册判据三层:bridge 状态文件 > 日志(✅ router 注册成功) > 账号设备表(GET /api/devices);
11
+ // ④ 失败不留死胡同:崩溃循环/卡死安装 → error + retryable + 可复制的诊断信息,POST /connect/retry
12
+ // 清退避立刻重试;
13
+ // ⑤ 安装信息上报:登录后 POST /api/install-report(Bearer + install_source/version/host_*);
14
+ // ⑥ 注册来源透传:/dsh-remote/register 全量透传 body(reg_source 等字段不丢)。
15
+ //
16
+ // 走真实分支(unset DSH_RELAY_SKIP_SERVICE),但 PATH 上的 launchctl/pgrep/ps/npx 全是假命令,
17
+ // 绝不碰本机真实服务(与 runtime-provision.test.mjs 同一手法)。
18
+ import assert from "node:assert/strict";
19
+ import http from "node:http";
20
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { mkdir, mkdtemp, readFile, 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
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
28
+ const SECRET = "secret-connect-1";
29
+
30
+ /** 轮询等待条件成立(避免固定 sleep 在并行/高负载下假失败)。 */
31
+ async function waitFor(fn, { timeout = 4000, step = 25 } = {}) {
32
+ const deadline = Date.now() + timeout;
33
+ for (;;) {
34
+ if (await fn()) return true;
35
+ if (Date.now() > deadline) return false;
36
+ await sleep(step);
37
+ }
38
+ }
39
+
40
+ /** 假 launchctl:记录调用,并按环境变量回放 print/list(与真机输出同构)。 */
41
+ const FAKE_LAUNCHCTL = `#!/bin/sh
42
+ printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
43
+ case "$1" in
44
+ print)
45
+ printf 'state = %s\\n' "$DSH_TEST_STATE"
46
+ printf 'pid = %s\\n' "$DSH_TEST_PID"
47
+ printf 'runs = %s\\n' "$DSH_TEST_RUNS"
48
+ printf 'last exit code = %s\\n' "$DSH_TEST_EXIT"
49
+ ;;
50
+ list)
51
+ printf '%s\\n' "$DSH_TEST_LIST"
52
+ ;;
53
+ esac
54
+ exit 0
55
+ `;
56
+ /** 假 pgrep:$DSH_TEST_PGREP 有值就回放(模拟「bridge 进程在跑」)。 */
57
+ const FAKE_PGREP = `#!/bin/sh
58
+ if [ -n "$DSH_TEST_PGREP" ]; then printf '%s\\n' "$DSH_TEST_PGREP"; exit 0; fi
59
+ exit 1
60
+ `;
61
+ const FAKE_PS = `#!/bin/sh
62
+ printf '1\\n'
63
+ exit 0
64
+ `;
65
+ /** 假 npx:只记录参数,绝不做真实安装。 */
66
+ const FAKE_NPX = `#!/bin/sh
67
+ printf '%s\\n' "$*" >> "$DSH_TEST_NPX_LOG"
68
+ exit 0
69
+ `;
70
+
71
+ /** 假账号 API:public-config / device-login / devices / install-report / register。 */
72
+ function startFakeRelay(opts = {}) {
73
+ const seen = [];
74
+ const devices = { list: opts.devices === undefined ? [] : opts.devices };
75
+ const installReports = [];
76
+ let mobileSessionsStatus = 200;
77
+ const srv = http.createServer(async (req, res) => {
78
+ const url = new URL(req.url, "http://x");
79
+ const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
80
+ let body = null;
81
+ if (req.method !== "GET" && req.method !== "HEAD") {
82
+ const chunks = [];
83
+ for await (const c of req) chunks.push(c);
84
+ try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { body = null; }
85
+ }
86
+ seen.push({ method: req.method, path: url.pathname, authorization: req.headers.authorization || "", body });
87
+ if (req.method === "GET" && url.pathname === "/api/public-config") {
88
+ return send(200, { service: "dsh-remote", app_url: "https://app.test/", api_url: `http://127.0.0.1:${srv.address().port}`, bridge_secret: SECRET });
89
+ }
90
+ if (req.method === "POST" && url.pathname === "/api/device-login") {
91
+ if ((req.headers["x-dsh-bridge-secret"] || "") !== SECRET) return send(401, { error: { code: "unauthorized", message: "需要有效设备密钥" } });
92
+ return send(200, { token: "jwt-connect" });
93
+ }
94
+ if (req.method === "GET" && url.pathname === "/api/devices") {
95
+ if (!(req.headers.authorization || "").startsWith("Bearer ")) return send(401, { error: { code: "invalid_token" } });
96
+ return send(200, { devices: devices.list });
97
+ }
98
+ if (req.method === "POST" && url.pathname === "/api/install-report") {
99
+ installReports.push({ authorization: req.headers.authorization || "", body });
100
+ if (opts.installReportStatus && opts.installReportStatus !== 200) return send(opts.installReportStatus, { error: { code: "nope" } });
101
+ return send(200, { ok: true });
102
+ }
103
+ if (req.method === "GET" && url.pathname === "/api/mobile-sessions") {
104
+ if (mobileSessionsStatus !== 200) return send(mobileSessionsStatus, { error: { code: "invalid_token", message: "无效或过期的 token" } });
105
+ return send(200, { ok: true, sessions: [] });
106
+ }
107
+ if (req.method === "POST" && url.pathname === "/api/register") return send(201, { ok: true, token: "jwt-reg" });
108
+ return send(404, { error: { code: "not_found" } });
109
+ });
110
+ return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({
111
+ srv, seen, installReports, port: srv.address().port,
112
+ setDevices(list) { devices.list = list; },
113
+ setMobileSessionsStatus(s) { mobileSessionsStatus = s; },
114
+ count(p) { return seen.filter((s) => s.path === p).length; },
115
+ })));
116
+ }
117
+
118
+ /**
119
+ * 隔离环境:假 HOME(plist 落这里)+ 假 launchctl/pgrep/ps/npx + 临时 relayDir。
120
+ * runtime=false 时不预置 dsh-setup.mjs(模拟「插件市场只装了面板插件」)。
121
+ */
122
+ async function setup({ runtime = true, state = "stopped", pid = "0", runs = 0, exitCode = 0, pgrep = "" } = {}) {
123
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-connect-"));
124
+ const fakeHome = path.join(root, "home");
125
+ const fakeBin = path.join(root, "bin");
126
+ const relayDir = path.join(root, "relay");
127
+ await mkdir(fakeBin, { recursive: true });
128
+ await mkdir(fakeHome, { recursive: true });
129
+ await mkdir(relayDir, { recursive: true });
130
+ const launchLog = path.join(root, "launchctl.log");
131
+ const npxLog = path.join(root, "npx.log");
132
+ await writeFile(launchLog, "");
133
+ await writeFile(npxLog, "");
134
+ for (const [name, script] of [["launchctl", FAKE_LAUNCHCTL], ["pgrep", FAKE_PGREP], ["ps", FAKE_PS], ["npx", FAKE_NPX]]) {
135
+ await writeFile(path.join(fakeBin, name), script);
136
+ await chmodSync(path.join(fakeBin, name), 0o755);
137
+ }
138
+ if (runtime) await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub");
139
+
140
+ const saved = {};
141
+ for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_NPX_LOG", "DSH_TEST_STATE", "DSH_TEST_PID",
142
+ "DSH_TEST_RUNS", "DSH_TEST_EXIT", "DSH_TEST_LIST", "DSH_TEST_PGREP", "DSH_RELAY_SKIP_SERVICE",
143
+ "DSH_SETUP_NPX_DIR", "DSH_RELAY_SELFHEAL_MS", "DSH_BRIDGE_INSTALL_SOURCE"]) {
144
+ saved[k] = process.env[k];
145
+ }
146
+ process.env.HOME = fakeHome;
147
+ process.env.PATH = `${fakeBin}:${saved.PATH}`;
148
+ process.env.DSH_TEST_LAUNCH_LOG = launchLog;
149
+ process.env.DSH_TEST_NPX_LOG = npxLog;
150
+ process.env.DSH_TEST_STATE = state;
151
+ process.env.DSH_TEST_PID = pid;
152
+ process.env.DSH_TEST_RUNS = String(runs);
153
+ process.env.DSH_TEST_EXIT = String(exitCode);
154
+ process.env.DSH_TEST_LIST = "";
155
+ if (pgrep) process.env.DSH_TEST_PGREP = pgrep; else delete process.env.DSH_TEST_PGREP;
156
+ delete process.env.DSH_BRIDGE_INSTALL_SOURCE;
157
+ process.env.DSH_SETUP_NPX_DIR = fakeBin;
158
+ delete process.env.DSH_RELAY_SKIP_SERVICE; // 走真实分支,但系统命令全是假的
159
+ process.env.DSH_RELAY_SELFHEAL_MS = "60000"; // 让后台 watcher 不参与用例节奏
160
+
161
+ return {
162
+ root, fakeHome, fakeBin, relayDir, launchLog, npxLog,
163
+ plistPath: path.join(fakeHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist"),
164
+ setState(next) { process.env.DSH_TEST_STATE = next; },
165
+ setPgrep(v) { if (v) process.env.DSH_TEST_PGREP = v; else delete process.env.DSH_TEST_PGREP; },
166
+ async restore() {
167
+ for (const [k, v] of Object.entries(saved)) {
168
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
169
+ }
170
+ await rm(root, { recursive: true, force: true });
171
+ },
172
+ };
173
+ }
174
+
175
+ /** 装载插件路由(收集 disposer:否则自愈定时器会跨用例继续跑)。 */
176
+ function boot(relayDir) {
177
+ const routes = new Map();
178
+ const disposers = [];
179
+ const logs = [];
180
+ apply({
181
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
182
+ effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
183
+ logger: { info(m) { logs.push(String(m)); }, warn(m) { logs.push(String(m)); } },
184
+ }, { relayDir });
185
+ routes.logs = logs;
186
+ routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
187
+ return routes;
188
+ }
189
+
190
+ async function serve(routes) {
191
+ const host = http.createServer((req, res) => {
192
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
193
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
194
+ });
195
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
196
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
197
+ }
198
+
199
+ async function writeConfig(relayDir, relayPort, extra = {}) {
200
+ await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
201
+ phone: "13800000000", password: "pw", device_id: "dev-connect",
202
+ api_url: `http://127.0.0.1:${relayPort}`, bridge_secret: SECRET,
203
+ ...extra,
204
+ }));
205
+ }
206
+
207
+ const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
208
+ const bridgeStatus = async (base) => (await (await fetch(`${base}/dsh-remote/bridge-status`)).json());
209
+
210
+ test("未登录:不补装、不拉服务,阶段=no_account(先引导登录,而不是空转)", async () => {
211
+ const relay = await startFakeRelay();
212
+ const env = await setup({ runtime: false });
213
+ try {
214
+ await writeConfig(env.relayDir, relay.port, { phone: "", password: "" });
215
+ const routes = boot(env.relayDir);
216
+ const { host, base } = await serve(routes);
217
+ try {
218
+ const r = await bridgeStatus(base);
219
+ assert.equal(r.ok, true);
220
+ assert.equal(r.connect.phase, "no_account");
221
+ assert.equal(r.connect.retryable, false, "未登录不是错误,不该出现重试入口");
222
+ assert.match(r.connect.text, /请先登录/);
223
+ // 未登录不得做任何「服务级」动作(拉起 bridge / launchctl 变更)——运行环境补装是插件加载即开始的,
224
+ // 与登录无关(0.6.2 起的既有行为),这里只锁「登录态才拉服务」这条边界。
225
+ // (launchctl print 只是状态查询,允许出现。)
226
+ const launchLog = await readOrEmpty(env.launchLog);
227
+ assert.ok(!/bootstrap|bootout|kickstart|unload/.test(launchLog), "未登录不得触发服务启停,实际:" + launchLog);
228
+ } finally { host.close(); routes.dispose(); }
229
+ } finally { await env.restore(); relay.srv.close(); }
230
+ });
231
+
232
+ test("缺运行环境:/bridge-status 自动后台补装,阶段=no_runtime/installing 且写下 plugin_market 记录", async () => {
233
+ const relay = await startFakeRelay();
234
+ const env = await setup({ runtime: false }); // 市场只装了面板插件:没有 dsh-setup.mjs
235
+ try {
236
+ await writeConfig(env.relayDir, relay.port);
237
+ const routes = boot(env.relayDir);
238
+ const { host, base } = await serve(routes);
239
+ try {
240
+ const r = await bridgeStatus(base);
241
+ assert.ok(["no_runtime", "installing"].includes(r.connect.phase), "阶段应为缺环境/安装中,实际 " + r.connect.phase);
242
+ assert.equal(r.connect.runtimeReady, false);
243
+ assert.match(r.connect.text, /正在准备运行环境/, "文案要面向非技术用户说明在准备运行环境");
244
+ assert.ok(await waitFor(async () => (await readOrEmpty(env.npxLog)).includes("@mrrisega/dsh-remote")), "应后台触发一次 npx 补装");
245
+ assert.ok(existsSync(path.join(env.relayDir, ".dsh-provisioned-by-plugin")), "应记录「本机运行环境由插件自愈补装」→ install_source=plugin_market");
246
+ } finally { host.close(); routes.dispose(); }
247
+ } finally { await env.restore(); relay.srv.close(); }
248
+ });
249
+
250
+ test("运行环境就绪但服务没起:轮询自动 bootstrap(零操作),阶段=starting", async () => {
251
+ const relay = await startFakeRelay();
252
+ const env = await setup({ runtime: true, state: "stopped" });
253
+ try {
254
+ await writeConfig(env.relayDir, relay.port);
255
+ const routes = boot(env.relayDir);
256
+ const { host, base } = await serve(routes);
257
+ try {
258
+ const r = await bridgeStatus(base);
259
+ assert.equal(r.connect.phase, "starting");
260
+ assert.match(r.connect.text, /正在启动 Bridge/);
261
+ const log = await readOrEmpty(env.launchLog);
262
+ assert.match(log, /bootstrap/, "应自动拉起 bridge(launchctl bootstrap),实际:" + log);
263
+ const plist = await readOrEmpty(env.plistPath);
264
+ assert.match(plist, /DSH_BRIDGE_INSTALL_VERSION/, "plist 应带安装版本(bridge 随设备登记上报)");
265
+ assert.match(plist, /DSH_BRIDGE_INSTALL_SOURCE/, "plist 应带安装来源");
266
+ assert.equal(r.connect.registered, false, "进程刚起时不能谎报「已注册」");
267
+ } finally { host.close(); routes.dispose(); }
268
+ } finally { await env.restore(); relay.srv.close(); }
269
+ });
270
+
271
+ test("进程在跑但账号还没这台设备:阶段=connecting(进程在跑 ≠ 能用)", async () => {
272
+ const relay = await startFakeRelay({ devices: [] });
273
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
274
+ try {
275
+ await writeConfig(env.relayDir, relay.port);
276
+ const routes = boot(env.relayDir);
277
+ const { host, base } = await serve(routes);
278
+ try {
279
+ const r = await bridgeStatus(base);
280
+ assert.equal(r.connect.phase, "connecting");
281
+ assert.equal(r.connect.bridgeRunning, true, "bridge 进程在跑");
282
+ assert.equal(r.connect.registered, false, "但设备尚未注册到中继 → 不能显示「已连接」");
283
+ assert.match(r.connect.text, /正在连接中继/);
284
+ } finally { host.close(); routes.dispose(); }
285
+ } finally { await env.restore(); relay.srv.close(); }
286
+ });
287
+
288
+ test("设备已登记到账号(手机端能看到):阶段=online,且 /status 里也带同一份 connect", async () => {
289
+ const relay = await startFakeRelay({ devices: [{ id: "dev-connect", device_name: "mbp", online: true }] });
290
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
291
+ try {
292
+ await writeConfig(env.relayDir, relay.port);
293
+ const routes = boot(env.relayDir);
294
+ const { host, base } = await serve(routes);
295
+ try {
296
+ const r = await bridgeStatus(base);
297
+ assert.equal(r.connect.phase, "online", "设备已在账号设备表 → 手机端可见即可用");
298
+ assert.equal(r.connect.registered, true);
299
+ assert.equal(r.connect.registerSource, "account_api");
300
+ assert.match(r.connect.text, /已连接 ✅ 现在可以用手机扫码访问/);
301
+ const st = await (await fetch(`${base}/dsh-remote/status`)).json();
302
+ assert.equal(st.connect.phase, "online", "/status 也应下发连接阶段(面板 30s 轮询同样能刷新)");
303
+ } finally { host.close(); routes.dispose(); }
304
+ } finally { await env.restore(); relay.srv.close(); }
305
+ });
306
+
307
+ test("bridge 日志显示已注册到中继:直接 online,且不再打账号设备表(省审计/请求)", async () => {
308
+ const relay = await startFakeRelay({ devices: [] });
309
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
310
+ try {
311
+ await writeConfig(env.relayDir, relay.port);
312
+ await writeFile(path.join(env.relayDir, ".dsh-bridge.log"),
313
+ "[dsh-remote] dsh web 在线,启动 bridge...\n[bridge] 隧道已连 wss://relay.test/_bridge,注册 dev-connect...\n[bridge] ✅ router 注册成功: dev-connect,等待手机访问 /remote/dev-connect/\n");
314
+ const routes = boot(env.relayDir);
315
+ const { host, base } = await serve(routes);
316
+ try {
317
+ const r = await bridgeStatus(base);
318
+ assert.equal(r.connect.phase, "online");
319
+ assert.equal(r.connect.registerSource, "tunnel");
320
+ assert.equal(relay.count("/api/devices"), 0, "有日志证据时不必再探账号设备表");
321
+ } finally { host.close(); routes.dispose(); }
322
+ } finally { await env.restore(); relay.srv.close(); }
323
+ });
324
+
325
+ test("启动崩溃循环:阶段=error + 可重试 + 诊断信息齐全;POST /connect/retry 清退避立刻再试", async () => {
326
+ const relay = await startFakeRelay();
327
+ const env = await setup({ runtime: true, state: "spawn scheduled", runs: 23, exitCode: 1 });
328
+ try {
329
+ await writeConfig(env.relayDir, relay.port);
330
+ const routes = boot(env.relayDir);
331
+ const { host, base } = await serve(routes);
332
+ try {
333
+ const r = await bridgeStatus(base);
334
+ assert.equal(r.connect.phase, "error");
335
+ assert.equal(r.connect.retryable, true, "崩溃循环必须可重试,不能是死胡同");
336
+ assert.equal(r.connect.error.code, "launchd_crash");
337
+ assert.match(r.connect.error.message, /反复启动失败/);
338
+ const diag = r.connect.diagnostics;
339
+ for (const key of ["插件版本", "配置目录", "连接阶段", "运行环境", "最近错误", "bridge 日志", env.relayDir]) {
340
+ assert.ok(diag.includes(key), `诊断信息应包含「${key}」,实际:\n${diag}`);
341
+ }
342
+ assert.equal(r.connect.attempts, 1, "已经自动尝试过一次(拉起 bridge)");
343
+ const before = (await readOrEmpty(env.launchLog)).split("\n").filter((l) => l.includes("bootstrap")).length;
344
+
345
+ const retried = await (await fetch(`${base}/dsh-remote/connect/retry`, { method: "POST" })).json();
346
+ assert.equal(retried.ok, true);
347
+ assert.equal(retried.connect.attempts, 1, "手动重试应清掉退避计数后重新计数(1=本次重试)");
348
+ const after = (await readOrEmpty(env.launchLog)).split("\n").filter((l) => l.includes("bootstrap")).length;
349
+ assert.ok(after > before, `「重试」按钮应真的再拉起一次(bootstrap ${before} → ${after})`);
350
+ } finally { host.close(); routes.dispose(); }
351
+ } finally { await env.restore(); relay.srv.close(); }
352
+ });
353
+
354
+ test("安装卡死(标记超时/进程已死):/status 如实报 error,短轮询随即清掉标记重新补装(不留死胡同)", async () => {
355
+ const relay = await startFakeRelay();
356
+ const env = await setup({ runtime: false });
357
+ try {
358
+ process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 隔离模式:ensureRuntime 不 spawn 真实 npx,便于稳定观察标记清理
359
+ await writeConfig(env.relayDir, relay.port);
360
+ const routes = boot(env.relayDir);
361
+ const { host, base } = await serve(routes);
362
+ try {
363
+ // 上次安装的残留标记:进程早已不存在(pid=999999)+ 超时(20 分钟前)
364
+ const marker = path.join(env.relayDir, ".dsh-setup-installing");
365
+ await writeFile(marker, JSON.stringify({ pid: 999999, at: Date.now() - 20 * 60 * 1000 }));
366
+ const st = await (await fetch(`${base}/dsh-remote/status`)).json();
367
+ assert.equal(st.connect.phase, "error", "/status 如实反映卡死,而不是一直显示「安装中」骗用户");
368
+ assert.equal(st.connect.error.code, "install_stuck");
369
+ assert.equal(st.connect.retryable, true);
370
+ assert.match(st.connect.detail, /清理|重新安装/);
371
+
372
+ // 短轮询端点带回自愈:清掉卡死标记 → 重新进入补装流程(下轮就会重新 npx)
373
+ const r = await bridgeStatus(base);
374
+ assert.equal(existsSync(marker), false, "卡死标记应被清掉,否则自动补装被永久挡住");
375
+ assert.ok(["no_runtime", "installing"].includes(r.connect.phase), "实际 " + r.connect.phase);
376
+ } finally { host.close(); routes.dispose(); }
377
+ } finally { await env.restore(); relay.srv.close(); }
378
+ });
379
+
380
+ test("登录后上报安装信息:install_source=plugin_market/npx + version/os/arch,Bearer 走 device-login JWT", async () => {
381
+ const relay = await startFakeRelay();
382
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
383
+ try {
384
+ await writeConfig(env.relayDir, relay.port, { phone: "", password: "" }); // 先未登录
385
+ const routes = boot(env.relayDir);
386
+ const { host, base } = await serve(routes);
387
+ try {
388
+ const noAuth = await (await fetch(`${base}/dsh-remote/bridge-status`)).json();
389
+ assert.equal(noAuth.connect.phase, "no_account");
390
+ assert.equal(relay.count("/api/install-report"), 0, "没有账号凭据时不得上报");
391
+
392
+ // 面板内登录成功(POST /dsh-remote/config)→ 立刻上报
393
+ const cfg = await (await fetch(`${base}/dsh-remote/config`, {
394
+ method: "POST", headers: { "content-type": "application/json" },
395
+ body: JSON.stringify({ phone: "13800000000", password: "pw" }),
396
+ })).json();
397
+ assert.equal(cfg.ok, true);
398
+ assert.ok(await waitFor(() => relay.installReports.length === 1), "登录后应上报一次安装信息");
399
+ const rep = relay.installReports[0];
400
+ assert.equal(rep.authorization, "Bearer jwt-connect", "必须带 device-login JWT");
401
+ assert.equal(rep.body.install_source, "npx", "有运行环境且非插件自愈 → 安装器部署(npx)");
402
+ assert.equal(rep.body.install_version, JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
403
+ assert.equal(rep.body.host_os, process.platform);
404
+ assert.equal(rep.body.host_arch, process.arch);
405
+ // device_id 可选(契约 {device_id?, ...}):换账号时本机 device_id 会被重新生成,此时不上报
406
+ assert.ok(!("device_id" in rep.body) || /^dev-/.test(String(rep.body.device_id)));
407
+ } finally { host.close(); routes.dispose(); }
408
+ } finally { await env.restore(); relay.srv.close(); }
409
+ });
410
+
411
+ test("插件自愈补装的机器:install_source 记成 plugin_market(区分「市场安装」与「安装器 npx」)", async () => {
412
+ const relay = await startFakeRelay();
413
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
414
+ try {
415
+ await writeConfig(env.relayDir, relay.port);
416
+ await writeFile(path.join(env.relayDir, ".dsh-provisioned-by-plugin"), JSON.stringify({ at: Date.now(), source: "plugin_market" }));
417
+ const routes = boot(env.relayDir);
418
+ const { host, base } = await serve(routes);
419
+ try {
420
+ await fetch(`${base}/dsh-remote/config`, {
421
+ method: "POST", headers: { "content-type": "application/json" },
422
+ body: JSON.stringify({ phone: "13800000000", password: "pw" }),
423
+ });
424
+ assert.ok(await waitFor(() => relay.installReports.length >= 1), "应上报安装信息");
425
+ assert.equal(relay.installReports[0].body.install_source, "plugin_market");
426
+ } finally { host.close(); routes.dispose(); }
427
+ } finally { await env.restore(); relay.srv.close(); }
428
+ });
429
+
430
+ test("老服务端没有 /api/install-report(404):静默忽略,绝不影响面板", async () => {
431
+ const relay = await startFakeRelay({ installReportStatus: 404 });
432
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
433
+ try {
434
+ await writeConfig(env.relayDir, relay.port);
435
+ const routes = boot(env.relayDir);
436
+ const { host, base } = await serve(routes);
437
+ try {
438
+ const cfg = await (await fetch(`${base}/dsh-remote/config`, {
439
+ method: "POST", headers: { "content-type": "application/json" },
440
+ body: JSON.stringify({ phone: "13800000000", password: "pw" }),
441
+ })).json();
442
+ assert.equal(cfg.ok, true, "上报失败不得影响登录响应");
443
+ // 404 = 老服务端没有该接口 → 静默忽略(不写去重标记,于是启动时那次 + 登录这次都会尝试)
444
+ assert.ok(await waitFor(() => relay.count("/api/install-report") >= 1), "应真的尝试过一次上报");
445
+ } finally { host.close(); routes.dispose(); }
446
+ } finally { await env.restore(); relay.srv.close(); }
447
+ });
448
+
449
+ test("连接轮询不放大认证:多次 /bridge-status + /status 只打一次 device-login(60s token 缓存 + 并发合并)", async () => {
450
+ const relay = await startFakeRelay({ devices: [{ id: "dev-connect", device_name: "mbp" }] });
451
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
452
+ try {
453
+ await writeConfig(env.relayDir, relay.port);
454
+ const routes = boot(env.relayDir);
455
+ const { host, base } = await serve(routes);
456
+ try {
457
+ for (let i = 0; i < 3; i++) await bridgeStatus(base);
458
+ await fetch(`${base}/dsh-remote/status`);
459
+ await fetch(`${base}/dsh-remote/status`);
460
+ assert.equal(relay.count("/api/device-login"), 1, "认证接口只应被调用一次(面板每 2~3s 轮询一次状态,绝不能每次都认证)");
461
+ const last = await bridgeStatus(base);
462
+ assert.equal(last.connect.phase, "online", "缓存命中不影响阶段判定");
463
+ assert.equal(relay.count("/api/device-login"), 1, "后续轮询仍复用缓存 token");
464
+ } finally { host.close(); routes.dispose(); }
465
+ } finally { await env.restore(); relay.srv.close(); }
466
+ });
467
+
468
+ test("JWT 被企业端拒绝(401):token 缓存立即失效,下一次轮询重新认证(不会一直拿坏 token 撞墙)", async () => {
469
+ const relay = await startFakeRelay({ devices: [] });
470
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
471
+ try {
472
+ await writeConfig(env.relayDir, relay.port);
473
+ const routes = boot(env.relayDir);
474
+ const { host, base } = await serve(routes);
475
+ try {
476
+ await bridgeStatus(base);
477
+ const before = relay.count("/api/device-login");
478
+ assert.ok(before >= 1, "首次轮询应完成一次认证");
479
+
480
+ relay.setMobileSessionsStatus(401); // 企业端拒绝当前 JWT(过期/轮换)
481
+ await fetch(`${base}/dsh-remote/mobile-sessions`);
482
+ relay.setMobileSessionsStatus(200);
483
+
484
+ await bridgeStatus(base);
485
+ assert.ok(relay.count("/api/device-login") > before, "401 之后必须重新认证,而不是继续用坏 token");
486
+ } finally { host.close(); routes.dispose(); }
487
+ } finally { await env.restore(); relay.srv.close(); }
488
+ });
489
+
490
+ test("注册来源透传:/dsh-remote/register 全量转发 body(reg_source=panel_register 等字段不丢)", async () => {
491
+ const relay = await startFakeRelay();
492
+ const env = await setup({ runtime: true, state: "running", pid: String(process.pid) });
493
+ try {
494
+ await writeConfig(env.relayDir, relay.port, { phone: "", password: "" });
495
+ const routes = boot(env.relayDir);
496
+ const { host, base } = await serve(routes);
497
+ try {
498
+ const r = await (await fetch(`${base}/dsh-remote/register`, {
499
+ method: "POST", headers: { "content-type": "application/json" },
500
+ body: JSON.stringify({
501
+ phone: "13800000000", sms_code: "218937", password: "password123",
502
+ reg_source: "panel_register", invite_code: "A8K2M4XQ",
503
+ captcha_id: "cap-1", captcha_answer: "1234",
504
+ }),
505
+ })).json();
506
+ assert.equal(r.ok, true);
507
+ const reg = relay.seen.filter((s) => s.path === "/api/register").pop();
508
+ assert.ok(reg, "应转发到企业端 /api/register");
509
+ assert.equal(reg.body.reg_source, "panel_register", "注册来源必须透传(增长口径)");
510
+ assert.equal(reg.body.invite_code, "A8K2M4XQ", "未知/扩展字段不得被丢弃");
511
+ assert.equal(reg.body.captcha_id, "cap-1");
512
+ assert.equal(reg.body.captcha_answer, "1234");
513
+ assert.equal(reg.body.sms_code, "218937");
514
+ } finally { host.close(); routes.dispose(); }
515
+ } finally { await env.restore(); relay.srv.close(); }
516
+ });