@mrrisega/dsh-remote 0.6.7-beta.2 → 0.6.7-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.
- package/README.md +15 -1
- package/clients/dsh-remote/dsh-bridge.mjs +47 -1
- package/clients/dsh-remote/e2ee-shim-script.js +18 -1
- package/clients/dsh-remote/mobile-adapter.mjs +485 -170
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +91 -34
- package/dsh-setup.mjs +137 -12
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +59 -8
- package/packages/dsh-remote-web/lib/index.js +419 -63
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +307 -0
- package/packages/dsh-remote-web/test/self-manage.test.mjs +16 -7
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +44 -33
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +442 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.7-beta.
|
|
3
|
+
"version": "0.6.7-beta.4",
|
|
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,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
|
+
});
|
|
@@ -155,6 +155,16 @@ test("残留 marker 自愈:记录进程已死的 marker 在插件启动时立
|
|
|
155
155
|
}
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
/** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完。 */
|
|
159
|
+
async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
|
|
160
|
+
const deadline = Date.now() + timeout;
|
|
161
|
+
for (;;) {
|
|
162
|
+
if (await fn()) return true;
|
|
163
|
+
if (Date.now() > deadline) return false;
|
|
164
|
+
await new Promise((r) => setTimeout(r, step));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
158
168
|
test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本地目录(解锁市场卸载)", async () => {
|
|
159
169
|
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-home-"));
|
|
160
170
|
const relayDir = path.join(tempHome, "relay");
|
|
@@ -189,13 +199,12 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
189
199
|
assert.equal(r.removedBundle, true, "应移除 dsh.profile.bundles 条目");
|
|
190
200
|
assert.equal(r.removedDir, true, "应删除本地插件目录与 node_modules 链接");
|
|
191
201
|
|
|
192
|
-
// 运行时/bridge
|
|
193
|
-
|
|
194
|
-
assert.equal(r.
|
|
195
|
-
|
|
196
|
-
assert.
|
|
197
|
-
assert.
|
|
198
|
-
assert.match(String(r.detail), /配置目录已清空/, "detail 应说明配置目录已清空");
|
|
202
|
+
// 运行时/bridge 清理:0.6.7-beta.3 起交给**独立子进程**(先回响应,慢活不阻塞事件循环),
|
|
203
|
+
// 所以这里断言响应契约 + 轮询等它的物理结果(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
|
|
204
|
+
assert.equal(r.runtimeCleanup, "deferred", "运行时清理应由子进程接管");
|
|
205
|
+
const gone = await waitFor(() => !existsSync(relayDir));
|
|
206
|
+
assert.equal(gone, true, "relayDir 物理上应已不存在(子进程执行)");
|
|
207
|
+
assert.match(String(r.detail), /正在后台清理/, "detail 应说明后台清理");
|
|
199
208
|
assert.match(String(r.detail), /请重启 dsh web/, "detail 应提示重启生效");
|
|
200
209
|
|
|
201
210
|
const patch = await readFile(patchFile, "utf8");
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
// 背景:用户反馈卸载不干净——bridge 自启动服务(launchd com.dshremote.bridge / Linux dsh-bridge)
|
|
3
3
|
// 仍在运行、自启动 plist/unit 仍在、配置目录 ~/.dsh-remote(账号/设备密钥/.dsh-config.json/
|
|
4
4
|
// .harness-cookie.json/固化运行时 dsh-setup.mjs + clients 等)也还在。
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// 0.6.7-beta.3 起卸载**分两段**(顺序是刻意的,见 uninstall 路由注释):
|
|
6
|
+
// ① 同步段:uninstallSelf(纯 fs、快)→ markUninstalled → 立刻回响应;
|
|
7
|
+
// ② 异步段:运行时清理(停自启动/杀残留/清配置目录)交给**独立子进程**执行。
|
|
8
|
+
// 为什么必须这样:同步段里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
|
|
9
|
+
// timeout 在 Windows 上并不可靠),旧实现会让 uninstallSelf 永远不执行、整个 dsh web 僵死
|
|
10
|
+
// (端口在听、无响应)。因此这些用例改为:断言响应契约(runtimeCleanup)+ 轮询等待子进程的物理结果。
|
|
7
11
|
// 测试隔离(绝不触碰本机真实 launchd/systemd/进程):
|
|
8
12
|
// - 大多数用例设 DSH_RELAY_SKIP_SERVICE=1 → 跳过一切系统级操作,只验证配置目录清理与 profile 清理;
|
|
9
13
|
// - 「自然跳过」用例把 PATH 指向假 launchctl/pgrep/ps,完全接管系统命令后走无开关路径。
|
|
@@ -23,6 +27,18 @@ const PLUGIN_BLOCK = `# >>> dsh-remote-ui (managed by dsh-remote plugin; do not
|
|
|
23
27
|
# <<< dsh-remote-ui
|
|
24
28
|
`;
|
|
25
29
|
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
/** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完(而不是同步返回)。 */
|
|
33
|
+
async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
|
|
34
|
+
const deadline = Date.now() + timeout;
|
|
35
|
+
for (;;) {
|
|
36
|
+
if (await fn()) return true;
|
|
37
|
+
if (Date.now() > deadline) return false;
|
|
38
|
+
await sleep(step);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
/** 加载插件(要求调用方已把 process.env.HOME 指向 tempHome;profile 推导才落在 temp 而非真实 ~/.dsh)。 */
|
|
27
43
|
function boot(relayDir) {
|
|
28
44
|
const routes = new Map();
|
|
@@ -96,14 +112,11 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
|
|
|
96
112
|
assert.equal(r.removedDep, true);
|
|
97
113
|
assert.equal(r.removedBundle, true);
|
|
98
114
|
assert.equal(r.removedDir, true, "profile 插件目录与 node_modules 链接应删除");
|
|
99
|
-
//
|
|
100
|
-
assert.equal(r.
|
|
101
|
-
assert.
|
|
102
|
-
|
|
103
|
-
assert.
|
|
104
|
-
assert.deepEqual(r.killedPids, []);
|
|
105
|
-
// 物理断言:relayDir 与其中全部残留都消失
|
|
106
|
-
assert.equal(existsSync(relayDir), false, "relayDir 目录应整体不存在");
|
|
115
|
+
// 运行时清理交给独立子进程(响应先回,慢活不再钉住事件循环)
|
|
116
|
+
assert.equal(r.runtimeCleanup, "deferred", "运行时清理必须由子进程接管(不得同步阻塞)");
|
|
117
|
+
assert.ok(r.cleanupLog, "应给出清理日志路径");
|
|
118
|
+
// 物理断言:等子进程跑完,relayDir 与其中全部残留都应消失
|
|
119
|
+
assert.ok(await waitFor(() => !existsSync(relayDir)), "relayDir 应被整目录清空(子进程执行)");
|
|
107
120
|
assert.equal(existsSync(path.join(relayDir, ".dsh-config.json")), false);
|
|
108
121
|
assert.equal(existsSync(path.join(relayDir, "dsh-setup.mjs")), false);
|
|
109
122
|
assert.equal(existsSync(path.join(relayDir, "clients")), false);
|
|
@@ -117,7 +130,7 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
|
|
|
117
130
|
assert.equal(existsSync(profile), true, "dsh web profile 目录整体不得被删");
|
|
118
131
|
// 人类可读 detail
|
|
119
132
|
assert.match(String(r.detail), /插件引用与本地文件已移除/);
|
|
120
|
-
assert.match(String(r.detail),
|
|
133
|
+
assert.match(String(r.detail), /正在后台清理/);
|
|
121
134
|
assert.match(String(r.detail), /请重启 dsh web/);
|
|
122
135
|
} finally {
|
|
123
136
|
host.close();
|
|
@@ -145,13 +158,12 @@ test("彻底卸载边界:只装了插件、从未跑过 bridge → relayDir
|
|
|
145
158
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
146
159
|
assert.equal(r.ok, true);
|
|
147
160
|
assert.equal(r.removedPatch, true, "profile 里的插件引用应照常清理");
|
|
148
|
-
assert.equal(r.
|
|
149
|
-
assert.equal(r.stoppedService, false);
|
|
150
|
-
assert.equal(r.removedPlist, false);
|
|
151
|
-
assert.deepEqual(r.killedPids, []);
|
|
161
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
152
162
|
const detail = String(r.detail);
|
|
153
163
|
assert.ok(!detail.includes("配置目录已清空"), "目录不存在时不应谎报已清空,实际: " + detail);
|
|
154
164
|
assert.match(detail, /请重启 dsh web/);
|
|
165
|
+
await sleep(800); // 等清理子进程跑完
|
|
166
|
+
assert.equal(existsSync(relayDir), false, "relayDir 本来就不存在 → 不得被凭空创建");
|
|
155
167
|
} finally {
|
|
156
168
|
host.close();
|
|
157
169
|
}
|
|
@@ -174,12 +186,13 @@ test("彻底卸载边界:什么都没有安装 → 全链路幂等返回 ok,
|
|
|
174
186
|
try {
|
|
175
187
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
176
188
|
assert.equal(r.ok, true);
|
|
177
|
-
assert.equal(r.
|
|
189
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
178
190
|
assert.equal(r.removedPatch, false);
|
|
179
191
|
assert.equal(r.removedDep, false);
|
|
180
192
|
assert.equal(r.removedBundle, false);
|
|
181
|
-
assert.deepEqual(r.killedPids, []);
|
|
182
193
|
assert.match(String(r.detail), /请重启 dsh web/);
|
|
194
|
+
await sleep(800);
|
|
195
|
+
assert.equal(existsSync(relayDir), false, "什么都没有也不该凭空创建目录");
|
|
183
196
|
} finally {
|
|
184
197
|
host.close();
|
|
185
198
|
}
|
|
@@ -206,10 +219,9 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
|
|
|
206
219
|
try {
|
|
207
220
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
208
221
|
assert.equal(r.ok, true);
|
|
209
|
-
assert.equal(r.
|
|
210
|
-
assert.
|
|
211
|
-
assert.equal(
|
|
212
|
-
assert.equal(existsSync(relayDir), false);
|
|
222
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
223
|
+
assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录清理不受开关影响");
|
|
224
|
+
assert.equal(existsSync(fakePlist), true, "开关置位 → plist 不得被删(子进程同样受开关约束)");
|
|
213
225
|
} finally {
|
|
214
226
|
host.close();
|
|
215
227
|
}
|
|
@@ -220,7 +232,7 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
|
|
|
220
232
|
}
|
|
221
233
|
});
|
|
222
234
|
|
|
223
|
-
test("
|
|
235
|
+
test("无开关(真实分支):自启动清理由独立子进程执行,且走 PATH 上的假 launchctl(不碰真实服务)", { skip: process.platform !== "darwin" }, async () => {
|
|
224
236
|
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-natural-"));
|
|
225
237
|
const prevHome = process.env.HOME;
|
|
226
238
|
const prevPath = process.env.PATH;
|
|
@@ -254,15 +266,13 @@ exit 0
|
|
|
254
266
|
try {
|
|
255
267
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
256
268
|
assert.equal(r.ok, true);
|
|
257
|
-
assert.equal(r.
|
|
258
|
-
|
|
259
|
-
assert.
|
|
260
|
-
assert.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const log = readFileSync(launchLog, "utf8");
|
|
265
|
-
assert.ok(!log.includes("bootout"), "未运行的服务不应触发 bootout,实际调用: " + log);
|
|
269
|
+
assert.equal(r.runtimeCleanup, "deferred", "无开关时同样走子进程");
|
|
270
|
+
// 等清理子进程跑完(它继承 HOME/PATH → plist 路径与 launchctl 都落在测试环境里)
|
|
271
|
+
assert.ok(await waitFor(() => !existsSync(plist)), "plist 应被清理子进程删除");
|
|
272
|
+
assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录应被清空");
|
|
273
|
+
// 关键安全性质:清理用的是 PATH 上的**假** launchctl —— 证明没有触碰任何真实服务
|
|
274
|
+
const log = existsSync(launchLog) ? readFileSync(launchLog, "utf8") : "";
|
|
275
|
+
assert.ok(log.includes("com.dshremote.bridge"), "应通过(假)launchctl 清理自启动,实际: " + log);
|
|
266
276
|
} finally {
|
|
267
277
|
host.close();
|
|
268
278
|
}
|
|
@@ -289,8 +299,9 @@ test("保护参数:relayDir 误配置成 dsh web profile 目录 → 绝不整
|
|
|
289
299
|
try {
|
|
290
300
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
291
301
|
assert.equal(r.ok, true);
|
|
292
|
-
assert.equal(r.
|
|
293
|
-
|
|
302
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
303
|
+
await sleep(1000); // 等清理子进程跑完:它必须自己判断出"这是 profile,不许删"
|
|
304
|
+
assert.equal(existsSync(profile), true, "dsh web profile 必须存活(子进程也受同一条护栏约束)");
|
|
294
305
|
assert.equal(existsSync(path.join(profile, "package.json")), true);
|
|
295
306
|
assert.equal(existsSync(path.join(profile, "dsh-remote-ui-plugin")), false, "插件子目录应被 uninstallSelf 单独移除");
|
|
296
307
|
assert.equal(existsSync(path.join(profile, "node_modules", "dsh-remote-ui")), false);
|