@mrrisega/dsh-remote 0.6.7-beta.3 → 0.6.7

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,307 @@
1
+ /**
2
+ * 「切换账号后设备没登记到新账号」回归(0.6.7-beta.4 新增)。
3
+ *
4
+ * 现场(2026-09-19 用户实测,本机复现):
5
+ * 23:55 装好插件 → 00:03 登录账号 A → bridge 用 A 的凭据登记(dev-xxxx 进 A 的设备列表)
6
+ * → 00:29 退出 A、登录账号 B(配置已改、device_id 已清)
7
+ * → **B 的设备列表里永远看不到这台 Mac**,而 A 那边它还在线。
8
+ *
9
+ * 三个缺陷叠加(本文件逐个锁死):
10
+ * ① bridge 的账号/密码是**进程启动时**从环境变量固化的;`dsh-setup run` 每 10s 重读配置,
11
+ * 但只在子进程**已退出**时才重新拉起它 → 换了账号,进程不换凭据。
12
+ * ② 切换账号时那次"重启 bridge"其实失败了:插件只认 `gui/<uid>` 域,而安装器按 macOS 26 的
13
+ * 修复把作业装在 `user/<uid>` 域 → `Could not find service ... in user gui: 501`,
14
+ * 而面板**不看 bridgeRestart 的返回值** → 静默失败。
15
+ * ③ 旧账号留下的 `.dsh-bridge-state.json`(phase=online + 旧 device_id)没被清理,
16
+ * 面板据此谎报「已连接」,自愈(只判"有没有进程在跑")也就永远不会去纠正。
17
+ *
18
+ * ⚠️ 隔离:unset 隔离开关的用例必须同时伪造 HOME 与 PATH(否则会碰真实 HOME 下的 launchd)。
19
+ */
20
+ import assert from "node:assert/strict";
21
+ import http from "node:http";
22
+ import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import test from "node:test";
27
+ import { fileURLToPath } from "node:url";
28
+ import { apply } from "../lib/index.js";
29
+
30
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
31
+ const SETUP_SRC = readFileSync(path.join(HERE, "..", "..", "..", "dsh-setup.mjs"), "utf8");
32
+ const CLIENT_SRC = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8");
33
+ const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
34
+
35
+ /** 假 launchctl:user 域报告 running、gui 域报告不存在(复刻 macOS 26 + 安装器实际形态)。 */
36
+ const FAKE_LAUNCHCTL = `#!/bin/sh
37
+ printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
38
+ if [ "$1" = print ]; then
39
+ case "$2" in
40
+ user/*) printf 'state = running\\npid = 4242\\nruns = 3\\n'; exit 0 ;;
41
+ gui/*) echo "Could not find service in domain for user gui" >&2; exit 1 ;;
42
+ esac
43
+ fi
44
+ exit 0
45
+ `;
46
+
47
+ async function makeEnv({ fakeLaunchd = true } = {}) {
48
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-acct-dev-"));
49
+ const fakeHome = path.join(root, "home");
50
+ const fakeBin = path.join(root, "bin");
51
+ const relayDir = path.join(root, "relay");
52
+ await mkdir(fakeHome, { recursive: true });
53
+ await mkdir(fakeBin, { recursive: true });
54
+ await mkdir(relayDir, { recursive: true });
55
+ if (fakeLaunchd) {
56
+ await writeFile(path.join(fakeBin, "launchctl"), FAKE_LAUNCHCTL);
57
+ await chmod(path.join(fakeBin, "launchctl"), 0o755);
58
+ }
59
+ const saved = {};
60
+ for (const k of ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_RELAY_SKIP_SERVICE", "DSH_REMOTE_TELEMETRY"]) {
61
+ saved[k] = process.env[k];
62
+ }
63
+ process.env.HOME = fakeHome;
64
+ if (fakeLaunchd) process.env.PATH = `${fakeBin}:${saved.PATH}`;
65
+ process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
66
+ process.env.DSH_REMOTE_TELEMETRY = "0";
67
+ process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 默认隔离:用例按需 unset
68
+ return {
69
+ root, relayDir, fakeHome, launchLog: path.join(root, "launchctl.log"),
70
+ async restore() {
71
+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
72
+ await rm(root, { recursive: true, force: true });
73
+ },
74
+ };
75
+ }
76
+
77
+ function boot(relayDir) {
78
+ const routes = new Map();
79
+ apply({
80
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
81
+ effect(register) { return register(); },
82
+ logger: { info() {}, warn() {} },
83
+ }, { relayDir });
84
+ return routes;
85
+ }
86
+
87
+ async function serve(routes) {
88
+ const host = http.createServer((req, res) => {
89
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
90
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
91
+ });
92
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
93
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
94
+ }
95
+
96
+ const json = async (p, init) => {
97
+ const res = await fetch(p, init);
98
+ return { status: res.status, body: await res.json().catch(() => null) };
99
+ };
100
+
101
+ // ─────────── ① 旧账号的 bridge 不得被当成「已连接」 ───────────
102
+
103
+ test("切换账号后:旧账号留下的 online 状态不得再当作注册证据", async () => {
104
+ const env = await makeEnv();
105
+ try {
106
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
107
+ // 切账号后的配置:新账号、device_id 已被清空
108
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
109
+ phone: "account-B", password: "pw", api_url: "http://127.0.0.1:1",
110
+ }));
111
+ // 旧账号 bridge 留下的状态:device_id 是旧的、还写着 online
112
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
113
+ device_id: "dev-old", phase: "online", tunnel_registered_at: 1789747521560, account_bound_at: 1789747429422,
114
+ }));
115
+ const routes = boot(env.relayDir);
116
+ const { host, base } = await serve(routes);
117
+ const stateFile = path.join(env.relayDir, ".dsh-bridge-state.json");
118
+ try {
119
+ // ① /status 是纯读接口:必须如实报告"跑着的是上一个账号的 bridge"
120
+ const { body: st } = await json(`${base}/dsh-remote/status`);
121
+ assert.equal(st.connect.accountCurrent, false, "必须识别出「跑着的是上一个账号的 bridge」");
122
+ assert.equal(st.connect.registered, false, "★旧账号的注册证据必须作废(否则面板谎报已连接)");
123
+ assert.notEqual(st.connect.phase, "online", "★不得报 online");
124
+ assert.match(String(st.connect.detail), /上一个账号|重启/, "应说明正在用新账号重新登记");
125
+ assert.match(String(st.connect.diagnostics), /bridge 账号: ⚠️/, "诊断信息里要能一眼看出");
126
+ assert.equal(existsSync(stateFile), true, "纯读接口不得改动状态文件");
127
+
128
+ // ② /bridge-status 会自愈:清掉过期状态(下一轮用新账号重新登记)
129
+ const { body: bs } = await json(`${base}/dsh-remote/bridge-status`);
130
+ assert.equal(existsSync(stateFile), false, "★过期状态必须被自愈清掉(否则面板一直谎报,永不纠正)");
131
+ assert.equal(bs.connect.registered, false, "清掉之后也不得凭空报已连接");
132
+ } finally {
133
+ host.close();
134
+ }
135
+ } finally {
136
+ await env.restore();
137
+ }
138
+ });
139
+
140
+ test("账号一致时行为不变:同一 device_id 的 online 状态仍算已连接(回归)", async () => {
141
+ const env = await makeEnv();
142
+ try {
143
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
144
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
145
+ phone: "account-A", password: "pw", device_id: "dev-1", api_url: "http://127.0.0.1:1",
146
+ }));
147
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
148
+ device_id: "dev-1", phase: "online", tunnel_registered_at: 1789747521560,
149
+ }));
150
+ const routes = boot(env.relayDir);
151
+ const { host, base } = await serve(routes);
152
+ try {
153
+ const { body } = await json(`${base}/dsh-remote/bridge-status`);
154
+ assert.equal(body.connect.accountCurrent, true);
155
+ assert.equal(body.connect.registered, true, "同一账号的注册证据仍然有效");
156
+ assert.match(String(body.connect.diagnostics), /bridge 账号: 当前账号/);
157
+ } finally {
158
+ host.close();
159
+ }
160
+ } finally {
161
+ await env.restore();
162
+ }
163
+ });
164
+
165
+ // ─────────── ② 切换账号要真的把旧 bridge 停掉 + 清掉过期状态 ───────────
166
+
167
+ test("切换账号:停掉旧 bridge 并清除过期状态文件(否则新账号永远等不到设备)", async () => {
168
+ const env = await makeEnv();
169
+ try {
170
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
171
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
172
+ phone: "account-A", password: "pw", device_id: "dev-old", api_url: "http://127.0.0.1:1",
173
+ }));
174
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
175
+ device_id: "dev-old", phase: "online", tunnel_registered_at: 1789747521560,
176
+ }));
177
+ const routes = boot(env.relayDir);
178
+ const { host, base } = await serve(routes);
179
+ try {
180
+ const { body } = await json(`${base}/dsh-remote/config`, {
181
+ method: "POST", headers: { "content-type": "application/json" },
182
+ body: JSON.stringify({ phone: "account-B", password: "pw2" }),
183
+ });
184
+ assert.equal(body.ok, true);
185
+ assert.ok(body.bridgeRestart, "响应必须带上重启结果(前端据此提示,不再静默)");
186
+ const cfg = JSON.parse(await readFile(path.join(env.relayDir, ".dsh-config.json"), "utf8"));
187
+ assert.equal(cfg.phone, "account-B");
188
+ assert.equal(cfg.device_id, undefined, "设备身份必须轮换");
189
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-bridge-state.json")), false,
190
+ "★旧账号的注册证据必须删除(否则面板一直谎报已连接,自愈永不纠正)");
191
+ } finally {
192
+ host.close();
193
+ }
194
+ } finally {
195
+ await env.restore();
196
+ }
197
+ });
198
+
199
+ test("退出登录:必须停 bridge 并作废设备身份(否则旧账号的隧道还在对外服务)", async () => {
200
+ const env = await makeEnv();
201
+ try {
202
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
203
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
204
+ phone: "account-A", password: "pw", device_id: "dev-1",
205
+ device_private_key: "pk", device_public_key: "pub", api_url: "http://127.0.0.1:1",
206
+ }));
207
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
208
+ device_id: "dev-1", phase: "online", tunnel_registered_at: 1,
209
+ }));
210
+ const routes = boot(env.relayDir);
211
+ const { host, base } = await serve(routes);
212
+ try {
213
+ const { body } = await json(`${base}/dsh-remote/logout`, { method: "POST" });
214
+ assert.equal(body.ok, true);
215
+ const cfg = JSON.parse(await readFile(path.join(env.relayDir, ".dsh-config.json"), "utf8"));
216
+ assert.equal(cfg.phone, undefined, "账号应被清除");
217
+ assert.equal(cfg.password, undefined);
218
+ assert.equal(cfg.device_id, undefined, "★设备身份必须一起作废");
219
+ assert.equal(existsSync(path.join(env.relayDir, ".dsh-bridge-state.json")), false, "★状态记录应清除");
220
+ } finally {
221
+ host.close();
222
+ }
223
+ } finally {
224
+ await env.restore();
225
+ }
226
+ });
227
+
228
+ // ─────────── ③ launchd 域名阶梯(事故的直接技术原因) ───────────
229
+
230
+ test("launchd:作业在 user/<uid> 域时也必须被认成「运行中」(旧实现只看 gui/<uid>)", { skip: process.platform !== "darwin" }, async () => {
231
+ const env = await makeEnv();
232
+ delete process.env.DSH_RELAY_SKIP_SERVICE; // 本用例要真的去调(假)launchctl
233
+ try {
234
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
235
+ await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
236
+ phone: "account-A", password: "pw", api_url: "http://127.0.0.1:1",
237
+ }));
238
+ const routes = boot(env.relayDir);
239
+ const { host, base } = await serve(routes);
240
+ try {
241
+ const { body } = await json(`${base}/dsh-remote/status`);
242
+ assert.equal(body.service.launchd.running, true,
243
+ "★安装器把作业装在 user/<uid>:插件必须查到这个域,否则会误判「未运行」并反复重复启动");
244
+ assert.equal(body.service.launchd.pid, 4242);
245
+ const log = existsSync(env.launchLog) ? readFileSync(env.launchLog, "utf8") : "";
246
+ assert.match(log, new RegExp(`print user/${process.getuid()}/com\\.dshremote\\.bridge`), "应先查 user 域");
247
+ } finally {
248
+ host.close();
249
+ }
250
+ } finally {
251
+ await env.restore();
252
+ }
253
+ });
254
+
255
+ test("源码契约:启动/停止都要覆盖两个 launchd 域(只认 gui 就是本次事故)", () => {
256
+ assert.ok(/function launchDomains\(\)/.test(INDEX_SRC), "应有统一的域阶梯");
257
+ const startFn = INDEX_SRC.slice(INDEX_SRC.indexOf("function startBridge(relayDir)"), INDEX_SRC.indexOf("function scheduleRuntime("));
258
+ assert.ok(/for \(const domain of launchDomains\(\)\)/.test(startFn), "启动必须遍历域阶梯(user 优先)");
259
+ assert.ok(/com\.dshremote\.bridge`\)\}/.test(startFn) || /launchDomains\(\)/.test(startFn));
260
+ const stopFn = INDEX_SRC.slice(INDEX_SRC.indexOf("/** 停止 bridge"), INDEX_SRC.indexOf("function uninstallRuntime("));
261
+ assert.ok(/launchTargets\(\)/.test(stopFn), "停止必须覆盖两个域(作业可能在 user 域)");
262
+ assert.ok(/resetBridgeForAccountChange/.test(INDEX_SRC), "应有「账号变更 → 停旧 bridge + 清过期状态」的统一入口");
263
+ // 自愈必须判账号归属,而不是只看"有没有进程在跑"
264
+ const ensureFn = INDEX_SRC.slice(INDEX_SRC.indexOf("function ensureConnection("), INDEX_SRC.indexOf("// ---------- 匿名装机"));
265
+ assert.ok(/bridgeMatchesCurrentAccount\(relayDir, cfg\)/.test(ensureFn), "自愈必须判账号归属");
266
+ assert.ok(/action: "restart"/.test(ensureFn), "账号不符时应重启而不是返回 none");
267
+ });
268
+
269
+ test("源码契约:watcher(dsh-setup run)也要能发现账号变化并重启子进程", () => {
270
+ assert.ok(/function accountFingerprint\(/.test(SETUP_SRC), "应有账号指纹");
271
+ assert.ok(/let bridgeAccountFp = ""/.test(SETUP_SRC), "应记录当前子进程用的凭据");
272
+ assert.ok(/bridgeAccountFp !== fp/.test(SETUP_SRC), "配置里的账号一变就要重启 bridge 子进程");
273
+ assert.ok(/账号配置已变化 → 重启 bridge/.test(SETUP_SRC), "应打日志说明为什么重启");
274
+ // pid 文件与去重护栏不再只服务 Windows(脱离进程兜底在 POSIX 上也用)
275
+ assert.ok(!/if \(IS_WIN\) writePidFile\(BRIDGE_PID_FILE/.test(SETUP_SRC), "pid 文件应全平台写");
276
+ assert.ok(!/ if \(IS_WIN\) \{\n const other = readPidFile\(WATCHER_PID_FILE\)/.test(SETUP_SRC), "去重护栏应全平台生效");
277
+ });
278
+
279
+ test("限流:429 不得被当成「密码错」,且 watcher 要退避而不是空撞", () => {
280
+ const bridge = readFileSync(path.join(HERE, "..", "..", "..", "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8");
281
+ assert.ok(/r\.status === 429/.test(bridge), "bridge 必须单独处理 429");
282
+ assert.ok(/retry_after_ms/.test(bridge), "应读取服务端给的 retry_after_ms");
283
+ assert.ok(/凭据没错|无需改密码/.test(bridge), "必须明确告诉用户不是密码问题(旧文案会误导人去改密码)");
284
+ const rateStart = bridge.indexOf("r.status === 429");
285
+ const rateBlock = bridge.slice(rateStart, bridge.indexOf("process.exit(3)", rateStart)); // 只取 429 分支自身
286
+ assert.ok(/\.dsh-login-ratelimited/.test(rateBlock), "应落盘「最早可重试时刻」供 watcher 退避");
287
+ // 只看**真实打印**的行(注释里引用旧文案是可以的)
288
+ const emitted = rateBlock.split("\n")
289
+ .filter((l) => !/^\s*(\/\/|\*)/.test(l))
290
+ .filter((l) => /请检查 DSH_BRIDGE_EMAIL/.test(l));
291
+ assert.equal(emitted.length, 0, `429 分支不得再提示去检查密码:${emitted.join(" / ")}`);
292
+ // 登录成功要清掉退避提示(服务端成功即清零)
293
+ assert.ok(/rmSync\(path\.join\(path\.dirname\(CONFIG_PATH\), "\.dsh-login-ratelimited"/.test(bridge));
294
+ // watcher 侧:限流窗口内不反复拉起 bridge
295
+ assert.ok(/function loginRateLimitUntil\(/.test(SETUP_SRC), "watcher 应能读限流退避时间");
296
+ assert.ok(/if \(rlUntil > Date\.now\(\)\)/.test(SETUP_SRC), "窗口内应直接返回,不空撞");
297
+ assert.ok(/凭据无需修改/.test(SETUP_SRC), "提示要说清楚不是凭据问题");
298
+ });
299
+
300
+ test("前端:登录/注册落盘后必须检查 bridgeRestart,不再静默失败", () => {
301
+ assert.ok(/function applyAccountSaved\(/.test(CLIENT_SRC), "应有统一的账号落盘提示");
302
+ assert.ok(/br\.ok === false/.test(CLIENT_SRC), "必须检查 bridgeRestart.ok === false");
303
+ assert.ok(/未能自动重启/.test(CLIENT_SRC), "失败时给出可见提示");
304
+ // 只数**调用点**(第二个实参是字符串字面量),别把函数定义也算进去
305
+ const calls = CLIENT_SRC.match(/applyAccountSaved\(cfg, "/g) || [];
306
+ assert.equal(calls.length, 2, `登录与注册两条路径都要用它(实际 ${calls.length} 处)`);
307
+ });
@@ -87,7 +87,11 @@ async function plantProfile(home, { extraPatch = "", unmarked = false } = {}) {
87
87
  fs.writeFileSync(path.join(local, "lib", "index.js"), "export const x = 1;\n");
88
88
  fs.symlinkSync(local, path.join(profile, "node_modules", PLUGIN_ID), "dir");
89
89
  fs.mkdirSync(path.join(home, "relay"), { recursive: true });
90
- fs.writeFileSync(path.join(home, "relay", ".dsh-config.json"), JSON.stringify({ phone: "13800000000", password: "pw" }));
90
+ // ⚠️ api_url 必须指向本地死端口:这些是**假账号**,一旦按默认地址请求生产,
91
+ // /api/device-login 会带着假密码失败,而服务端登录限流是按出口 IP 计的(5 次/15 分钟)——
92
+ // 实测结果是把**本机真实的 bridge** 连坐进限流(2026-09-19 事故)。
93
+ fs.writeFileSync(path.join(home, "relay", ".dsh-config.json"),
94
+ JSON.stringify({ phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1" }));
91
95
  return { profile, patchFile: path.join(profile, "cordis.patch.yml") };
92
96
  }
93
97
 
@@ -209,6 +213,69 @@ test("CLI 卸载(dsh-remote plugin --uninstall):同样必须留下合法
209
213
  }
210
214
  });
211
215
 
216
+ /** 本机是否有真正的 dsh CLI(CI 通常没有 → 该用例自动跳过)。 */
217
+ function dshAvailable() {
218
+ try { return spawnSync("dsh", ["--help"], { stdio: "ignore", timeout: 20000 }).status === 0; }
219
+ catch { return false; }
220
+ }
221
+ const HAS_DSH = dshAvailable();
222
+
223
+ /**
224
+ * 最强的一条:把卸载结果交给**真实 dsh 解析器**判定。
225
+ * 报告的 Fix E 正是要求这个 —— 断言对象不是"我们写了什么",而是"dsh 能不能 compose 出插件树"
226
+ *(compose 成功 ≈ dsh web 能启动)。另外附一条反例:纯注释文档必须被 dsh 拒绝,
227
+ * 否则这条用例就是空断言(证明它有牙齿)。
228
+ */
229
+ test("(需本机装有 dsh)卸载后的 patch 必须能被真实 dsh 解析:--dump-config 成功", { skip: !HAS_DSH && "本机没有 dsh CLI" }, async () => {
230
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-un-real-"));
231
+ const prevHome = process.env.HOME;
232
+ const prevSkip = process.env.DSH_RELAY_SKIP_SERVICE;
233
+ process.env.HOME = home;
234
+ process.env.DSH_RELAY_SKIP_SERVICE = "1";
235
+ const dshHome = path.join(home, "dshhome");
236
+ const profile = path.join(dshHome, "profiles", "web");
237
+ fs.mkdirSync(profile, { recursive: true });
238
+ fs.writeFileSync(path.join(profile, "package.json"), JSON.stringify({ name: "web", version: "0.0.0", dsh: { profile: {} } }));
239
+ const patchFile = path.join(profile, "cordis.patch.yml");
240
+ const comments = "# Your patch layer for this dsh profile, applied after every bundle layer:\n# a top-level YAML array of loader patch entries\n";
241
+ const runDsh = () => spawnSync("dsh", ["--profile", "web", "--dump-config"], {
242
+ encoding: "utf8", timeout: 60000, env: { ...process.env, DSH_HOME: dshHome },
243
+ });
244
+ try {
245
+ // 反例(负向对照):纯注释 → dsh 必须报错(否则本用例没有牙齿)
246
+ fs.writeFileSync(patchFile, comments);
247
+ const negative = runDsh();
248
+ assert.notEqual(negative.status, 0, "纯注释文档必须被 dsh 拒绝(这就是用户遇到的启动失败)");
249
+ assert.match(String(negative.stderr || "") + String(negative.stdout || ""), /top-level YAML array/);
250
+
251
+ // 正向:我们的安装器写入的 patch → dsh 必须接受
252
+ fs.writeFileSync(patchFile, `${comments}[]\n`);
253
+ fs.mkdirSync(path.join(profile, "node_modules"), { recursive: true });
254
+ fs.writeFileSync(path.join(profile, "node_modules", "probe"), "");
255
+ const ok = runDsh();
256
+ assert.equal(ok.status, 0, `安装态 patch 应能被 dsh 接受:${ok.stderr}`);
257
+
258
+ // 关键:走一遍真实卸载(插件路由),卸载后 dsh 仍必须能 compose
259
+ const { patchFile: installedPatch } = await (async () => {
260
+ const { profile: p2, patchFile: pf } = await plantProfile(home);
261
+ return { patchFile: pf };
262
+ })();
263
+ const routes = boot(path.join(home, "relay"));
264
+ const { host, base } = await serve(routes);
265
+ try {
266
+ await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
267
+ } finally { host.close(); }
268
+ fs.copyFileSync(installedPatch, patchFile); // 把"卸载后的 patch"交给真实 dsh 判定
269
+ const after = runDsh();
270
+ assert.equal(after.status, 0, `★卸载后的 patch 必须仍能被 dsh 接受(否则 dsh web 起不来):${after.stderr}`);
271
+ assert.ok(isTopLevelArrayDoc(fs.readFileSync(patchFile, "utf8")));
272
+ } finally {
273
+ if (prevSkip === undefined) delete process.env.DSH_RELAY_SKIP_SERVICE; else process.env.DSH_RELAY_SKIP_SERVICE = prevSkip;
274
+ process.env.HOME = prevHome;
275
+ fs.rmSync(home, { recursive: true, force: true });
276
+ }
277
+ });
278
+
212
279
  // ─────────────────────── ② 卸载顺序与阻塞(缺陷二) ───────────────────────
213
280
 
214
281
  test("源码契约:卸载必须先卸插件、再关自愈、最后才把慢活交子进程", () => {
@@ -460,6 +460,24 @@ test("前端:bridge-status 轮询失败不再静默(旧版让面板永久停
460
460
  assert.ok(/connFailRef\.v = 0/.test(CLIENT_SRC), "成功必须清零(一次抖动不该把面板染红)");
461
461
  });
462
462
 
463
+ test("测试隔离:套件不得访问任何外部地址(生产限流会被测试污染)", () => {
464
+ // 事故(2026-09-19):有用例在配置里写了假账号却没覆盖 api_url → 按默认地址请求**生产中继**,
465
+ // 其中 /api/device-login 带假密码必然失败,而服务端登录限流按**出口 IP** 计(5 次/15 分钟)——
466
+ // 结果不仅污染生产审计,还让**本机自己的 bridge 被连坐**(拿不到 JWT),
467
+ // 真机表现成"切换账号后设备一直登记不上去",排查方向被彻底带偏。
468
+ const repoRoot = path.join(HERE, "..", "..", "..");
469
+ const guard = path.join(repoRoot, "scripts", "test-net-guard.cjs");
470
+ assert.ok(existsSync(guard), "必须有测试期网络护栏脚本");
471
+ const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
472
+ for (const name of ["test:router", "test:plugin", "test:bridge"]) {
473
+ assert.match(String(pkg.scripts[name]), /test-net-guard\.cjs/, `${name} 必须注入网络护栏`);
474
+ }
475
+ // 护栏本体:只放行本地地址,其余一律阻断
476
+ const src = readFileSync(guard, "utf8");
477
+ assert.ok(/ENETUNREACH/.test(src), "护栏应阻断外部请求");
478
+ assert.ok(/LOCAL_HOST/.test(src), "护栏应只放行本地地址");
479
+ });
480
+
463
481
  test("前端:平台文案不再默认 macOS(Windows 用户看不懂「自启动服务」)", () => {
464
482
  assert.ok(/serviceManager === "detached"/.test(CLIENT_SRC), "面板应识别 Windows 的 detached 模式");
465
483
  assert.match(CLIENT_SRC, /Windows 任务计划程序 dsh-remote-bridge/, "卸载说明要写明 Windows 侧会删掉什么");
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 测试期网络护栏(NODE_OPTIONS=--require 注入,见 package.json 的 test 脚本)。
3
+ *
4
+ * 为什么必须有(2026-09-19 实测事故):
5
+ * 有测试用例在 profile 配置里写了**假账号**(phone/password)却没覆盖 `api_url`,
6
+ * 于是插件按默认地址去请求**生产中继** `https://n.risegao.cn:…`。其中
7
+ * `/api/device-login` 带着假密码必然失败,而服务端的登录限流是**按出口 IP**
8
+ * 统计「5 次失败 / 15 分钟」——结果是:
9
+ * · 生产审计日志被测试污染;
10
+ * · **本机自己的 bridge 被连坐**:限流窗口内它拿不到 JWT(429),
11
+ * 真机表现为「切换账号后设备一直登记不上去」,排查方向被彻底带偏。
12
+ *
13
+ * 现在的行为:测试期**一律阻断**任何非本地请求(throw ENETUNREACH),
14
+ * 并在进程退出时把被拦下的地址打印出来,提醒用例作者去补 `api_url`。
15
+ * 这既不改变用例结论(需要断网的分支照常走失败路径),又保证测试永远碰不到外部服务。
16
+ */
17
+ const realFetch = globalThis.fetch;
18
+ const blocked = new Set();
19
+
20
+ const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0|::1)(:\d+)?$/i;
21
+
22
+ function hostOf(input) {
23
+ try {
24
+ const url = typeof input === "string" ? input : input && input.url ? input.url : String(input);
25
+ return new URL(url).host;
26
+ } catch {
27
+ return "";
28
+ }
29
+ }
30
+
31
+ globalThis.fetch = function guardedFetch(input, init) {
32
+ const host = hostOf(input);
33
+ if (host && !LOCAL_HOST.test(host)) {
34
+ const raw = typeof input === "string" ? input : (input && input.url) || "";
35
+ blocked.add(String(raw).replace(/(token|password|secret)=[^&]*/gi, "$1=***").slice(0, 160));
36
+ const err = new Error(`test-net-guard: 测试期禁止访问外部地址 ${host}(请在用例里把 api_url 指向本地假服务)`);
37
+ err.code = "ENETUNREACH";
38
+ return Promise.reject(err);
39
+ }
40
+ return realFetch.call(this, input, init);
41
+ };
42
+
43
+ // 子进程(安装器 / 清理助手等)继承 NODE_OPTIONS,也要能看到告警
44
+ process.on("exit", () => {
45
+ if (!blocked.size) return;
46
+ process.stderr.write(
47
+ `\n[test-net-guard] ⚠️ 有 ${blocked.size} 个外部请求被拦下(用例应改用本地假服务,别打生产):\n `
48
+ + [...blocked].join("\n ") + "\n\n");
49
+ });