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

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,600 @@
1
+ // 匿名装机/连接遥测(客户端半)回归 —— 契约 + 隐私边界(2026-09)。
2
+ //
3
+ // 现场(2026-09 生产数据):11 个新注册用户里只有 3 人把设备连上——6 人电脑端从未安装、2 人装了但
4
+ // bridge 没连上;而「装不上」的人没有任何账号、也就没有任何数据,「本地 OK、新机器失败」无法归因。
5
+ // 本用例锁死这条补上的通道:
6
+ // ① 触发点正确:plugin_loaded / install_started / install_failed(fail_code) / runtime_ready /
7
+ // bridge_started / bridge_registered / tunnel_disconnected / first_remote_ok(只一次) / panel_opened;
8
+ // ② 契约形状:POST <api_url>/api/telemetry/events + content-type + x-dsh-client: dsh-remote/<version>,
9
+ // **无 Authorization**;body { install_id, source, events[] },单批 ≤20、≤32KB、事件名/失败码白名单;
10
+ // ③ install_id:本机随机 UUID、落盘 0600、复用同一值(不跨机器派生);
11
+ // ④ 队列:0600、上限 200 丢最旧;≥5 条立即发、否则 60s 心跳;失败退避、6 次后丢弃该批;
12
+ // ⑤ 开关:DSH_REMOTE_TELEMETRY=0 → 零请求零文件(连 install_id 都不生成);
13
+ // ⑥ 隐私:payload 里不得出现 phone/password/hostname/machine_fp/IP/路径等任何禁止字段(含对抗性 extra);
14
+ // ⑦ 静默:发送失败/退避/关闭时必须不影响面板路由与连接流程(响应照常返回)。
15
+ //
16
+ // 手法与 connect-loop.test.mjs 一致:真实分支 + PATH 上的假 launchctl/pgrep/ps/npx,绝不碰本机真实服务。
17
+ import assert from "node:assert/strict";
18
+ import http from "node:http";
19
+ import { chmodSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
20
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import test from "node:test";
24
+ import { apply, __telemetryInternals as TELEMETRY } from "../lib/index.js";
25
+
26
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
+
28
+ /** 轮询等待条件成立(避免固定 sleep 在并行/高负载下假失败)。 */
29
+ async function waitFor(fn, { timeout = 5000, step = 10 } = {}) {
30
+ const deadline = Date.now() + timeout;
31
+ for (;;) {
32
+ const v = await fn();
33
+ if (v) return v;
34
+ if (Date.now() > deadline) return false;
35
+ await sleep(step);
36
+ }
37
+ }
38
+
39
+ const ALLOWED_EVENT_KEYS = new Set(["name", "at", "version", "os", "arch", "node", "fail_code"]);
40
+ /** 禁止出现在遥测 payload 里的字段/取值(隐私边界;见 docs/telemetry.md「不采集什么」)。 */
41
+ const FORBIDDEN = [
42
+ "phone", "password", "passwd", "email", "token", "secret", "authorization", "bearer",
43
+ "hostname", "username", "machine_fp", "device_id", "/Users/", "/home/", "13800000000",
44
+ ];
45
+
46
+ const FAKE_LAUNCHCTL = `#!/bin/sh
47
+ printf '%s\\n' "$*" >> "$DSH_TEST_LAUNCH_LOG"
48
+ case "$1" in
49
+ print)
50
+ printf 'state = %s\\n' "$DSH_TEST_STATE"
51
+ printf 'pid = %s\\n' "$DSH_TEST_PID"
52
+ printf 'runs = %s\\n' "$DSH_TEST_RUNS"
53
+ printf 'last exit code = %s\\n' "$DSH_TEST_EXIT"
54
+ ;;
55
+ list)
56
+ printf '%s\\n' "$DSH_TEST_LIST"
57
+ ;;
58
+ esac
59
+ exit 0
60
+ `;
61
+ const FAKE_PGREP = `#!/bin/sh
62
+ if [ -n "$DSH_TEST_PGREP" ]; then printf '%s\\n' "$DSH_TEST_PGREP"; exit 0; fi
63
+ exit 1
64
+ `;
65
+ const FAKE_PS = `#!/bin/sh
66
+ printf '1\\n'
67
+ exit 0
68
+ `;
69
+ /** 假 npx:只记录参数,绝不做真实安装;退出码由 DSH_TEST_NPX_EXIT 控制。 */
70
+ const FAKE_NPX = `#!/bin/sh
71
+ printf '%s\\n' "$*" >> "$DSH_TEST_NPX_LOG"
72
+ exit ${"${DSH_TEST_NPX_EXIT:-0}"}
73
+ `;
74
+
75
+ const SECRET = "secret-telemetry-1";
76
+ const TMP_DIRS = [];
77
+
78
+ /** 假账号 API:只关心 /api/telemetry/events(可切换 500/200 以验证退避与出队)。 */
79
+ function startFakeRelay(opts = {}) {
80
+ const telemetry = []; // {headers, body, raw}
81
+ const seen = [];
82
+ let telemetryStatus = opts.telemetryStatus === undefined ? 200 : opts.telemetryStatus;
83
+ const srv = http.createServer(async (req, res) => {
84
+ const url = new URL(req.url, "http://x");
85
+ const chunks = [];
86
+ for await (const c of req) chunks.push(c);
87
+ const raw = Buffer.concat(chunks).toString("utf8");
88
+ const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
89
+ seen.push({ method: req.method, path: url.pathname });
90
+ if (req.method === "POST" && url.pathname === "/api/telemetry/events") {
91
+ let body = null;
92
+ try { body = JSON.parse(raw); } catch { body = null; }
93
+ telemetry.push({ headers: req.headers, body, raw });
94
+ if (telemetryStatus !== 200) return send(telemetryStatus, { error: { code: "boom" } });
95
+ return send(200, { ok: true });
96
+ }
97
+ if (req.method === "GET" && url.pathname === "/api/public-config") {
98
+ return send(200, { service: "dsh-remote", app_url: "https://app.test/", api_url: `http://127.0.0.1:${srv.address().port}`, bridge_secret: SECRET });
99
+ }
100
+ if (req.method === "POST" && url.pathname === "/api/device-login") return send(200, { token: "jwt-tel" });
101
+ if (req.method === "GET" && url.pathname === "/api/devices") return send(200, { devices: [] });
102
+ return send(404, { error: { code: "not_found" } });
103
+ });
104
+ return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({
105
+ srv, telemetry, seen, port: srv.address().port,
106
+ setTelemetryStatus(s) { telemetryStatus = s; },
107
+ count(p) { return seen.filter((s) => s.path === p).length; },
108
+ })));
109
+ }
110
+
111
+ /**
112
+ * 隔离环境:假 HOME + 假 launchctl/pgrep/ps/npx + 临时 relayDir。
113
+ * 默认加速节奏(DSH_REMOTE_TELEMETRY_MS)+ 走真实分支(unset DSH_RELAY_SKIP_SERVICE)。
114
+ */
115
+ async function setup({ runtime = true, telemetryMs = "10", skipService = false } = {}) {
116
+ const root = await mkdtemp(path.join(os.tmpdir(), "dsh-telemetry-"));
117
+ TMP_DIRS.push(root);
118
+ const fakeHome = path.join(root, "home");
119
+ const fakeBin = path.join(root, "bin");
120
+ const relayDir = path.join(root, "relay");
121
+ await mkdir(fakeBin, { recursive: true });
122
+ await mkdir(fakeHome, { recursive: true });
123
+ await mkdir(relayDir, { recursive: true });
124
+ const launchLog = path.join(root, "launchctl.log");
125
+ const npxLog = path.join(root, "npx.log");
126
+ await writeFile(launchLog, "");
127
+ await writeFile(npxLog, "");
128
+ for (const [name, script] of [["launchctl", FAKE_LAUNCHCTL], ["pgrep", FAKE_PGREP], ["ps", FAKE_PS], ["npx", FAKE_NPX]]) {
129
+ await writeFile(path.join(fakeBin, name), script);
130
+ await chmodSync(path.join(fakeBin, name), 0o755);
131
+ }
132
+ if (runtime) await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub");
133
+
134
+ const saved = {};
135
+ const KEYS = ["HOME", "PATH", "DSH_TEST_LAUNCH_LOG", "DSH_TEST_NPX_LOG", "DSH_TEST_STATE", "DSH_TEST_PID",
136
+ "DSH_TEST_RUNS", "DSH_TEST_EXIT", "DSH_TEST_LIST", "DSH_TEST_PGREP", "DSH_TEST_NPX_EXIT",
137
+ "DSH_RELAY_SKIP_SERVICE", "DSH_SETUP_NPX_DIR", "DSH_RELAY_SELFHEAL_MS", "DSH_BRIDGE_INSTALL_SOURCE",
138
+ "DSH_REMOTE_TELEMETRY", "DSH_REMOTE_TELEMETRY_MS", "DSH_RELAY_RESTART_DRYRUN"];
139
+ for (const k of KEYS) saved[k] = process.env[k];
140
+ process.env.HOME = fakeHome;
141
+ process.env.PATH = `${fakeBin}:${saved.PATH}`;
142
+ process.env.DSH_TEST_LAUNCH_LOG = launchLog;
143
+ process.env.DSH_TEST_NPX_LOG = npxLog;
144
+ process.env.DSH_TEST_STATE = "stopped";
145
+ process.env.DSH_TEST_PID = "0";
146
+ process.env.DSH_TEST_RUNS = "0";
147
+ process.env.DSH_TEST_EXIT = "0";
148
+ process.env.DSH_TEST_LIST = "";
149
+ process.env.DSH_TEST_NPX_EXIT = "0";
150
+ delete process.env.DSH_TEST_PGREP;
151
+ delete process.env.DSH_BRIDGE_INSTALL_SOURCE;
152
+ process.env.DSH_SETUP_NPX_DIR = fakeBin;
153
+ process.env.DSH_REMOTE_TELEMETRY = "1"; // 显式开启(用例要验证真实发送;关掉的分支另有专门用例)
154
+ if (telemetryMs) process.env.DSH_REMOTE_TELEMETRY_MS = telemetryMs; else delete process.env.DSH_REMOTE_TELEMETRY_MS;
155
+ process.env.DSH_RELAY_SELFHEAL_MS = "60000"; // 让后台 watcher 不参与用例节奏
156
+ if (skipService) process.env.DSH_RELAY_SKIP_SERVICE = "1"; else delete process.env.DSH_RELAY_SKIP_SERVICE;
157
+
158
+ return {
159
+ root, fakeHome, fakeBin, relayDir, launchLog, npxLog,
160
+ setState(v) { process.env.DSH_TEST_STATE = v; },
161
+ async restore() {
162
+ for (const [k, v] of Object.entries(saved)) {
163
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
164
+ }
165
+ TELEMETRY.reset(relayDir);
166
+ await rm(root, { recursive: true, force: true });
167
+ },
168
+ };
169
+ }
170
+
171
+ /** 装载插件路由(收集 disposer:否则自愈/遥测定时器会跨用例继续跑)。 */
172
+ function boot(relayDir) {
173
+ const routes = new Map();
174
+ const disposers = [];
175
+ apply({
176
+ webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
177
+ effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
178
+ logger: { info() {}, warn() {} },
179
+ }, { relayDir });
180
+ routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
181
+ return routes;
182
+ }
183
+
184
+ async function serve(routes) {
185
+ const host = http.createServer((req, res) => {
186
+ const handler = routes.get(new URL(req.url, "http://x").pathname);
187
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
188
+ });
189
+ await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
190
+ return { host, base: `http://127.0.0.1:${host.address().port}` };
191
+ }
192
+
193
+ async function writeConfig(relayDir, relayPort, extra = {}) {
194
+ await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
195
+ phone: "13800000000", password: "pw", device_id: "dev-telemetry",
196
+ api_url: `http://127.0.0.1:${relayPort}`, bridge_secret: SECRET,
197
+ ...extra,
198
+ }));
199
+ }
200
+
201
+ const bridgeStatus = async (base) => (await (await fetch(`${base}/dsh-remote/bridge-status`)).json());
202
+ const telemetryFilesIn = (dir) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.startsWith(".telemetry")) : []);
203
+ const readOrEmpty = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
204
+
205
+ /**
206
+ * 「本机已产生过的事件」= 已送达的(假中继收到的)∪ 仍在本地队列里的。
207
+ * 按 name|at|version 去重,避免「正在发送中的那一批」在队列与中继两边被重复计数。
208
+ */
209
+ function observed(relay, relayDir) {
210
+ const map = new Map();
211
+ const add = (ev) => { if (ev && ev.name) map.set(`${ev.name}|${ev.at}|${ev.version || ""}`, ev); };
212
+ for (const req of relay.telemetry) for (const ev of (req.body?.events || [])) add(ev);
213
+ for (const ev of TELEMETRY.queueOf(relayDir)) add(ev);
214
+ const list = [...map.values()];
215
+ return { list, names: list.map((e) => e.name), count: (n) => list.filter((e) => e.name === n).length, has: (n) => list.some((e) => e.name === n) };
216
+ }
217
+
218
+ /** 断言一次请求完全符合冻结契约与隐私边界(形状 + 白名单 + 禁止字段)。 */
219
+ function assertContractRequest(req, { eventNames, failCodes }) {
220
+ // 头:content-type + x-dsh-client,且**不带 Authorization**(完全匿名)
221
+ assert.match(String(req.headers["content-type"] || ""), /application\/json/, "必须声明 application/json");
222
+ assert.match(String(req.headers["x-dsh-client"] || ""), /^dsh-remote\/\d+\.\d+\.\d+/, "x-dsh-client 必须是 dsh-remote/<version>");
223
+ assert.equal(req.headers.authorization, undefined, "匿名通道:绝不能带 Authorization");
224
+ assert.ok(!/authorization/i.test(req.raw), "payload 里不得出现 authorization");
225
+ // body 形状
226
+ assert.match(String(req.body.install_id), /^[0-9a-f-]{36}$/i, "install_id 必须是随机 UUID");
227
+ assert.equal(req.body.source, "plugin");
228
+ assert.ok(Array.isArray(req.body.events) && req.body.events.length > 0, "events 必须是非空数组");
229
+ assert.ok(req.body.events.length <= TELEMETRY.batchMax, "单批 ≤ 20 条");
230
+ assert.ok(Buffer.byteLength(req.raw, "utf8") <= TELEMETRY.bodyMax, "body ≤ 32KB");
231
+ for (const ev of req.body.events) {
232
+ assert.ok(eventNames.includes(ev.name), "事件名必须在白名单内:" + ev.name);
233
+ for (const k of Object.keys(ev)) assert.ok(ALLOWED_EVENT_KEYS.has(k), "payload 出现契约外字段:" + k);
234
+ assert.equal(typeof ev.at, "number");
235
+ assert.equal(typeof ev.version, "string");
236
+ assert.equal(typeof ev.os, "string");
237
+ assert.equal(typeof ev.arch, "string");
238
+ assert.match(String(ev.node), /^\d+$/, "node 只能是主版本号数字字符串");
239
+ if (ev.name === "install_failed" || ev.name === "update_failed") {
240
+ assert.ok(failCodes.includes(ev.fail_code), "fail_code 必须在白名单内:" + ev.fail_code);
241
+ }
242
+ }
243
+ // 禁止字段(含取值形态):hostname / 用户名 / 路径 / 手机号 / 凭据 / 设备指纹 / IP 一律不得出现
244
+ for (const bad of FORBIDDEN) {
245
+ assert.ok(!req.raw.toLowerCase().includes(bad.toLowerCase()), "遥测 payload 不得包含禁止字段:" + bad);
246
+ }
247
+ assert.ok(!/"\d{1,3}(\.\d{1,3}){3}"/.test(req.raw), "不得上报 IP");
248
+ }
249
+
250
+ test("触发点:plugin_loaded → install_started → runtime_ready → bridge_started → bridge_registered/first_remote_ok", async () => {
251
+ const relay = await startFakeRelay();
252
+ const env = await setup({ runtime: false }); // 市场只装了面板插件:缺运行环境
253
+ try {
254
+ await writeConfig(env.relayDir, relay.port);
255
+ const routes = boot(env.relayDir);
256
+ // ① apply() 一次 → plugin_loaded;② 缺运行环境 → 后台补装 → install_started
257
+ let seen = observed(relay, env.relayDir);
258
+ assert.ok(seen.has("plugin_loaded"), "插件装载必须入队 plugin_loaded,实际:" + seen.names.join(","));
259
+ assert.ok(seen.has("install_started"), "缺运行环境必须入队 install_started,实际:" + seen.names.join(","));
260
+
261
+ const { host, base } = await serve(routes);
262
+ try {
263
+ // ③ 运行环境补齐(dsh-setup.mjs 落地)→ 面板轮询触达探针 → runtime_ready
264
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
265
+ let r = await bridgeStatus(base);
266
+ assert.equal(r.ok, true);
267
+ assert.equal(r.connect.phase, "starting", "环境在但服务没起 → starting");
268
+ seen = observed(relay, env.relayDir);
269
+ assert.ok(seen.has("runtime_ready"), "环境补齐后必须入队 runtime_ready,实际:" + seen.names.join(","));
270
+ assert.ok(seen.has("bridge_started"), "阶段 starting 必须入队 bridge_started,实际:" + seen.names.join(","));
271
+
272
+ // ④ 反复轮询不得重复计数(每进程每阶段只发一次)
273
+ await bridgeStatus(base);
274
+ await bridgeStatus(base);
275
+ assert.equal(observed(relay, env.relayDir).count("bridge_started"), 1, "bridge_started 每进程只一条");
276
+
277
+ // ⑤ 中继注册成功(状态文件 tunnel_registered_at + 进程在跑 + 账号设备表已登记)→ online
278
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({
279
+ device_id: "dev-telemetry", phase: "online", tunnel_registered_at: Date.now(), account_bound_at: Date.now(),
280
+ }));
281
+ env.setState("running");
282
+ process.env.DSH_TEST_PID = String(process.pid);
283
+ process.env.DSH_TEST_PGREP = "4242 /path/dsh-bridge.mjs";
284
+ r = await bridgeStatus(base);
285
+ assert.equal(r.connect.phase, "online", "注册证据齐备 → online");
286
+ seen = observed(relay, env.relayDir);
287
+ assert.ok(seen.has("bridge_registered"), "online 必须入队 bridge_registered,实际:" + seen.names.join(","));
288
+ assert.ok(seen.has("first_remote_ok"), "首次 online 且账号可见 → first_remote_ok,实际:" + seen.names.join(","));
289
+ await bridgeStatus(base);
290
+ seen = observed(relay, env.relayDir);
291
+ assert.equal(seen.count("bridge_registered"), 1);
292
+ assert.equal(seen.count("first_remote_ok"), 1, "first_remote_ok 只一次");
293
+
294
+ // ⑥ 面板打开(浏览器半调用的本地路由):每进程一次
295
+ await fetch(`${base}/dsh-remote/telemetry/panel-opened`, { method: "POST" });
296
+ await fetch(`${base}/dsh-remote/telemetry/panel-opened`, { method: "POST" });
297
+ await sleep(60); // 让进行中的批次落定,再统一统计
298
+ assert.equal(observed(relay, env.relayDir).count("panel_opened"), 1);
299
+ assert.equal(TELEMETRY.queueOf(env.relayDir).length, 0, "假中继 200 → 应全部送达并出队");
300
+ } finally { host.close(); routes.dispose(); }
301
+ } finally { await env.restore(); relay.srv.close(); }
302
+ });
303
+
304
+ test("契约:批量发送的形状/头/白名单完全符合冻结契约,且身份是匿名随机 UUID", async () => {
305
+ const relay = await startFakeRelay();
306
+ const env = await setup();
307
+ try {
308
+ await writeConfig(env.relayDir, relay.port);
309
+ const routes = boot(env.relayDir);
310
+ const { host, base } = await serve(routes);
311
+ try {
312
+ // 造够 ≥5 条事件 → 立即发送
313
+ TELEMETRY.record(env.relayDir, "install_started");
314
+ TELEMETRY.record(env.relayDir, "install_failed", { fail_code: "npm_unreachable" });
315
+ TELEMETRY.record(env.relayDir, "install_failed", { fail_code: "不在白名单里的原因" }); // → unknown
316
+ TELEMETRY.record(env.relayDir, "runtime_ready");
317
+ TELEMETRY.record(env.relayDir, "harness_restart");
318
+ TELEMETRY.record(env.relayDir, "update_failed", { fail_code: "npm_eacces" });
319
+
320
+ const sent = await waitFor(() => (relay.telemetry.length ? relay.telemetry : false));
321
+ assert.ok(sent, "队列 ≥5 条必须立即批量发送");
322
+ const req = sent[0];
323
+ assertContractRequest(req, { eventNames: TELEMETRY.eventNames, failCodes: TELEMETRY.failCodes });
324
+ const names = req.body.events.map((e) => e.name);
325
+ assert.ok(names.includes("install_started") && names.includes("runtime_ready") && names.includes("harness_restart"));
326
+ const unknown = req.body.events.find((e) => e.name === "install_failed" && e.fail_code === "unknown");
327
+ assert.ok(unknown, "白名单外的 fail_code 必须归为 unknown(原始文本不外发)");
328
+ // 未登录/无账号也照发:这是匿名通道,与账号体系无关
329
+ assert.equal(req.body.events.every((e) => !("device_id" in e)), true);
330
+ // 已送达 → 出队
331
+ await waitFor(() => TELEMETRY.queueOf(env.relayDir).length === 0);
332
+ assert.equal(TELEMETRY.queueOf(env.relayDir).length, 0, "成功送达必须出队");
333
+ } finally { host.close(); routes.dispose(); }
334
+ } finally { await env.restore(); relay.srv.close(); }
335
+ });
336
+
337
+ test("隐私审计:对抗性 extra 字段(手机号/hostname/machine_fp/路径/密码)一律进不了 payload", async () => {
338
+ const relay = await startFakeRelay();
339
+ const env = await setup();
340
+ try {
341
+ await writeConfig(env.relayDir, relay.port);
342
+ boot(env.relayDir);
343
+ // 直接往唯一的构造点灌脏数据:字段白名单 + 事件名校验必须把它们全部丢掉
344
+ TELEMETRY.record(env.relayDir, "install_started", { device_id: "dev-telemetry", machine_fp: "fp-abc" });
345
+ TELEMETRY.record(env.relayDir, "plugin_loaded", { hostname: "mac.local", username: "mac" });
346
+ TELEMETRY.record(env.relayDir, "panel_opened", { path: "/Users/mac/.dsh-remote", phone: "13800000000" });
347
+ TELEMETRY.record(env.relayDir, "update_started", { email: "a@b.c", token: "jwt", password: "pw" });
348
+ TELEMETRY.record(env.relayDir, "harness_restart", { ip: "1.2.3.4" });
349
+ // 白名单外的事件名:一条都不入队
350
+ assert.equal(TELEMETRY.record(env.relayDir, "user_login", { phone: "13800000000" }), false, "白名单外事件名必须被拒");
351
+ assert.equal(TELEMETRY.record(env.relayDir, "page_view", {}), false);
352
+
353
+ const sent = await waitFor(() => (relay.telemetry.length ? relay.telemetry : false));
354
+ assert.ok(sent, "应发出这一批");
355
+ for (const req of sent) assertContractRequest(req, { eventNames: TELEMETRY.eventNames, failCodes: TELEMETRY.failCodes });
356
+ const raw = sent.map((s) => s.raw).join("");
357
+ for (const bad of ["13800000000", "mac.local", "fp-abc", "/Users/mac", "1.2.3.4", "a@b.c"]) {
358
+ assert.ok(!raw.includes(bad), "payload 泄漏了敏感取值:" + bad);
359
+ }
360
+ // 源码级约束:唯一的 payload 构造点里不得出现禁止字段名
361
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
362
+ const start = src.indexOf("function telemetryEventOf(");
363
+ assert.ok(start > 0, "必须存在唯一的 payload 构造点 telemetryEventOf");
364
+ const body = src.slice(start, src.indexOf("\n}\n", start));
365
+ for (const bad of ["phone", "password", "hostname", "machine_fp", "username", "device_id", "\\bip\\b", "path"]) {
366
+ assert.ok(!new RegExp(bad).test(body), "payload 构造点里不得出现禁止字段:" + bad);
367
+ }
368
+ for (const allowed of ["name", "at", "version", "os", "arch", "node", "fail_code"]) {
369
+ assert.ok(body.includes(allowed), "payload 构造点应只含白名单字段:" + allowed);
370
+ }
371
+ } finally { await env.restore(); relay.srv.close(); }
372
+ });
373
+
374
+ test("开关:DSH_REMOTE_TELEMETRY=0 → 零请求零文件(连 install_id 都不生成)", async () => {
375
+ const relay = await startFakeRelay();
376
+ const env = await setup({ runtime: false });
377
+ try {
378
+ process.env.DSH_REMOTE_TELEMETRY = "0";
379
+ await writeConfig(env.relayDir, relay.port);
380
+ const routes = boot(env.relayDir);
381
+ const { host, base } = await serve(routes);
382
+ try {
383
+ await bridgeStatus(base);
384
+ await fetch(`${base}/dsh-remote/telemetry/panel-opened`, { method: "POST" });
385
+ await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
386
+ await bridgeStatus(base);
387
+ await sleep(120); // 给心跳/立即发送若干轮机会
388
+ assert.equal(relay.count("/api/telemetry/events"), 0, "关闭后不得发任何请求");
389
+ assert.deepEqual(telemetryFilesIn(env.relayDir), [], "关闭后不得生成任何 .telemetry-* 文件");
390
+ assert.equal(TELEMETRY.installId(env.relayDir), null, "关闭后不得生成 install_id");
391
+ assert.equal(TELEMETRY.queueOf(env.relayDir).length, 0);
392
+ assert.equal(TELEMETRY.record(env.relayDir, "install_started"), false, "关闭后不得入队");
393
+ } finally { host.close(); routes.dispose(); }
394
+ } finally { await env.restore(); relay.srv.close(); }
395
+ });
396
+
397
+ test("install_id:0600 落盘、跨批次复用;队列文件 0600、上限 200 丢最旧", async () => {
398
+ const relay = await startFakeRelay();
399
+ const env = await setup();
400
+ try {
401
+ // api 指向不可达端口:让发送失败,队列得以观察
402
+ await writeConfig(env.relayDir, 1);
403
+ TELEMETRY.reset(env.relayDir);
404
+ const id1 = TELEMETRY.installId(env.relayDir);
405
+ assert.match(String(id1), /^[0-9a-f-]{36}$/i, "install_id 必须是随机 UUID");
406
+ assert.equal(TELEMETRY.installId(env.relayDir), id1, "再次调用必须复用同一个 install_id");
407
+ assert.equal((statSync(path.join(env.relayDir, ".telemetry-install-id")).mode & 0o777), 0o600, "install_id 必须 0600");
408
+
409
+ // 队列上限:205 条 → 只留最后 200 条(丢最旧)
410
+ TELEMETRY.reset(env.relayDir);
411
+ TELEMETRY.record(env.relayDir, "plugin_loaded"); // 最旧,应被挤掉
412
+ for (let i = 0; i < 204; i++) TELEMETRY.record(env.relayDir, "panel_opened");
413
+ const q = TELEMETRY.queueOf(env.relayDir);
414
+ assert.equal(q.length, TELEMETRY.queueMax, "队列上限必须是 " + TELEMETRY.queueMax);
415
+ assert.ok(!q.some((e) => e.name === "plugin_loaded"), "超出上限必须丢最旧");
416
+ assert.equal((statSync(path.join(env.relayDir, ".telemetry-queue.json")).mode & 0o777), 0o600, "队列文件必须 0600");
417
+ await sleep(80); // 让进行中的失败发送收尾(失败不出队)
418
+ assert.equal(TELEMETRY.queueOf(env.relayDir).length, TELEMETRY.queueMax, "发送失败不得丢事件");
419
+ assert.ok(relay.count("/api/telemetry/events") === 0, "不该碰假中继(api 指向不可达端口)");
420
+ } finally { await env.restore(); relay.srv.close(); }
421
+ });
422
+
423
+ test("失败退避:不阻塞主流程;退避窗口内不重试;累计 6 次后丢弃该批", async () => {
424
+ const relay = await startFakeRelay({ telemetryStatus: 500 });
425
+ const env = await setup({ telemetryMs: "10" });
426
+ try {
427
+ await writeConfig(env.relayDir, relay.port);
428
+ const routes = boot(env.relayDir);
429
+ const { host, base } = await serve(routes);
430
+ try {
431
+ TELEMETRY.record(env.relayDir, "install_started");
432
+ TELEMETRY.record(env.relayDir, "install_failed", { fail_code: "npm_unreachable" });
433
+ TELEMETRY.record(env.relayDir, "runtime_ready");
434
+ TELEMETRY.record(env.relayDir, "bridge_started");
435
+ TELEMETRY.record(env.relayDir, "bridge_registered");
436
+ await waitFor(() => relay.count("/api/telemetry/events") >= 1);
437
+ const st = TELEMETRY.stateOf(env.relayDir);
438
+ assert.equal(st.attempts, 1, "第一次失败后应进入退避计数 1");
439
+ assert.ok(st.nextAt > Date.now(), "退避窗口内必须推后重试时间");
440
+
441
+ // 退避窗口内显式再发一次:不得产生新请求(避免失败风暴)
442
+ const before = relay.count("/api/telemetry/events");
443
+ await TELEMETRY.flush(env.relayDir);
444
+ assert.equal(relay.count("/api/telemetry/events"), before, "退避窗口内不得重试");
445
+
446
+ // 静默:发送一直失败时,面板/连接路由照常可用(响应时间不受影响)
447
+ const t0 = Date.now();
448
+ const r = await fetch(`${base}/dsh-remote/bridge-status`);
449
+ const body = await r.json();
450
+ const cost = Date.now() - t0;
451
+ assert.equal(r.status, 200, "发送失败不得影响面板路由");
452
+ assert.equal(body.ok, true);
453
+ assert.ok(cost < 2000, "面板路由不得被遥测发送拖慢,实际 " + cost + "ms");
454
+
455
+ // 累计 6 次仍失败 → 丢弃该批(不永久堆积)
456
+ const ok = await waitFor(() => TELEMETRY.queueOf(env.relayDir).length === 0, { timeout: 8000 });
457
+ assert.ok(ok, "6 次失败后必须丢弃该批(队列清零)");
458
+ const total = relay.count("/api/telemetry/events");
459
+ assert.ok(total >= 6, "必须真的重试到 6 次,实际 " + total);
460
+ assert.ok(total <= 12, "重试次数不得失控,实际 " + total);
461
+ } finally { host.close(); routes.dispose(); }
462
+ } finally { await env.restore(); relay.srv.close(); }
463
+ });
464
+
465
+ test("送达即出队:失败过一批后恢复 200 → 队列清空且不再重复发送", async () => {
466
+ const relay = await startFakeRelay({ telemetryStatus: 500 });
467
+ const env = await setup({ telemetryMs: "20" });
468
+ try {
469
+ await writeConfig(env.relayDir, relay.port);
470
+ boot(env.relayDir);
471
+ TELEMETRY.record(env.relayDir, "install_started");
472
+ TELEMETRY.record(env.relayDir, "runtime_ready");
473
+ TELEMETRY.record(env.relayDir, "bridge_started");
474
+ TELEMETRY.record(env.relayDir, "bridge_registered");
475
+ TELEMETRY.record(env.relayDir, "panel_opened");
476
+ await waitFor(() => relay.count("/api/telemetry/events") >= 1);
477
+ const queuedAfterFail = TELEMETRY.queueOf(env.relayDir).length;
478
+ assert.ok(queuedAfterFail >= 5, "失败不得丢事件,实际队列 " + queuedAfterFail);
479
+ relay.setTelemetryStatus(200);
480
+ const drained = await waitFor(() => TELEMETRY.queueOf(env.relayDir).length === 0, { timeout: 8000 });
481
+ assert.ok(drained, "退避到期后重试应送达并清空队列");
482
+ assert.equal(observed(relay, env.relayDir).count("install_started"), 1, "送达后不得重复发送(install_started 只有一条)");
483
+ const sentCount = relay.count("/api/telemetry/events");
484
+ const delivered = relay.telemetry[relay.telemetry.length - 1];
485
+ assertContractRequest(delivered, { eventNames: TELEMETRY.eventNames, failCodes: TELEMETRY.failCodes });
486
+ await sleep(120);
487
+ assert.equal(relay.count("/api/telemetry/events"), sentCount, "送达后不得重复发送");
488
+ } finally { await env.restore(); relay.srv.close(); }
489
+ });
490
+
491
+ test("掉线与重启:tunnel_disconnected 同一掉线周期只一条;harness_restart 触发即入队并跨进程补发", async () => {
492
+ const relay = await startFakeRelay();
493
+ const env = await setup();
494
+ try {
495
+ await writeConfig(env.relayDir, relay.port);
496
+ const routes = boot(env.relayDir);
497
+ // 掉线前:日志显示已注册成功;状态文件曾 online(wasRegistered 记忆)
498
+ await writeFile(path.join(env.relayDir, ".dsh-bridge.log"),
499
+ "[bridge] 隧道已连 wss://relay/_bridge,注册 dev-telemetry...\n[bridge] ✅ router 注册成功: dev-telemetry\n");
500
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({ phase: "online", tunnel_registered_at: Date.now() }));
501
+ const { host, base } = await serve(routes);
502
+ try {
503
+ // online(进程在跑)→ 建立「曾注册」记忆
504
+ env.setState("running");
505
+ process.env.DSH_TEST_PGREP = "4242 /path/dsh-bridge.mjs";
506
+ let r = await bridgeStatus(base);
507
+ assert.equal(r.connect.phase, "online");
508
+
509
+ // 掉线:日志新增「隧道断开」,状态文件回到 connecting(KeepAlive 仍在跑)
510
+ await writeFile(path.join(env.relayDir, ".dsh-bridge.log"),
511
+ "[bridge] 隧道已连 wss://relay/_bridge,注册 dev-telemetry...\n[bridge] ✅ router 注册成功: dev-telemetry\n"
512
+ + "[bridge] 隧道断开(code=1006)\n[bridge] 2s 后重连...\n");
513
+ await writeFile(path.join(env.relayDir, ".dsh-bridge-state.json"), JSON.stringify({ phase: "connecting" }));
514
+ await bridgeStatus(base);
515
+ await bridgeStatus(base); // 同一掉线周期再轮询两次
516
+ await sleep(60); // 让已排队的事件落定(假中继 200 会很快发出去)
517
+ assert.equal(observed(relay, env.relayDir).count("tunnel_disconnected"), 1,
518
+ "同一掉线周期只发一条 tunnel_disconnected");
519
+
520
+ // 重启:dry-run 也走同一入口(真实重启会换进程,用例只锁埋点)
521
+ process.env.DSH_RELAY_RESTART_DRYRUN = "1";
522
+ const rr = await fetch(`${base}/dsh-remote/harness/restart`, { method: "POST" });
523
+ assert.equal(rr.status, 200);
524
+ await sleep(60);
525
+ assert.equal(observed(relay, env.relayDir).count("harness_restart"), 1, "重启触发一次记一条");
526
+ } finally { host.close(); routes.dispose(); }
527
+ } finally { await env.restore(); relay.srv.close(); }
528
+ });
529
+
530
+ test("跨进程补发:上一进程遗留的磁盘队列在下次装载时立刻发出(重启前的事件不丢)", async () => {
531
+ const relay = await startFakeRelay();
532
+ const env = await setup();
533
+ try {
534
+ await writeConfig(env.relayDir, relay.port);
535
+ // 模拟「上一进程把 harness_restart 落在盘上就退出了」
536
+ await writeFile(path.join(env.relayDir, ".telemetry-queue.json"), JSON.stringify({
537
+ v: 1,
538
+ events: [{ name: "harness_restart", at: Date.now() - 5000, version: "0.0.0-test", os: "darwin", arch: "arm64", node: "22" }],
539
+ }), { mode: 0o600 });
540
+ const routes = boot(env.relayDir); // apply() → telemetryStart 读回队列并立即补发
541
+ assert.ok(TELEMETRY.queueOf(env.relayDir).some((e) => e.name === "harness_restart"), "磁盘队列必须被读回");
542
+ const sent = await waitFor(() => (relay.telemetry.length ? relay.telemetry : false));
543
+ assert.ok(sent, "遗留队列必须在下次装载时立刻补发");
544
+ assert.ok(sent[0].body.events.some((e) => e.name === "harness_restart"));
545
+ // 队列文件里的外来垃圾(非白名单事件名)不得被转发
546
+ await writeFile(path.join(env.relayDir, ".telemetry-queue.json"), JSON.stringify({
547
+ v: 1, events: [{ name: "user_login", phone: "13800000000" }],
548
+ }), { mode: 0o600 });
549
+ TELEMETRY.reset(env.relayDir);
550
+ routes.dispose();
551
+ const before = relay.telemetry.length;
552
+ boot(env.relayDir);
553
+ await sleep(120);
554
+ for (const req of relay.telemetry.slice(before)) {
555
+ assert.ok(!(req.body?.events || []).some((e) => e.name === "user_login"), "队列里的非白名单事件不得被发出");
556
+ assert.ok(!req.raw.includes("13800000000"), "队列里的非白名单事件内容不得被转发");
557
+ }
558
+ assert.equal(TELEMETRY.queueOf(env.relayDir).filter((e) => e.name === "user_login").length, 0);
559
+ } finally { await env.restore(); relay.srv.close(); }
560
+ });
561
+
562
+ test("不为遥测创建配置目录 / 测试隔离:隔离开关下零副作用,且代码里没有为遥测建目录", async () => {
563
+ const relay = await startFakeRelay();
564
+ const env = await setup({ skipService: true }); // 诊断/测试隔离(DSH_RELAY_SKIP_SERVICE=1)
565
+ try {
566
+ await rm(env.relayDir, { recursive: true, force: true }); // 模拟「只装了插件、从未生成过配置目录」
567
+ boot(env.relayDir);
568
+ await sleep(120);
569
+ assert.equal(existsSync(env.relayDir), false, "隔离开关下遥测不得创建任何东西");
570
+ assert.equal(telemetryFilesIn(env.relayDir).length, 0);
571
+ assert.equal(relay.count("/api/telemetry/events"), 0, "隔离开关下绝不得把事件发到真实端点");
572
+ assert.equal(TELEMETRY.record(env.relayDir, "install_started"), false, "隔离开关下不得入队");
573
+ // 源码级:遥测段落不得出现「为统计创建配置目录」的动作(否则会改变卸载/安装判定的既有语义)
574
+ const src = readFileSync(new URL("../lib/index.js", import.meta.url), "utf8");
575
+ const section = src.slice(src.indexOf("// ---------- 匿名装机/连接遥测"), src.indexOf("/**\n * 安装信息上报(插件侧通道)"));
576
+ assert.ok(section.length > 2000, "必须定位到遥测段落");
577
+ assert.ok(!/mkdirSync\(\s*relayDir/.test(section), "遥测不得为统计创建配置目录");
578
+ assert.ok(section.includes("telemetryDirReady"), "落盘必须先判断配置目录已存在");
579
+ } finally { await env.restore(); relay.srv.close(); }
580
+ });
581
+
582
+ test("面板可见性:关于卡片写明「匿名统计 + 关闭方式」,并链到 README/docs/telemetry.md", () => {
583
+ const client = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
584
+ assert.match(client, /匿名装机统计/, "面板必须有匿名统计说明");
585
+ assert.match(client, /DSH_REMOTE_TELEMETRY=0/, "面板必须写明关闭方法");
586
+ assert.ok(client.includes("README.md#匿名装机统计与隐私"), "面板必须链到 README 对应段落");
587
+ assert.ok(client.includes("docs/telemetry.md"), "面板必须链到完整字段清单");
588
+ assert.match(client, /不含任何账号、手机号、会话或文件内容/, "面板必须写明不采集什么");
589
+ assert.ok(client.includes("/dsh-remote/telemetry/panel-opened"), "面板打开事件必须走本地路由(浏览器半不直连遥测端点)");
590
+ const readme = readFileSync(new URL("../../../README.md", import.meta.url), "utf8");
591
+ assert.match(readme, /## 匿名装机统计与隐私/, "README 必须有对应小节(面板链接的锚点)");
592
+ assert.ok(readme.includes("docs/telemetry.md"), "README 必须链到 docs/telemetry.md");
593
+ assert.match(readme, /DSH_REMOTE_TELEMETRY=0/, "README 必须写明关闭方法");
594
+ const doc = readFileSync(new URL("../../../docs/telemetry.md", import.meta.url), "utf8");
595
+ for (const line of ["不采集什么", "如何关闭", "hostname", "machine_fp", "IP", "DSH_REMOTE_TELEMETRY=0"]) {
596
+ assert.ok(doc.includes(line), "docs/telemetry.md 必须覆盖:" + line);
597
+ }
598
+ for (const name of TELEMETRY.eventNames) assert.ok(doc.includes(name), "docs/telemetry.md 必须列出事件:" + name);
599
+ for (const code of TELEMETRY.failCodes) assert.ok(doc.includes(code), "docs/telemetry.md 必须列出失败码:" + code);
600
+ });