@mrrisega/dsh-remote 0.6.12 → 0.6.14
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/clients/dsh-remote/dsh-bridge.mjs +183 -3
- package/clients/dsh-remote/mobile-adapter.mjs +219 -5
- package/clients/dsh-remote/test/boot-tip.test.mjs +158 -0
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +196 -0
- package/clients/dsh-remote/upstream-discovery.mjs +292 -0
- package/dsh-setup.mjs +64 -16
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/index.js +106 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +183 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +219 -5
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +292 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +64 -16
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +16 -1
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +148 -0
- package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +170 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 上游(dsh web)地址发现契约测试。
|
|
4
|
+
*
|
|
5
|
+
* 背景(两起真实事故,见 upstream-discovery.mjs 顶部注释):
|
|
6
|
+
* ① `dsh web --port 8090` / `--port 0`(系统分配端口)→ 写死 3080 的探测永远失败;
|
|
7
|
+
* ② **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认 43120、占用则 +1,插件 0.6.10
|
|
8
|
+
* 实测「面板永久停在 starting、设备从未登记」——watcher 探 3080 探不到就永不派生 bridge。
|
|
9
|
+
*
|
|
10
|
+
* 这里守四件事:
|
|
11
|
+
* ① 提示优先级(env > 端口文件 > DSH_WEB_URL)与规范化;
|
|
12
|
+
* ② **身份校验**:候选端口可能被别的程序占用,绝不能把 bridge 接到陌生人身上;
|
|
13
|
+
* ③ 提示不可用时会去探候选(含 Desktop 端口区间),并报出**准确的来源**;
|
|
14
|
+
* ④ 什么都找不到时保持历史行为(回退 3080),不能变成"抛错/空地址"。
|
|
15
|
+
*
|
|
16
|
+
* 用法: node --test test/upstream-discovery.test.mjs
|
|
17
|
+
*/
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import test from "node:test";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
candidatePorts,
|
|
27
|
+
discoverUpstream,
|
|
28
|
+
listeningPortsOfPid,
|
|
29
|
+
looksLikeDshWeb,
|
|
30
|
+
normalizeUpstream,
|
|
31
|
+
probeDshWeb,
|
|
32
|
+
resolveUpstreamHint,
|
|
33
|
+
DESKTOP_DEFAULT_PORT,
|
|
34
|
+
FALLBACK_UPSTREAM,
|
|
35
|
+
UPSTREAM_FILE
|
|
36
|
+
} from "../upstream-discovery.mjs";
|
|
37
|
+
|
|
38
|
+
const DIR = mkdtempSync(path.join(os.tmpdir(), "dsh-upstream-"));
|
|
39
|
+
process.on("exit", () => { try { rmSync(DIR, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
40
|
+
|
|
41
|
+
/** 假的 dsh web:回一段带官方特征标记的 HTML(身份校验靠它)。 */
|
|
42
|
+
const DSH_HTML = "<!doctype html><html><head><title>DeepSeek Harness</title></head><body><script>__ModuleLoader__</script></body></html>";
|
|
43
|
+
/** 冒牌服务:端口在听,但不是 dsh web。 */
|
|
44
|
+
const STRANGER = "hello, I am some other local server";
|
|
45
|
+
|
|
46
|
+
async function listen(handler) {
|
|
47
|
+
const srv = http.createServer(handler);
|
|
48
|
+
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
|
49
|
+
return { srv, port: srv.address().port, url: `http://127.0.0.1:${srv.address().port}` };
|
|
50
|
+
}
|
|
51
|
+
const close = (x) => new Promise((r) => x.srv.close(r));
|
|
52
|
+
|
|
53
|
+
const writeHint = (url) => writeFileSync(path.join(DIR, UPSTREAM_FILE), url + "\n");
|
|
54
|
+
/**
|
|
55
|
+
* 把 3080 封掉的 fetch 包装。
|
|
56
|
+
* 为什么需要:开发机上真实存在一个 dsh web 监听 3080,候选扫描会命中它 ——
|
|
57
|
+
* 那是**宿主**而不是测试夹具,断言就变成了"看运气"。封掉它,测试才只依赖自己起的假服务。
|
|
58
|
+
*/
|
|
59
|
+
const realFetch = globalThis.fetch;
|
|
60
|
+
const blockedFetch = (input, init) => {
|
|
61
|
+
const url = String(typeof input === "string" ? input : (input && input.url) || "");
|
|
62
|
+
if (/:3080(\/|$)/.test(url)) return Promise.reject(new Error("blocked: host 3080"));
|
|
63
|
+
return realFetch(input, init);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
test("规范化:去尾斜杠;非 http(s) / 空值一律丢弃", () => {
|
|
68
|
+
assert.equal(normalizeUpstream("http://127.0.0.1:43120/"), "http://127.0.0.1:43120");
|
|
69
|
+
assert.equal(normalizeUpstream(" http://127.0.0.1:3080 "), "http://127.0.0.1:3080");
|
|
70
|
+
assert.equal(normalizeUpstream("wss://n.risegao.cn:13443"), "");
|
|
71
|
+
assert.equal(normalizeUpstream(""), "");
|
|
72
|
+
assert.equal(normalizeUpstream(undefined), "");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("提示优先级:env > 端口文件 > DSH_WEB_URL", () => {
|
|
76
|
+
writeHint("http://127.0.0.1:43120");
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
resolveUpstreamHint({ relayDir: DIR, env: { DSH_BRIDGE_UPSTREAM: "http://127.0.0.1:9001" } }),
|
|
79
|
+
{ url: "http://127.0.0.1:9001", source: "env" }
|
|
80
|
+
);
|
|
81
|
+
assert.deepEqual(resolveUpstreamHint({ relayDir: DIR, env: {} }), { url: "http://127.0.0.1:43120", source: "file" });
|
|
82
|
+
// 文件坏掉 → 落到 DSH_WEB_URL
|
|
83
|
+
writeHint("not-a-url");
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
resolveUpstreamHint({ relayDir: DIR, env: { DSH_WEB_URL: "https://example.com:8090/?token=x" } }),
|
|
86
|
+
{ url: "http://127.0.0.1:8090", source: "dsh_web_url" }
|
|
87
|
+
);
|
|
88
|
+
writeHint("http://127.0.0.1:43120");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("身份校验:只认 dsh web 特征,拒绝陌生服务与空响应", () => {
|
|
92
|
+
assert.equal(looksLikeDshWeb(200, DSH_HTML), true);
|
|
93
|
+
assert.equal(looksLikeDshWeb(401, "dsh web authentication required; reopen the URL printed by dsh web."), true);
|
|
94
|
+
assert.equal(looksLikeDshWeb(200, STRANGER), false);
|
|
95
|
+
assert.equal(looksLikeDshWeb(200, ""), false);
|
|
96
|
+
assert.equal(looksLikeDshWeb(500, DSH_HTML), false, "5xx 不算");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("行为:probeDshWeb 认得出真 dsh web,也拒绝陌生服务与死端口", async () => {
|
|
100
|
+
const dsh = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); });
|
|
101
|
+
const stranger = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end(STRANGER); });
|
|
102
|
+
try {
|
|
103
|
+
assert.equal(await probeDshWeb(dsh.url), true);
|
|
104
|
+
assert.equal(await probeDshWeb(stranger.url), false, "端口在听但不是 dsh web → 必须拒绝");
|
|
105
|
+
assert.equal(await probeDshWeb("http://127.0.0.1:1"), false, "死端口 → false");
|
|
106
|
+
assert.equal(await probeDshWeb("wss://x"), false, "非 http(s) → false");
|
|
107
|
+
} finally {
|
|
108
|
+
await close(dsh); await close(stranger);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("★ DSH Desktop 场景:端口文件过期/缺失时,靠候选探测找到 43120 上的 dsh web", async () => {
|
|
113
|
+
// 模拟 Desktop:dsh web 在 43120,而端口文件写的是上一轮的旧端口(已无人监听)
|
|
114
|
+
const desktop = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); });
|
|
115
|
+
// 探测真的走 43120 —— 测试里换成"把 Desktop 端口作为 extraPorts 注入",避免占用真实端口
|
|
116
|
+
writeHint("http://127.0.0.1:1"); // 过期提示
|
|
117
|
+
try {
|
|
118
|
+
const found = await discoverUpstream({
|
|
119
|
+
relayDir: DIR,
|
|
120
|
+
env: {},
|
|
121
|
+
extraPorts: [desktop.port],
|
|
122
|
+
fetchImpl: blockedFetch,
|
|
123
|
+
platform: "win32" // 隔离宿主:不走 ps/pgrep/netstat,候选只来自提示/额外端口/静态表
|
|
124
|
+
});
|
|
125
|
+
assert.equal(found.url, desktop.url, "必须落到候选命中的那个 dsh web");
|
|
126
|
+
assert.equal(found.source, "extra");
|
|
127
|
+
assert.ok(found.rejected.includes("http://127.0.0.1:1"), "过期提示应被记入 rejected");
|
|
128
|
+
} finally {
|
|
129
|
+
await close(desktop);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("★ 候选探测不做身份校验的话会接错人:陌生服务在候选端口上也必须被拒绝", async () => {
|
|
134
|
+
const stranger = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/plain" }); res.end(STRANGER); });
|
|
135
|
+
writeHint("http://127.0.0.1:1");
|
|
136
|
+
try {
|
|
137
|
+
const found = await discoverUpstream({ relayDir: DIR, env: {}, extraPorts: [stranger.port], fetchImpl: blockedFetch, platform: "win32" });
|
|
138
|
+
assert.notEqual(found.url, stranger.url, "陌生服务绝不能成为上游");
|
|
139
|
+
assert.equal(found.url, "http://127.0.0.1:1", "找不到时回落到提示地址(让上层按'上游没起来'重试)");
|
|
140
|
+
} finally {
|
|
141
|
+
await close(stranger);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("提示可用时不做候选扫描(省掉无谓探测)", async () => {
|
|
146
|
+
const dsh = await listen((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); res.end(DSH_HTML); });
|
|
147
|
+
writeHint(dsh.url);
|
|
148
|
+
try {
|
|
149
|
+
const found = await discoverUpstream({ relayDir: DIR, env: {}, fetchImpl: blockedFetch, platform: "win32" });
|
|
150
|
+
assert.equal(found.url, dsh.url);
|
|
151
|
+
assert.equal(found.source, "file");
|
|
152
|
+
assert.deepEqual(found.probed, [dsh.port], "只探了提示本身");
|
|
153
|
+
} finally {
|
|
154
|
+
await close(dsh);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("显式 DSH_BRIDGE_UPSTREAM 直接采信(探测都不做) —— 保持既有语义", async () => {
|
|
159
|
+
const found = await discoverUpstream({
|
|
160
|
+
relayDir: DIR,
|
|
161
|
+
env: { DSH_BRIDGE_UPSTREAM: "http://127.0.0.1:63999" },
|
|
162
|
+
platform: "win32",
|
|
163
|
+
fetchImpl: () => { throw new Error("不该发请求"); }
|
|
164
|
+
});
|
|
165
|
+
assert.equal(found.url, "http://127.0.0.1:63999");
|
|
166
|
+
assert.equal(found.source, "env");
|
|
167
|
+
assert.deepEqual(found.probed, []);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("什么都找不到 → 回退历史默认 3080(不抛错、不放空)", async () => {
|
|
171
|
+
writeHint("not-a-url");
|
|
172
|
+
const found = await discoverUpstream({ relayDir: DIR, env: {}, fetchImpl: blockedFetch, platform: "win32", timeoutMs: 120 });
|
|
173
|
+
assert.equal(found.url, FALLBACK_UPSTREAM);
|
|
174
|
+
assert.equal(found.source, "fallback");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("候选构成:含 3080 与 DSH Desktop 默认端口区间,且去重、带来源", () => {
|
|
178
|
+
writeHint("http://127.0.0.1:43120");
|
|
179
|
+
const list = candidatePorts({ relayDir: DIR, env: {}, platform: "win32" });
|
|
180
|
+
const ports = list.map((e) => e.port);
|
|
181
|
+
assert.ok(ports.includes(3080), "必须包含历史默认端口");
|
|
182
|
+
for (let i = 0; i < 12; i++) assert.ok(ports.includes(DESKTOP_DEFAULT_PORT + i), `必须覆盖 Desktop 端口区间 +${i}`);
|
|
183
|
+
assert.equal(new Set(ports).size, ports.length, "不得重复");
|
|
184
|
+
assert.equal(list.find((e) => e.port === 43120).source, "file", "首个候选应来自端口文件");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("实测端口:能从本进程真实监听端口里读出来(macOS/Linux 用 lsof)", async (t) => {
|
|
188
|
+
if (process.platform === "win32") return t.skip("Windows 走 netstat,另有实现");
|
|
189
|
+
const srv = await listen((_req, res) => res.end("x"));
|
|
190
|
+
try {
|
|
191
|
+
const ports = listeningPortsOfPid(process.pid);
|
|
192
|
+
assert.ok(ports.includes(srv.port), `应包含 ${srv.port},实际 ${JSON.stringify(ports)}`);
|
|
193
|
+
} finally {
|
|
194
|
+
await close(srv);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游(dsh web)地址发现 —— **单一实现**,watcher(dsh-setup.mjs)与 bridge(dsh-bridge.mjs)共用。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 桥接链路的两端(watcher 的存活探测、bridge 的转发目标)原本把上游写死
|
|
7
|
+
* `http://127.0.0.1:3080`(独立 `dsh web` 的默认端口)。但真实世界里端口是会变的:
|
|
8
|
+
*
|
|
9
|
+
* · `dsh web --port 8090` / profile 里配了 port / `--port 0` 让系统分配空闲端口;
|
|
10
|
+
* · **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认监听 **43120**,且被占用时按 +1 递增;
|
|
11
|
+
* · `dsh web` 重启后端口可能不同。
|
|
12
|
+
*
|
|
13
|
+
* 2026-09-23 用户实测(DSH Desktop 2.0.4 + 插件 0.6.10):上游在 43120,而 watcher 探 3080 →
|
|
14
|
+
* **永远探不到 → bridge 永远不被派生 → 面板永久停在「正在启动 Bridge…」**,设备从未登记。
|
|
15
|
+
* 这不是"某个用户环境特殊",而是**所有改了端口的用户**都会踩的同一类问题,所以要有通用发现。
|
|
16
|
+
*
|
|
17
|
+
* ## 发现顺序(先便宜后昂贵,每一步都做**身份校验**)
|
|
18
|
+
*
|
|
19
|
+
* 1. `DSH_BRIDGE_UPSTREAM`(显式覆盖,部署/排障用)—— 唯一不做探测就采信的来源;
|
|
20
|
+
* 2. `<relayDir>/.dsh-upstream`(**插件半**在宿主进程里用 `ctx.webServer.port` 落盘,最权威);
|
|
21
|
+
* 3. `DSH_WEB_URL` 的端口;
|
|
22
|
+
* 4. **实测端口**:本进程的祖先进程 / 本机 dsh 进程正在 LISTEN 的端口(lsof / netstat);
|
|
23
|
+
* 5. 静态候选:3080 + DSH Desktop 的 43120 及其递增区间。
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 第 4、5 步必须**校验身份**:43120 之类的端口可能被别的本地程序占用,
|
|
26
|
+
* 把 bridge 接到一个陌生人身上既不可用、也可能泄露数据。判据见 `looksLikeDshWeb`。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 探测全部**并行**且有总预算:本地端口连接被拒是瞬时的,只有"端口在听但不回包"才会等到超时,
|
|
29
|
+
* 串行探 12 个候选最坏要十几秒,会让 watcher 的轮询卡住。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
|
|
36
|
+
/** 插件半落盘的上游地址文件名(与 packages/dsh-remote-web/lib/index.js 的常量同名同义)。 */
|
|
37
|
+
export const UPSTREAM_FILE = ".dsh-upstream";
|
|
38
|
+
/** 独立 `dsh web` 的默认端口(历史行为,最后的兜底)。 */
|
|
39
|
+
export const FALLBACK_UPSTREAM = "http://127.0.0.1:3080";
|
|
40
|
+
/** DSH Desktop(Electron 壳)的默认 Web 端口;被占用时它按 +1 递增,所以要扫一段区间。 */
|
|
41
|
+
export const DESKTOP_DEFAULT_PORT = 43120;
|
|
42
|
+
/** Desktop 端口区间扫多少个(43120..43120+11)。 */
|
|
43
|
+
export const DESKTOP_PORT_SCAN = 12;
|
|
44
|
+
/** 单个候选的探测超时(本地回环,超过它基本就是"在听但不回包")。 */
|
|
45
|
+
const PROBE_TIMEOUT_MS = 900;
|
|
46
|
+
|
|
47
|
+
/** 规范化:去尾斜杠;非 http(s) 一律丢弃(返回空串)。 */
|
|
48
|
+
export function normalizeUpstream(value) {
|
|
49
|
+
const raw = String(value ?? "").trim();
|
|
50
|
+
if (!raw) return "";
|
|
51
|
+
if (!/^https?:\/\/[^\s]+$/i.test(raw)) return "";
|
|
52
|
+
return raw.replace(/\/+$/, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 从字符串里取端口(拿不到返回 0)。 */
|
|
56
|
+
function portOf(value) {
|
|
57
|
+
try {
|
|
58
|
+
const n = Number(new URL(String(value)).port);
|
|
59
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 1) 显式环境变量。 */
|
|
66
|
+
export function upstreamFromEnv(env = process.env) {
|
|
67
|
+
return normalizeUpstream(env.DSH_BRIDGE_UPSTREAM);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 2) 插件半落盘的端口文件(插件不在场/首次安装时可能没有)。 */
|
|
71
|
+
export function upstreamFromFile(relayDir) {
|
|
72
|
+
if (!relayDir) return "";
|
|
73
|
+
try {
|
|
74
|
+
return normalizeUpstream(fs.readFileSync(path.join(relayDir, UPSTREAM_FILE), "utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 3) DSH_WEB_URL 的端口(bridge 的 loopback 围栏只认 127.0.0.1,这里也统一回环地址)。 */
|
|
81
|
+
export function upstreamFromDshWebUrl(env = process.env) {
|
|
82
|
+
const raw = String(env.DSH_WEB_URL || "").trim();
|
|
83
|
+
if (!raw) return "";
|
|
84
|
+
const port = portOf(raw);
|
|
85
|
+
return port ? `http://127.0.0.1:${port}` : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 按优先级取出"提示地址"(不做任何网络探测)。
|
|
90
|
+
* @returns {{url: string, source: "env"|"file"|"dsh_web_url"|""}}
|
|
91
|
+
*/
|
|
92
|
+
export function resolveUpstreamHint({ relayDir = "", env = process.env } = {}) {
|
|
93
|
+
const explicit = upstreamFromEnv(env);
|
|
94
|
+
if (explicit) return { url: explicit, source: "env" };
|
|
95
|
+
const fromFile = upstreamFromFile(relayDir);
|
|
96
|
+
if (fromFile) return { url: fromFile, source: "file" };
|
|
97
|
+
const fromWebUrl = upstreamFromDshWebUrl(env);
|
|
98
|
+
if (fromWebUrl) return { url: fromWebUrl, source: "dsh_web_url" };
|
|
99
|
+
return { url: "", source: "" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- 实测端口(best effort,任何失败都只是少一个候选,绝不影响主流程) ----------
|
|
103
|
+
|
|
104
|
+
function tryExec(cmd, args, timeoutMs = 4000) {
|
|
105
|
+
try {
|
|
106
|
+
return String(execFileSync(cmd, args, { encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] }) || "");
|
|
107
|
+
} catch {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 某进程正在 LISTEN 的 TCP 端口。macOS/Linux 用 lsof,Windows 用 netstat -ano。 */
|
|
113
|
+
export function listeningPortsOfPid(pid, platform = process.platform) {
|
|
114
|
+
const n = Number(pid);
|
|
115
|
+
if (!Number.isInteger(n) || n <= 0) return [];
|
|
116
|
+
const ports = new Set();
|
|
117
|
+
if (platform === "win32") {
|
|
118
|
+
for (const line of tryExec("netstat", ["-ano"], 6000).split(/\r?\n/)) {
|
|
119
|
+
if (!/LISTENING/i.test(line)) continue;
|
|
120
|
+
const cols = line.trim().split(/\s+/);
|
|
121
|
+
if (cols.length < 5) continue;
|
|
122
|
+
if (Number(cols[cols.length - 1]) !== n) continue;
|
|
123
|
+
const m = /:(\d+)$/.exec(cols[1] || "");
|
|
124
|
+
if (m) ports.add(Number(m[1]));
|
|
125
|
+
}
|
|
126
|
+
return [...ports];
|
|
127
|
+
}
|
|
128
|
+
const out = tryExec("lsof", ["-nP", "-a", "-p", String(n), "-iTCP", "-sTCP:LISTEN"], 5000);
|
|
129
|
+
for (const line of out.split("\n")) {
|
|
130
|
+
const m = /:(\d+)\s*\(LISTEN\)/.exec(line);
|
|
131
|
+
if (m) ports.add(Number(m[1]));
|
|
132
|
+
}
|
|
133
|
+
return [...ports];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 祖先进程 pid(最多 6 层):被插件/宿主拉起的 watcher、bridge 走这条路能找到宿主。 */
|
|
137
|
+
export function ancestorPids(startPid = process.pid, maxDepth = 6, platform = process.platform) {
|
|
138
|
+
if (platform === "win32") return []; // Windows 无 ps;靠 netstat+pgrep 不可行,交给端口文件与静态候选
|
|
139
|
+
const out = [];
|
|
140
|
+
let pid = Number(startPid);
|
|
141
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
142
|
+
const txt = tryExec("ps", ["-o", "ppid=", "-p", String(pid)], 2000).trim();
|
|
143
|
+
const ppid = Number(txt);
|
|
144
|
+
if (!Number.isInteger(ppid) || ppid <= 1) break;
|
|
145
|
+
out.push(ppid);
|
|
146
|
+
pid = ppid;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 本机 dsh 相关进程(pgrep)—— Desktop 壳/独立 dsh web 都可能命中。 */
|
|
152
|
+
export function dshProcessPids(platform = process.platform) {
|
|
153
|
+
if (platform === "win32") return [];
|
|
154
|
+
const out = tryExec("pgrep", ["-f", "dsh"], 4000);
|
|
155
|
+
return out
|
|
156
|
+
.split(/\s+/)
|
|
157
|
+
.map((s) => Number(s))
|
|
158
|
+
.filter((n) => Number.isInteger(n) && n > 1 && n !== process.pid);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 候选列表(已去重,按"可能性"排序),每项带**来源标注**(用于日志与测试断言)。
|
|
163
|
+
* 顺序 = 提示里的端口 → 显式额外端口 → DSH_WEB_URL 的端口 → 祖先/dsh 进程实测监听端口
|
|
164
|
+
* → 3080 → Desktop 默认端口区间。
|
|
165
|
+
* @returns {Array<{port:number, source:string}>}
|
|
166
|
+
*/
|
|
167
|
+
export function candidatePorts({ relayDir = "", env = process.env, extraPorts = [], platform = process.platform } = {}) {
|
|
168
|
+
const out = [];
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
const push = (p, source) => {
|
|
171
|
+
const n = Number(p);
|
|
172
|
+
if (!Number.isInteger(n) || n <= 0 || n >= 65536 || seen.has(n)) return;
|
|
173
|
+
seen.add(n);
|
|
174
|
+
out.push({ port: n, source });
|
|
175
|
+
};
|
|
176
|
+
const hint = resolveUpstreamHint({ relayDir, env });
|
|
177
|
+
push(portOf(hint.url), hint.source || "hint");
|
|
178
|
+
for (const p of extraPorts) push(p, "extra");
|
|
179
|
+
push(portOf(upstreamFromDshWebUrl(env)), "dsh_web_url");
|
|
180
|
+
const pids = [...new Set([process.pid, ...ancestorPids(process.pid, 6, platform), ...dshProcessPids(platform)])];
|
|
181
|
+
for (const pid of pids) for (const p of listeningPortsOfPid(pid, platform)) push(p, "process");
|
|
182
|
+
push(3080, "static");
|
|
183
|
+
for (let i = 0; i < DESKTOP_PORT_SCAN; i++) push(DESKTOP_DEFAULT_PORT + i, "static");
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------- 身份校验 ----------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 这个地址**真的**是 dsh web 吗?
|
|
191
|
+
*
|
|
192
|
+
* 为什么必须有:候选探测会去敲 43120 这类可能被别的程序占用的端口。只看"端口有响应"就把
|
|
193
|
+
* bridge 接上去,用户会得到一个"能连上但不工作"的怪状态,而且等于把这台机器的流量交给陌生服务。
|
|
194
|
+
* 判据取 dsh web 自身的稳定特征(HTML/标题/鉴权文案),任一命中即通过。
|
|
195
|
+
*/
|
|
196
|
+
export const DSH_WEB_MARKERS = [
|
|
197
|
+
"__ModuleLoader__",
|
|
198
|
+
"dsh-client-modules",
|
|
199
|
+
"deepseek harness",
|
|
200
|
+
"dsh web authentication required",
|
|
201
|
+
"dsh-remote"
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
export function looksLikeDshWeb(status, text) {
|
|
205
|
+
const body = String(text || "").toLowerCase();
|
|
206
|
+
if (!body) return false;
|
|
207
|
+
if (status >= 500) return false;
|
|
208
|
+
return DSH_WEB_MARKERS.some((m) => body.includes(m));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 探测一个候选:GET `/`,带身份校验。
|
|
213
|
+
* @returns {Promise<boolean>}
|
|
214
|
+
*/
|
|
215
|
+
export async function probeDshWeb(url, { timeoutMs = PROBE_TIMEOUT_MS, fetchImpl } = {}) {
|
|
216
|
+
const target = normalizeUpstream(url);
|
|
217
|
+
if (!target) return false;
|
|
218
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
219
|
+
if (typeof doFetch !== "function") return false;
|
|
220
|
+
try {
|
|
221
|
+
const ac = new AbortController();
|
|
222
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
223
|
+
let res;
|
|
224
|
+
try {
|
|
225
|
+
// redirect: manual —— 不跟随跳转(跳走的多半不是我们要的宿主)
|
|
226
|
+
res = await doFetch(`${target}/`, { signal: ac.signal, redirect: "manual" });
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
}
|
|
230
|
+
const status = Number(res && res.status) || 0;
|
|
231
|
+
if (status >= 500) return false;
|
|
232
|
+
// 只读前 8KB 足够命中特征;读流失败按"不是"处理(宁可少一个候选,不可误接)
|
|
233
|
+
let text = "";
|
|
234
|
+
try {
|
|
235
|
+
if (res.body && typeof res.body.getReader === "function") {
|
|
236
|
+
const reader = res.body.getReader();
|
|
237
|
+
const { value } = await reader.read();
|
|
238
|
+
text = value ? Buffer.from(value).toString("utf8") : "";
|
|
239
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
240
|
+
} else if (typeof res.text === "function") {
|
|
241
|
+
text = await res.text();
|
|
242
|
+
}
|
|
243
|
+
} catch { /* 读失败 → text 为空 → 判否 */ }
|
|
244
|
+
return looksLikeDshWeb(status, text.slice(0, 8192));
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 发现上游地址(带身份校验)。
|
|
252
|
+
*
|
|
253
|
+
* @param {number[]} [extraPorts] 调用方已知的额外候选(排在静态候选之前;测试与特化部署用)。
|
|
254
|
+
* @returns {Promise<{url: string, source: string, probed: number[], rejected: string[]}>}
|
|
255
|
+
* source: env | file | dsh_web_url | process | static | extra | fallback
|
|
256
|
+
*/
|
|
257
|
+
export async function discoverUpstream({
|
|
258
|
+
relayDir = "",
|
|
259
|
+
env = process.env,
|
|
260
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
261
|
+
fetchImpl,
|
|
262
|
+
platform = process.platform,
|
|
263
|
+
extraPorts = [],
|
|
264
|
+
log
|
|
265
|
+
} = {}) {
|
|
266
|
+
const hint = resolveUpstreamHint({ relayDir, env });
|
|
267
|
+
// ① 显式环境变量:部署方说是什么就是什么(不探测,保持既有语义)
|
|
268
|
+
if (hint.source === "env") return { url: hint.url, source: "env", probed: [], rejected: [] };
|
|
269
|
+
// ② 提示地址先验证:文件/DSh_WEB_URL 都可能过期(宿主换端口/换了程序占用)
|
|
270
|
+
if (hint.url && (await probeDshWeb(hint.url, { timeoutMs, fetchImpl }))) {
|
|
271
|
+
return { url: hint.url, source: hint.source, probed: [portOf(hint.url)], rejected: [] };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ③ 候选并行探测(本地端口,拒绝是瞬时的;并行是为了不让 watcher 卡住)
|
|
275
|
+
const hintPort = portOf(hint.url);
|
|
276
|
+
const entries = candidatePorts({ relayDir, env, platform, extraPorts }).filter((e) => e.port !== hintPort);
|
|
277
|
+
const results = await Promise.all(entries.map(async (e) => ({ ...e, ok: await probeDshWeb(`http://127.0.0.1:${e.port}`, { timeoutMs, fetchImpl }) })));
|
|
278
|
+
const hit = results.find((r) => r.ok);
|
|
279
|
+
if (hit) {
|
|
280
|
+
const url = `http://127.0.0.1:${hit.port}`;
|
|
281
|
+
if (typeof log === "function") log(`[upstream] 动态发现上游 ${url}(来源 ${hit.source};提示 ${hint.url || "无"} 不可用)`);
|
|
282
|
+
return { url, source: hit.source, probed: entries.map((e) => e.port), rejected: hint.url ? [hint.url] : [] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ④ 都没找到:保持历史行为(3080),让上层按"上游没起来"正常重试
|
|
286
|
+
return {
|
|
287
|
+
url: hint.url || FALLBACK_UPSTREAM,
|
|
288
|
+
source: hint.url ? hint.source : "fallback",
|
|
289
|
+
probed: entries.map((e) => e.port),
|
|
290
|
+
rejected: hint.url ? [hint.url] : []
|
|
291
|
+
};
|
|
292
|
+
}
|
package/dsh-setup.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import path from "node:path";
|
|
|
29
29
|
import { spawn, spawnSync, execSync } from "node:child_process";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
32
|
+
import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./clients/dsh-remote/upstream-discovery.mjs";
|
|
32
33
|
|
|
33
34
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
|
|
34
35
|
const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
|
|
@@ -779,14 +780,21 @@ function restartBridgeService() {
|
|
|
779
780
|
* 【2026-09-23 用户实测事故】这里原本处处写死 `http://127.0.0.1:3080`:只要用户的 dsh web
|
|
780
781
|
* 不在默认端口(`dsh web --port 8090`、profile 里配了 port、或 `--port 0` 让系统分配空闲端口),
|
|
781
782
|
* watcher 的在线探测就**永远**失败 → bridge 永远不启动,而面板只会说「正在启动 Bridge…」。
|
|
782
|
-
* 优先级:显式环境变量 > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
|
|
783
|
-
*
|
|
784
|
-
*
|
|
783
|
+
* 优先级:显式环境变量 > **运行时动态发现** > 插件半留下的端口文件 > DSH_WEB_URL > 默认 3080。
|
|
784
|
+
*
|
|
785
|
+
* 为什么还要「动态发现」这一层(2026-09-23 第二起实测,DSH Desktop):
|
|
786
|
+
* DSH Desktop(Electron 壳 `dsh-plugin-desktop`)默认把 Web 服务放在 **43120**,被占用还会 +1;
|
|
787
|
+
* 而端口文件要等**插件半**加载后才有。于是「端口文件还没写」或「写的是上一轮的旧端口」时,
|
|
788
|
+
* 光靠文件仍会永久探错端口。发现逻辑(含 DSH 进程实际监听端口 + Desktop 端口区间 + 身份校验)
|
|
789
|
+
* 统一在 clients/dsh-remote/upstream-discovery.mjs,watcher 与 bridge 共用同一份实现。
|
|
785
790
|
*/
|
|
786
791
|
const UPSTREAM_FILE = ".dsh-upstream";
|
|
792
|
+
/** 运行时动态发现到的上游(优于可能过期的端口文件;进程内缓存)。 */
|
|
793
|
+
let discoveredUpstream = "";
|
|
787
794
|
function upstreamUrl() {
|
|
788
795
|
const explicit = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim();
|
|
789
796
|
if (explicit) return explicit.replace(/\/+$/, "");
|
|
797
|
+
if (discoveredUpstream) return discoveredUpstream;
|
|
790
798
|
try {
|
|
791
799
|
const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
|
|
792
800
|
if (/^https?:\/\/[^\s]+$/i.test(fromFile)) return fromFile.replace(/\/+$/, "");
|
|
@@ -798,28 +806,69 @@ function upstreamUrl() {
|
|
|
798
806
|
if (u.port) return `http://127.0.0.1:${u.port}`; // bridge 的 loopback 围栏只认 127.0.0.1
|
|
799
807
|
} catch { /* 非 URL:忽略 */ }
|
|
800
808
|
}
|
|
801
|
-
return
|
|
809
|
+
return FALLBACK_UPSTREAM;
|
|
802
810
|
}
|
|
803
811
|
/** 上游端口(诊断/日志用;取不到就回退 3080)。 */
|
|
804
812
|
function upstreamPort() {
|
|
805
813
|
try { return Number(new URL(upstreamUrl()).port) || 3080; } catch { return 3080; }
|
|
806
814
|
}
|
|
807
815
|
|
|
816
|
+
/** 发现结果缓存与冷却:避免每轮轮询都去 lsof/pgrep + 逐个端口探测。 */
|
|
817
|
+
let upstreamDiscoveryAt = 0;
|
|
818
|
+
let upstreamDiscoveryInflight = null;
|
|
819
|
+
const UPSTREAM_DISCOVERY_COOLDOWN_MS = 15_000;
|
|
820
|
+
|
|
808
821
|
/**
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
* 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
|
|
822
|
+
* 动态发现上游(带身份校验),成功后写入 `discoveredUpstream` 供全局复用。
|
|
823
|
+
* @param {boolean} force 忽略冷却(探测失败后的重试用)
|
|
812
824
|
*/
|
|
813
|
-
async function
|
|
825
|
+
async function refreshUpstreamDiscovery(force = false) {
|
|
826
|
+
if (upstreamDiscoveryInflight) return upstreamDiscoveryInflight;
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
if (!force && now - upstreamDiscoveryAt < UPSTREAM_DISCOVERY_COOLDOWN_MS) return upstreamUrl();
|
|
829
|
+
upstreamDiscoveryAt = now;
|
|
830
|
+
upstreamDiscoveryInflight = (async () => {
|
|
831
|
+
try {
|
|
832
|
+
const found = await discoverUpstream({ relayDir: CONFIG_DIR, log: (m) => console.log(`[dsh-remote] ${m}`) });
|
|
833
|
+
// 「提示地址验证通过」与「候选命中」都值得记下来;fallback(什么都没找到)不污染现有判断
|
|
834
|
+
if (found.url && found.source !== "fallback") discoveredUpstream = found.url;
|
|
835
|
+
return upstreamUrl();
|
|
836
|
+
} catch {
|
|
837
|
+
return upstreamUrl();
|
|
838
|
+
} finally {
|
|
839
|
+
upstreamDiscoveryInflight = null;
|
|
840
|
+
}
|
|
841
|
+
})();
|
|
842
|
+
return upstreamDiscoveryInflight;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** 上游是否**有响应**(判据刻意宽松:4xx/5xx 也算"端口在听",那种情况问题在别处)。 */
|
|
846
|
+
async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
|
|
814
847
|
try {
|
|
815
848
|
const ac = new AbortController();
|
|
816
849
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
817
|
-
await fetch(
|
|
850
|
+
await fetch(url + "/", { signal: ac.signal });
|
|
818
851
|
clearTimeout(timer);
|
|
819
852
|
return true;
|
|
820
853
|
} catch { return false; }
|
|
821
854
|
}
|
|
822
855
|
|
|
856
|
+
/**
|
|
857
|
+
* dsh web 是否正在运行(端口以 upstreamUrl() 为准,**绝不写死 3080**)。
|
|
858
|
+
* bridge 本身依赖 dsh web 才工作:dsh web 没开时 bridge 起来也会立刻退出,
|
|
859
|
+
* 所以「装完当下 bridge 没在跑」是**正常状态**,不能当失败吓用户(见 install 汇总)。
|
|
860
|
+
*
|
|
861
|
+
* 探测失败时会**主动做一次动态发现**再试一次 —— 这一条正是 DSH Desktop 场景的关键:
|
|
862
|
+
* 上游在 43120(Desktop 默认,占用还会 +1),而端口文件可能还没写或写着上一轮的旧端口。
|
|
863
|
+
*/
|
|
864
|
+
async function isDshWebUp(timeoutMs = 1200) {
|
|
865
|
+
if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
|
|
866
|
+
const before = upstreamUrl();
|
|
867
|
+
const after = await refreshUpstreamDiscovery(true);
|
|
868
|
+
if (after === before) return false;
|
|
869
|
+
return upstreamReachable(after, timeoutMs);
|
|
870
|
+
}
|
|
871
|
+
|
|
823
872
|
/**
|
|
824
873
|
* 解析 dsh web profile 目录(安装与插件管理共用同一口径)。
|
|
825
874
|
*/
|
|
@@ -1125,14 +1174,13 @@ async function runBridge() {
|
|
|
1125
1174
|
/** 限流提示只打一次,避免刷屏。 */
|
|
1126
1175
|
let warnedRateLimit = false;
|
|
1127
1176
|
|
|
1128
|
-
// watcher:检测 dsh web
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
fetch(upstreamUrl() + "/")
|
|
1132
|
-
.then(() => { clearTimeout(t); resolve(true); })
|
|
1133
|
-
.catch(() => { clearTimeout(t); resolve(false); });
|
|
1134
|
-
});
|
|
1177
|
+
// watcher:检测 dsh web 是否存活(端口动态解析,绝不写死 3080),存活才启动 bridge。
|
|
1178
|
+
// 走 isDshWebUp():探测失败会自动做一次动态发现(DSH Desktop 的 43120 / 自定义端口都靠它)。
|
|
1179
|
+
const checkUpstream = () => isDshWebUp(2000);
|
|
1135
1180
|
|
|
1181
|
+
// 先做一次动态发现再打印/派生:否则日志会长期写着错误的端口(用户实测就卡在
|
|
1182
|
+
// 「等待 dsh web(127.0.0.1:3080)启动...」而真实上游在 43120)。
|
|
1183
|
+
await refreshUpstreamDiscovery(true);
|
|
1136
1184
|
console.log(`[dsh-remote] 等待 dsh web(${upstreamUrl()})启动...`);
|
|
1137
1185
|
let bridgeProc = null;
|
|
1138
1186
|
let starting = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.14",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"test": "npm run test:router && npm run test:plugin && npm run test:bridge",
|
|
54
|
-
"test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs",
|
|
54
|
+
"test:router": "NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test packages/relay-router/test/native-device-selection.test.mjs packages/relay-router/test/native-app-entry.test.mjs packages/relay-router/test/presence-report.test.mjs packages/relay-router/test/logger.test.mjs packages/relay-router/test/device-free-assets.test.mjs packages/relay-router/test/sse-streaming-contract.test.mjs",
|
|
55
55
|
"_comment:test:plugin": "DSH_RELAY_BUNDLED_RUNTIME=/nonexistent = 强制走 npx 兜底分支(否则本机跑过 npm pack 后 packages/dsh-remote-web/runtime 存在,用例会改成走本地拷贝)。DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)。DSH_RELAY_DEFAULT_API= 死端口:用例拆掉临时目录后,仍在飞的后台请求(装机上报重试等)会读到空配置并回落「默认云端地址」—— 指到本地死端口才不会打生产。",
|
|
56
56
|
"test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
57
57
|
"_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
|