@mrrisega/dsh-remote 0.7.0-beta.2 → 0.7.0-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.
@@ -21,7 +21,7 @@
21
21
  //
22
22
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
23
23
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, constants as fsConstants } from "node:fs";
24
- import { join, dirname, sep, delimiter } from "node:path";
24
+ import { join, dirname, sep, delimiter, isAbsolute, relative } from "node:path";
25
25
  import { execSync, spawn, spawnSync } from "node:child_process";
26
26
  import { randomUUID } from "node:crypto";
27
27
  import { homedir, tmpdir } from "node:os";
@@ -740,6 +740,24 @@ function applySaaSMode(cfg) {
740
740
  * 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
741
741
  */
742
742
  const HARNESS_COOKIE_FILE = ".harness-cookie.json";
743
+ /**
744
+ * fetch 失败时**把真实原因带出来**(2026-10-10)。
745
+ * undici 只给一句 "fetch failed",真因藏在 `e.cause`(ECONNREFUSED / ENOTFOUND /
746
+ * CERT_HAS_EXPIRED / UND_ERR_CONNECT_TIMEOUT / 代理相关…)。以前这层被吞掉,
747
+ * 于是面板上只有"验证码服务不可达: fetch failed",排查只能猜。
748
+ */
749
+ function fetchFailReason(e) {
750
+ const parts = [String((e && e.message) || e)];
751
+ let c = e && e.cause;
752
+ for (let i = 0; c && i < 3; i += 1) {
753
+ const bit = c.code || c.message || c.name;
754
+ if (bit) parts.push(String(bit));
755
+ c = c.cause;
756
+ }
757
+ try { if (e && e.name && !parts.includes(e.name)) parts.push(String(e.name)); } catch { /* 忽略 */ }
758
+ return [...new Set(parts)].join(" ← ");
759
+ }
760
+
743
761
  /**
744
762
  * Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
745
763
  *
@@ -995,6 +1013,14 @@ function runtimeManifest(relayDir) {
995
1013
  */
996
1014
  function missingRuntimeFiles(relayDir) {
997
1015
  if (!existsSync(join(relayDir, "dsh-setup.mjs"))) return ["dsh-setup.mjs"];
1016
+ /* ⚠️ 已知盲区(2026-10-10 发现,**暂不做 gating**):bridge 顶层 `import WebSocket from "ws"` 是裸依赖,
1017
+ 而 runtimeManifest 只跟**相对路径 import**,所以"运行环境里有没有 ws"从未进过这份自检。
1018
+ 试过把 `node_modules/ws` 加进必需清单,但真实语义是 **Node 会向上找 node_modules**
1019
+ (~/.dsh-remote/node_modules/ws 与宿主/父级 node_modules 都可能提供它),
1020
+ 按"某个固定路径必须存在"判会误伤 —— 实测直接打挂 17 个连接流程用例,且真实机器上
1021
+ ws 本来就解析得到(已验证)。所以这里保持"只判入口点名的相对文件",
1022
+ 根治放在**源头**:安装器拷贝插件时必须保留 `runtime/node_modules`(已修 + 有用例),
1023
+ 以及 `npm run sync:local` 会把 ws 一并同步到 relayDir。 */
998
1024
  return runtimeManifest(relayDir).filter((rel) => !existsSync(join(relayDir, rel)));
999
1025
  }
1000
1026
 
@@ -1020,6 +1046,23 @@ function launchAgentPath() {
1020
1046
  if (isDarwin()) return join(homedir(), "Library/LaunchAgents/com.dshremote.bridge.plist");
1021
1047
  return null;
1022
1048
  }
1049
+ /**
1050
+ * ★ 这份自启动 plist 是否**确实是为本 relayDir 装的**(内容里写着 `<relayDir>/dsh-setup.mjs`)。
1051
+ *
1052
+ * 🔴 2026-10-10 事故(两次,同一根因):`reapBrokenAutostart()` 用**当前 relayDir** 判断
1053
+ * "入口脚本缺失"(测试的临时 relayDir 当然没有 dsh-setup.mjs → 判 true),
1054
+ * 但删除的却是 `launchAgentPath()` = **真实 HOME 下那份 plist** → 把开发者本机的自启动
1055
+ * 连 bootout 带删文件一起干掉了(远程访问中断,且日志里看不出异常)。
1056
+ * 判据与删除目标必须是**同一个东西**:先确认这份 plist 属于本 relayDir,再动手。
1057
+ */
1058
+ function plistOwnedByRelayDir(relayDir) {
1059
+ try {
1060
+ const p = launchAgentPath();
1061
+ if (!p || !existsSync(p)) return false;
1062
+ return readFileSync(p, "utf8").includes(join(relayDir, "dsh-setup.mjs"));
1063
+ } catch { return false; }
1064
+ }
1065
+
1023
1066
 
1024
1067
  /**
1025
1068
  * Linux 的 systemd user unit 路径(与 dsh-setup.mjs 的 `autostartFilePath()` **同一路径**)。
@@ -1075,6 +1118,7 @@ Environment=PATH=${SERVICE_PATH}
1075
1118
  ${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
1076
1119
  Environment=DSH_BRIDGE_INSTALL_SOURCE=${installSourceOf(relayDir)}
1077
1120
  Environment=DSH_BRIDGE_INSTALL_VERSION=${PLUGIN_VERSION}
1121
+ Environment=DSH_BRIDGE_HARNESS_VERSION=${harnessVersion()}
1078
1122
 
1079
1123
  [Install]
1080
1124
  WantedBy=default.target
@@ -1557,7 +1601,8 @@ function ensureRuntime(relayDir) {
1557
1601
  windowsHide: true,
1558
1602
  // DSH_BRIDGE_INSTALL_SOURCE:本机运行环境是插件自愈补的 → 安装器原样透传到 plist/bridge env,
1559
1603
  // 于是设备行上的 install_source 如实记成 plugin_market(而不是安装器默认的 npx)。
1560
- env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org", DSH_BRIDGE_INSTALL_SOURCE: "plugin_market" }),
1604
+ // DSH_BRIDGE_HARNESS_VERSION 同理:安装器会把它写进 plist;不给的话,桥重启后就丢了宿主版本。
1605
+ env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org", DSH_BRIDGE_INSTALL_SOURCE: "plugin_market", DSH_BRIDGE_HARNESS_VERSION: harnessVersion() }),
1561
1606
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
1562
1607
  onError: (e) => failSpawn(e, npx.kind),
1563
1608
  });
@@ -1653,7 +1698,7 @@ function writeAutostartFile(relayDir) {
1653
1698
  <key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
1654
1699
  <key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
1655
1700
  <key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
1656
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
1701
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${harnessVersion()}</string></dict>
1657
1702
  </dict></plist>`;
1658
1703
  mkdirSync(dirname(plistPath), { recursive: true });
1659
1704
  writeFileSync(plistPath, plist, { mode: 0o644 });
@@ -1689,6 +1734,8 @@ function reapBrokenAutostart(relayDir) {
1689
1734
  // 只有 macOS 有 launchd 自启动项可摘;Windows/Linux 走的是 systemd/无自启动,判据不适用。
1690
1735
  if (!isDarwin()) return false;
1691
1736
  if (!plistEntryMissing()) return false;
1737
+ // 归属校验:这份 plist 不是给本 relayDir 装的 → 一个字节都不动(见 plistOwnedByRelayDir)
1738
+ if (!plistOwnedByRelayDir(relayDir)) return false;
1692
1739
  const st = launchdStatus();
1693
1740
  if (!st.running && !st.crashing && !st.state) return false; // 作业未被 launchd 加载、无可摘的东西
1694
1741
  for (const target of launchTargets()) sh(`launchctl bootout ${target}`);
@@ -1763,6 +1810,8 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
1763
1810
  env: spawnEnv({
1764
1811
  DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
1765
1812
  DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
1813
+ // 宿主版本:bridge 自己推不出来(它是另一个进程),只能由插件在宿主进程内问出来再注入。
1814
+ DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
1766
1815
  // 上游端口**必须**透传:watcher 用它判断「dsh web 在线吗」,bridge 用它做转发目标。
1767
1816
  // 不传的话两边都回落到写死的 3080 —— dsh web 不在默认端口时 bridge 永远不启动(0.6.12 修)。
1768
1817
  DSH_BRIDGE_UPSTREAM: upstreamUrl(),
@@ -2016,9 +2065,36 @@ function uninstallRuntime(relayDir, protectedPath) {
2016
2065
  // a) 停服务 + 移除自启动文件
2017
2066
  try {
2018
2067
  if (isDarwin()) {
2068
+ /* 🔴 2026-10-10 事故护栏:**relayDir 不在当前 HOME 之下时,绝不碰真实自启动**。
2069
+ 当天某个用例用「真 HOME + 临时 relayDir」调到了这里:plist 路径解析到真实的
2070
+ ~/Library/LaunchAgents/com.dshremote.bridge.plist → 先 bootout 真桥、再把 plist 删了
2071
+ → **开发者本机的远程访问被测试整个卸掉**(job 消失、桥停摆)。
2072
+ 判据:生产部署里 relayDir 一定在 HOME 之下(~/.dsh-remote);
2073
+ 测试夹具的 relayDir 在 /var/folders/... 而 HOME 是真实的 —— 这个不一致必须拒绝。
2074
+ 显式要跨目录操作时设 DSH_RELAY_ALLOW_FOREIGN_SERVICE=1。 */
2075
+ const underHome = (() => {
2076
+ try {
2077
+ const rel = relative(homedir(), relayDir);
2078
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
2079
+ } catch { return false; }
2080
+ })();
2081
+ if (!underHome && process.env.DSH_RELAY_ALLOW_FOREIGN_SERVICE !== "1") {
2082
+ out.serviceSkipped = `relayDir 不在 HOME 之下(${relayDir}),拒绝改动真实自启动服务`;
2083
+ return out;
2084
+ }
2019
2085
  // 只处理「plist 位于当前 HOME」的服务:本插件/dsh-setup.mjs 安装的服务一定在此
2020
2086
  const plistPath = launchAgentPath();
2021
2087
  if (plistPath && existsSync(plistPath)) {
2088
+ /* ★★ 归属校验(2026-10-10 事故的最终护栏):
2089
+ 这份 plist 必须**确实是为本 relayDir 装的**(内容里写着 <relayDir>/dsh-setup.mjs)。
2090
+ 不校验的话,只要有用例拿「真 HOME + 临时 relayDir」走到这里,就会把开发者本机
2091
+ 真实的服务**先 bootout 再删文件**(当天实测发生:桥停摆、远程访问中断)。
2092
+ 归属不符 → 整个服务分支跳过(并如实说明),绝不越界。 */
2093
+ const ownedByThisDir = plistOwnedByRelayDir(relayDir);
2094
+ if (!ownedByThisDir) {
2095
+ out.serviceSkipped = `plist 不属于本 relayDir,拒绝改动:${plistPath}`;
2096
+ return out;
2097
+ }
2022
2098
  // crashing 也要 bootout:崩溃循环中的作业在 launchd 里仍是「已加载」,
2023
2099
  // 只删 plist 文件它照样被 KeepAlive 反复重拉(见 launchdStatus 注释)。
2024
2100
  const st = launchdStatus();
@@ -3112,6 +3188,12 @@ async function proxyFeedback(relayDir, req, res, pathname) {
3112
3188
  const headers = {
3113
3189
  "x-dsh-device": cfg.device_id || "",
3114
3190
  "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
3191
+ // 真实 UA 必须**转发**:以前不转发,上游 fetch 就用 undici 默认 UA(`node`),
3192
+ // 后台「机型」列对每条真实反馈都显示同一个没用值(生产实测 fb_* 全是 user_agent=node),
3193
+ // 于是「Windows 还是 Linux 出的问题」只能靠翻安装日志考古。同时补一个结构化平台字段
3194
+ // (x-dsh-platform),比让后台解析 UA 可靠得多。
3195
+ "user-agent": String(req.headers["user-agent"] || `dsh-remote-web/${PLUGIN_VERSION}`),
3196
+ "x-dsh-platform": process.platform,
3115
3197
  };
3116
3198
  if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
3117
3199
  const auth = req.headers.authorization;
@@ -3234,7 +3316,7 @@ async function proxyAnnouncements(relayDir, req, res, pathname) {
3234
3316
  res.end(text);
3235
3317
  } catch (e) {
3236
3318
  // 网络不可达 / 超时 / JSON 异常:一律 502 + 人话,绝不把异常抛回 webServer(那会变成 500 堆栈)
3237
- return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${e.message}` });
3319
+ return sendJson(res, 502, { ok: false, error: `公告服务不可达: ${fetchFailReason(e)}` });
3238
3320
  }
3239
3321
  }
3240
3322
 
@@ -3527,7 +3609,14 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
3527
3609
  windowsHide: true, // Windows:不弹控制台窗口(POSIX 上该选项被忽略)
3528
3610
  // PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
3529
3611
  // (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
3530
- env: spawnEnv({ DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir), ...(extraEnv || {}) }),
3612
+ env: spawnEnv({
3613
+ DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
3614
+ // 安装器会把这些环境变量原样写进 plist/systemd 单元,所以宿主版本也要一起给它 ——
3615
+ // 否则安装器重启桥之后,新写的自启动项里就没有 harness 版本了。
3616
+ DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
3617
+ DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
3618
+ ...(extraEnv || {}),
3619
+ }),
3531
3620
  stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
3532
3621
  onError,
3533
3622
  });
@@ -3607,6 +3696,7 @@ async function runOnlineUpdate(relayDir) {
3607
3696
  env: spawnEnv({
3608
3697
  DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
3609
3698
  DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
3699
+ DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
3610
3700
  npm_config_registry: "https://registry.npmjs.org",
3611
3701
  }),
3612
3702
  stdio: ["ignore", out, out],
@@ -3869,13 +3959,22 @@ function writePatchDocument(patchFile, text) {
3869
3959
  * 助手做的是「宁可少做也不卡住」的有界清理:杀残留进程 → 删自启动 → 清配置目录,每步独立 try/catch。
3870
3960
  */
3871
3961
  function buildRuntimeCleanupHelper(relayDir, protectedPath) {
3962
+ /* ★ 归属校验(2026-10-10 事故第三条路径):计划里的 plistPath 用的是**当前 homedir**,
3963
+ 而 relayDir 来自调用方 —— 两者不必是同一个目录。曾有测试用「真 HOME + 临时 relayDir」
3964
+ 走到这里,于是这个 detached 助手把**开发者本机真实的自启动 plist 删了**(异步删,
3965
+ 所以在进程内插桩根本抓不到)。现在:plist 里没写着本 relayDir 的入口脚本 → **不给路径**,
3966
+ 助手自然一个字节都不动。 */
3967
+ const homePlist = join(homedir(), "Library", "LaunchAgents", "com.dshremote.bridge.plist");
3968
+ const ownsHomePlist = (() => {
3969
+ try { return existsSync(homePlist) && readFileSync(homePlist, "utf8").includes(join(relayDir, "dsh-setup.mjs")); } catch { return false; }
3970
+ })();
3872
3971
  const plan = JSON.stringify({
3873
3972
  relayDir,
3874
3973
  protectedPath: protectedPath || "",
3875
3974
  platform: osPlatform(),
3876
3975
  taskName: WIN_TASK_NAME,
3877
3976
  pidFiles: [WATCHER_PID_FILE, BRIDGE_PID_FILE],
3878
- plistPath: join(homedir(), "Library", "LaunchAgents", "com.dshremote.bridge.plist"),
3977
+ plistPath: ownsHomePlist ? homePlist : "",
3879
3978
  unitPath: join(homedir(), ".config", "systemd", "user", "dsh-bridge.service"),
3880
3979
  log: join(tmpdir(), "dsh-remote-uninstall.log"),
3881
3980
  });
@@ -4613,6 +4712,8 @@ function buildConnectDiagnostics(relayDir, conn, log) {
4613
4712
  const rows = [
4614
4713
  "dsh-remote 连接诊断",
4615
4714
  `插件版本: ${PLUGIN_VERSION}`,
4715
+ // 宿主版本:生产反馈里最缺的就是这个数 —— 只报插件版本时,「是不是 DSH 升级导致的兼容故障」答不出来
4716
+ `宿主版本: ${harnessVersion() || "未知"}`,
4616
4717
  `配置目录: ${relayDir}`,
4617
4718
  `连接阶段: ${conn.phase}(${conn.text})`,
4618
4719
  `设备 ID: ${conn.deviceId || "(未生成)"}`,
@@ -4678,7 +4779,7 @@ async function buildDiagReport(relayDir, opts = {}) {
4678
4779
  const conn = await composeConnect(relayDir, { manual });
4679
4780
  const cfg = loadConfig(relayDir);
4680
4781
  const head = redactForDiagnostics([
4681
- `【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
4782
+ `【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} 宿主 ${harnessVersion() || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
4682
4783
  `【阶段】${conn.phase}(${conn.text})`,
4683
4784
  `【设备】ID=${conn.deviceId || "(未生成)"} 运行环境=${conn.runtimeReady ? "就绪" : `缺失${conn.runtimeMissing && conn.runtimeMissing.length ? `(缺 ${conn.runtimeMissing.slice(0, 3).join("、")})` : ""}`}`,
4684
4785
  `【bridge】${conn.bridgeRunning ? `在运行 pid=${conn.bridgePids.join(",")}` : "未运行"} 守护=${conn.watcherRunning ? `在运行 pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `(${Math.round(conn.watcherAgeMs / 1000)}s)` : ""}` : "未运行"}${conn.watcherWedged ? " ⚠️活着但拉不起 bridge" : ""}${conn.watcherRestarts ? ` 已自动重启 ${conn.watcherRestarts} 次` : ""}`,
@@ -4714,6 +4815,10 @@ async function submitFeedback(relayDir, payload) {
4714
4815
  "content-type": "application/json",
4715
4816
  "x-dsh-device": cfg.device_id || "",
4716
4817
  "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
4818
+ // 与 proxyFeedback 同一口径:结构化平台字段 + 真实 UA(此处没有浏览器请求可转发,
4819
+ // 就如实写本机平台,绝不冒充浏览器 UA)。
4820
+ "user-agent": `dsh-remote-web/${PLUGIN_VERSION} (${process.platform}; ${process.arch})`,
4821
+ "x-dsh-platform": process.platform,
4717
4822
  };
4718
4823
  if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
4719
4824
  const t = await feedbackAuthToken(relayDir).catch(() => "");
@@ -5180,9 +5285,51 @@ function harnessVersionFromEntry(entry) {
5180
5285
  }
5181
5286
  }
5182
5287
 
5288
+ /**
5289
+ * 由**宿主根目录**解析版本:`<root>/node_modules/@deepseek-ai/dsh/package.json`。
5290
+ *
5291
+ * 【为什么还要这一条(2026-10-09 真机)】桌面端(DeepSeek Harness 0.2,Electron)的宿主进程 argv 是:
5292
+ * [0] …/MacOS/DeepSeek Harness
5293
+ * [1] --expose-internals ← **是开关,不是路径**
5294
+ * [2] …/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/index.js
5295
+ * [3] …/app.asar/dsh ← **宿主根目录**
5296
+ * 于是「取 argv[1] 的上一级 package.json」在桌面端**必然拿不到**(argv[1] 是 `--expose-internals`),
5297
+ * 这就是桌面用户全部上报 harness 版本为空的原因。好在 argv[3] 就是宿主根,其
5298
+ * `node_modules/@deepseek-ai/dsh/package.json` 正是宿主版本(实测 0.2.0-rc.2)。
5299
+ * 桌面端是 Electron,`readFileSync` 能透明读 asar 内的路径,所以这条在真实宿主里可读。
5300
+ */
5301
+ function harnessVersionFromRoot(root) {
5302
+ try {
5303
+ const manifest = JSON.parse(readFileSync(join(String(root), "node_modules", HARNESS_PACKAGE_NAME, "package.json"), "utf8"));
5304
+ if (!manifest || manifest.name !== HARNESS_PACKAGE_NAME) return "";
5305
+ return telemetryToken(manifest.version);
5306
+ } catch {
5307
+ return ""; // 不是宿主根 / 读不到 → 交给下一条候选,绝不抛
5308
+ }
5309
+ }
5310
+
5311
+ /**
5312
+ * 从整条 argv 里找出宿主版本(**顺序试探,先命中先返回**)。三条来源都不联网、不起子进程:
5313
+ * ① 目录形态:某参数本身就是宿主根(桌面端 argv[3]);
5314
+ * ② 文件形态:某参数是 DSH 主模块(Web 版 argv[1]),取它上一级的 package.json;
5315
+ * 开关(`-` 开头)与非绝对路径一律跳过 —— 绝不把 `--expose-internals` 当路径去 realpath。
5316
+ */
5317
+ function harnessVersionFromArgv(argv) {
5318
+ const list = Array.isArray(argv) ? argv : [];
5319
+ for (const raw of list) {
5320
+ const p = String(raw ?? "");
5321
+ if (p === "" || p.startsWith("-") || !isAbsolute(p)) continue;
5322
+ const viaRoot = harnessVersionFromRoot(p);
5323
+ if (viaRoot) return viaRoot;
5324
+ const viaEntry = harnessVersionFromEntry(p);
5325
+ if (viaEntry) return viaEntry;
5326
+ }
5327
+ return "";
5328
+ }
5329
+
5183
5330
  /** 本进程运行所在的宿主版本(未知 = "")。每次调用现读:事件量很小,且绝不该缓存出过期结论。 */
5184
5331
  function harnessVersion() {
5185
- return harnessVersionFromEntry(process.argv[1]);
5332
+ return harnessVersionFromArgv(process.argv);
5186
5333
  }
5187
5334
 
5188
5335
  /**
@@ -5556,6 +5703,8 @@ export const __telemetryInternals = {
5556
5703
  // 必须能被用例逐条锁住(含未知/非法值的降级行为)。
5557
5704
  harnessVersion: () => harnessVersion(),
5558
5705
  harnessVersionFromEntry: (entry) => harnessVersionFromEntry(entry),
5706
+ harnessVersionFromRoot: (root) => harnessVersionFromRoot(root),
5707
+ harnessVersionFromArgv: (argv) => harnessVersionFromArgv(argv),
5559
5708
  harnessToken: (v) => telemetryToken(v),
5560
5709
  updateTag: UPDATE_TAG,
5561
5710
  eventNames: [...TELEMETRY_EVENT_NAMES],
@@ -5582,7 +5731,7 @@ export const __telemetryInternals = {
5582
5731
  * 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
5583
5732
  * 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
5584
5733
  * 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
5585
- * body { device_id?, install_source, install_version, host_os, host_arch },成功 200 {ok:true};
5734
+ * body { device_id?, install_source, install_version, harness_version, host_os, host_arch },成功 200 {ok:true};
5586
5735
  * 404/400 一律忽略(老服务端没有该接口时不能报错)。
5587
5736
  */
5588
5737
  const INSTALL_REPORT_FILE = ".dsh-install-report.json";
@@ -5617,6 +5766,9 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
5617
5766
  ...(cfg.device_id ? { device_id: String(cfg.device_id) } : {}),
5618
5767
  install_source: source,
5619
5768
  install_version: version,
5769
+ // 宿主(DeepSeek Harness)版本:与 install_version(插件版本)是两个数,
5770
+ // 只报插件版本时「是不是 DSH 升级导致的故障」答不出来(2026-10 生产反馈的普遍缺口)。
5771
+ harness_version: harnessVersion(),
5620
5772
  host_os: process.platform,
5621
5773
  host_arch: process.arch,
5622
5774
  };
@@ -6572,7 +6724,7 @@ function registerRoutes(ctx, relayDir) {
6572
6724
  res.writeHead(200, { "content-type": type || "application/json; charset=utf-8", "cache-control": "no-store" });
6573
6725
  res.end(buf);
6574
6726
  } catch (e) {
6575
- sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${e.message}` });
6727
+ sendJson(res, 502, { ok: false, error: `验证码服务不可达: ${fetchFailReason(e)}` });
6576
6728
  }
6577
6729
  },
6578
6730
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.0-beta.2",
3
+ "version": "0.7.0-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",
@@ -254,8 +254,25 @@ function resolveInstallVersion() {
254
254
  return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || "";
255
255
  } catch { return ""; }
256
256
  }
257
+ /**
258
+ * 宿主(DeepSeek Harness)版本:**由插件注入**,bridge 自己算不出来。
259
+ *
260
+ * 为什么必须是注入:宿主版本要从"宿主进程的 argv / 安装目录"推出来,而 bridge 是插件
261
+ * **另外拉起的独立进程**(脱离宿主,见 dsh-setup.mjs 的守护),它的 argv 与宿主毫无关系。
262
+ * 插件跑在宿主进程内,能拿到真实版本(见 packages/dsh-remote-web/lib/index.js 的
263
+ * harnessVersion()),所以由它经 DSH_BRIDGE_HARNESS_VERSION 传下来。
264
+ *
265
+ * 与 INSTALL_VERSION 的区别(曾经把这两个数混为一谈,导致版本问题答不出来):
266
+ * install_version = **本插件**的版本(用户装的 dsh-remote-web 是哪个版本)
267
+ * harness_version = **宿主**的版本(用户跑的 DeepSeek Harness 是哪个版本)
268
+ * 只知其一都定位不了"是不是宿主升级导致的兼容性故障"。
269
+ */
270
+ function resolveHarnessVersion() {
271
+ return String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
272
+ }
257
273
  const INSTALL_SOURCE = resolveInstallSource();
258
274
  const INSTALL_VERSION = resolveInstallVersion();
275
+ const HARNESS_VERSION = resolveHarnessVersion();
259
276
 
260
277
  // ---------- 稳定设备身份(.dsh-config.json) ----------
261
278
 
@@ -1399,6 +1416,9 @@ async function registerDeviceInAccount(token) {
1399
1416
  // install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
1400
1417
  install_source: INSTALL_SOURCE,
1401
1418
  install_version: INSTALL_VERSION,
1419
+ // v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
1420
+ // 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
1421
+ harness_version: HARNESS_VERSION,
1402
1422
  host_os: process.platform,
1403
1423
  host_arch: process.arch
1404
1424
  })
@@ -1028,6 +1028,74 @@ const SCRIPT = `(() => {
1028
1028
  窄屏还会自动收起左栏(viewportWidth < 1024 → narrowExpanded = false)。
1029
1029
  我们插一脚只会把官方推到相反方向(这正是「点开变黑遮罩」那条链的起点)。 */
1030
1030
  let fabDone = false;
1031
+
1032
+ /* ===== 📶 网络延迟采样器(2026-10-10 二修,与"某一代菜单 DOM"彻底解耦)=================
1033
+ 一修(0.7.0-beta.3)改掉了"亚毫秒样本被丢弃",但业主实测**仍然一直「测速中」**,
1034
+ 而同一时段镜像页打了 69 次 /api/agentPresets/list 这类**完全合格**的请求 ——
1035
+ 说明真根因不是样本被过滤,而是**采样根本没接上**(或渲染写进了已被替换的旧节点)。
1036
+
1037
+ 所以这次不再依赖"建菜单时顺手包装一次 fetch":
1038
+ · 安装独立成 maInstallNetSampler(),幂等、**自愈**(发现 window.fetch 上不是我们的采样器
1039
+ 就重新包一层)—— 谁在这之后把 fetch 换掉(我们自己注入的 E2EE shim 会在解锁时再包一次)
1040
+ 都能兜住;
1041
+ · 样本挂 window(跨"菜单被 SPA 重建"共享);
1042
+ · 渲染**每次现查实时 DOM**,不再抱着建菜单时的旧引用(旧引用可能已脱离文档,
1043
+ 于是样本明明在涨、界面却永远停在初始文案);
1044
+ · 空态**直接说明原因**,下次再出问题不用猜:一眼看出是"没接上"还是"接上了但没请求"。 */
1045
+ const maNetPatch = { ok: false, samples: 0, lastUrl: "", lastAt: 0 };
1046
+ try { window.__dshMaNet = maNetPatch; } catch (eNet0) { /* 忽略 */ }
1047
+ /** 当前这一代菜单注册的渲染器(可能为 null:菜单还没建)。 */
1048
+ let maNetRender = null;
1049
+ const maNoteRtt = (ms, url) => {
1050
+ try {
1051
+ if (typeof ms !== "number" || !isFinite(ms)) return;
1052
+ const r = Math.round(ms);
1053
+ // 只拒绝负数与超时;0(本机/回环的真实往返不足 1ms)照收,展示层再夹到 ≥1ms。
1054
+ if (r < 0 || r > 20000) return;
1055
+ const bag = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
1056
+ bag.push(r);
1057
+ while (bag.length > 8) bag.shift();
1058
+ maNetPatch.samples = bag.length;
1059
+ maNetPatch.lastUrl = String(url || "").slice(0, 80);
1060
+ maNetPatch.lastAt = Date.now();
1061
+ if (maNetRender) { try { maNetRender(); } catch (eR) { /* 忽略 */ } }
1062
+ } catch (eN) { /* 忽略 */ }
1063
+ };
1064
+ /** 把采样器挂到 window.fetch 上(**只挂一次**)。返回是否已就绪。 */
1065
+ const maInstallNetSampler = () => {
1066
+ try {
1067
+ if (typeof window.fetch !== "function") return false;
1068
+ /* ⚠️ 幂等判据必须是**稳定标志**,不能拿 window.fetch.__dshMaNetSampler 当判据(2026-10-10 自测抓到):
1069
+ "会话过期探测"那层包装会在本采样器**外面**再包一层(它建菜单时才装,晚于这里),
1070
+ 于是最外层 window.fetch 上没有我们的标记 → 若用函数标记当判据,每一拍都会再包一层,
1071
+ 秒级泄漏(请求穿过 N 层、同一趟被记 N 个样本)。 */
1072
+ if (maNetPatch.installed === true) { maNetPatch.ok = true; return true; }
1073
+ const orig = window.fetch;
1074
+ const wrapped = function (input, init) {
1075
+ let url = "";
1076
+ try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (eU) { url = ""; }
1077
+ const t0 = maNowMs();
1078
+ const p = orig.apply(this, arguments);
1079
+ try {
1080
+ const abs = url.slice(0, 8).toLowerCase();
1081
+ const absolute = abs.indexOf("http://") === 0 || abs.indexOf("https://") === 0;
1082
+ const sameOrigin = !absolute || url.indexOf(window.location.origin) === 0;
1083
+ const apiish = url.indexOf("/api/") >= 0 || url.indexOf("/remote/") >= 0
1084
+ || url.indexOf("_devices") >= 0 || url.indexOf("_quota") >= 0;
1085
+ if (sameOrigin && apiish && p && typeof p.then === "function") {
1086
+ p.then(() => { maNoteRtt(maNowMs() - t0, url); }, () => { /* 网络错误不算延迟 */ });
1087
+ }
1088
+ } catch (eW) { /* 忽略 */ }
1089
+ return p;
1090
+ };
1091
+ wrapped.__dshMaNetSampler = true; // 只作记号便于人造排查(**不作**幂等判据)
1092
+ maNetPatch.installed = true; // 稳定幂等判据
1093
+ window.fetch = wrapped;
1094
+ maNetPatch.ok = true;
1095
+ return true;
1096
+ } catch (eI) { return false; }
1097
+ };
1098
+
1031
1099
  const buildRemoteMenu = () => {
1032
1100
  /* ===== 统一「远程控制」悬浮按钮:字号 + 加密状态 + 返回设备列表 =====
1033
1101
  2026-09-19 用户要求原话:「把字体的菜单和加密小锁的菜单合并成一个小悬浮按钮;点击后可以弹菜单
@@ -1244,9 +1312,11 @@ const SCRIPT = `(() => {
1244
1312
  旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
1245
1313
  反而让用户以为哪里坏了。 */
1246
1314
  const RTT_KEEP = 8, RTT_MAX_MS = 20000;
1247
- const rttSamples = [];
1315
+ /* 建菜单时的引用只作**兜底**:正常路径每次渲染都现查实时 DOM(见 liveNetEl/liveRttEl)。 */
1248
1316
  const rttEl = fab.querySelector('[data-role="rtt"]');
1249
1317
  const netEl = menu.querySelector('[data-role="net"]');
1318
+ /* 样本一律读 window 上那一份(由上面的 maNoteRtt 写入,跨"菜单被重建"共享)。 */
1319
+ const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
1250
1320
  const rttValue = () => {
1251
1321
  if (!rttSamples.length) return null;
1252
1322
  const s = rttSamples.slice().sort((a, b) => a - b);
@@ -1258,37 +1328,45 @@ const SCRIPT = `(() => {
1258
1328
  const rttGrade = (ms) => (ms <= 500 ? "ok" : ms <= 1000 ? "mid" : ms <= 2000 ? "slow" : "bad");
1259
1329
  const rttLabel = (g) => (g === "ok" ? "顺畅" : g === "mid" ? "正常" : g === "slow" ? "偏慢" : "很慢");
1260
1330
  const FAB_TITLE_BASE = "远程控制:字号 / 网络延迟 / 加密状态 / 返回设备列表(可拖动移动位置)";
1331
+ /* 🔴 每次渲染都**现查实时 DOM**(2026-10-10 二修)。
1332
+ 抱着建菜单时的旧引用是"样本在涨、界面却永远停在初始文案"的经典成因:官方 SPA 换掉 body
1333
+ 子树后旧引用脱离文档,更新写进了看不见的节点。现查则永远写用户真正看得见的那一个;
1334
+ 查不到才退回旧引用(宽屏/菜单未开时本来就没有可写的节点)。 */
1335
+ const liveNetEl = () => { try { return document.querySelector(".dsh-ma-menu-net") || netEl; } catch (eQ) { return netEl; } };
1336
+ const liveRttEl = () => { try { return fab.querySelector('[data-role="rtt"]') || rttEl; } catch (eQ2) { return rttEl; } };
1261
1337
  const renderRtt = () => {
1262
1338
  try {
1263
1339
  const ms = rttValue();
1264
1340
  const g = ms === null ? "" : rttGrade(ms);
1265
- if (rttEl) {
1266
- if (ms === null) { rttEl.hidden = true; rttEl.textContent = ""; rttEl.removeAttribute("data-rtt"); }
1341
+ // 0ms 是句谎话(不足 1ms 的真实往返会被四舍五入成 0)→ 展示层夹到 ≥1ms。
1342
+ const shown = ms === null ? null : Math.max(1, Math.round(ms));
1343
+ const rEl = liveRttEl();
1344
+ if (rEl) {
1345
+ if (shown === null) { rEl.hidden = true; rEl.textContent = ""; rEl.removeAttribute("data-rtt"); }
1267
1346
  else {
1268
- rttEl.hidden = false;
1269
- rttEl.textContent = ms + "ms";
1270
- rttEl.setAttribute("data-rtt", g);
1347
+ rEl.hidden = false;
1348
+ rEl.textContent = shown + "ms";
1349
+ rEl.setAttribute("data-rtt", g);
1271
1350
  }
1272
1351
  }
1273
- if (netEl) {
1274
- netEl.setAttribute("data-rtt", g || "");
1275
- const ico = netEl.querySelector(".ico");
1276
- const txt = netEl.querySelector(".txt");
1277
- if (ico) ico.textContent = ms === null ? "📶" : (g === "bad" ? "📵" : "📶");
1278
- if (txt) txt.textContent = ms === null ? "测速中…(等一次网络请求后显示)" : (ms + " ms · " + rttLabel(g));
1352
+ const nEl = liveNetEl();
1353
+ if (nEl) {
1354
+ nEl.setAttribute("data-rtt", g || "");
1355
+ const ico = nEl.querySelector(".ico");
1356
+ const txt = nEl.querySelector(".txt");
1357
+ if (ico) ico.textContent = shown === null ? "📶" : (g === "bad" ? "📵" : "📶");
1358
+ if (txt) {
1359
+ /* 空态**说清原因**(2026-10-10):以前只有一句「等一次网络请求后显示」,
1360
+ 于是"采样器压根没接上"和"接上了但还没有请求"长得一模一样,业主两次反馈
1361
+ 我们都只能猜。现在一眼可分。 */
1362
+ txt.textContent = shown !== null ? (shown + " ms · " + rttLabel(g))
1363
+ : (maNetPatch.ok ? "测速中…(已接上,等一次请求)" : "测速中…(采样器未接上)");
1364
+ }
1279
1365
  }
1280
- try { fab.title = ms === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + ms + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
1366
+ try { fab.title = shown === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + shown + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
1281
1367
  } catch (e) { /* 延迟显示失败绝不影响主功能 */ }
1282
1368
  };
1283
- /** 记一个成功样本(由 fetch 包装在**响应到达**时调用;失败/超时不调用)。 */
1284
- const noteRtt = (ms) => {
1285
- if (typeof ms !== "number" || !isFinite(ms)) return;
1286
- const r = Math.round(ms);
1287
- if (r < 1 || r > RTT_MAX_MS) return; // 亚毫秒=同帧缓存命中;>20s=卡住/超时 → 都不是"网络延迟"
1288
- rttSamples.push(r);
1289
- while (rttSamples.length > RTT_KEEP) rttSamples.shift();
1290
- renderRtt();
1291
- };
1369
+ maNetRender = renderRtt; // 注册给采样器(样品到达时回调它)
1292
1370
  renderRtt();
1293
1371
 
1294
1372
  /* ❌ 这里曾有「打开右侧栏」这一段与 maRightPane* 三件套 —— 2026-09-27 根治时整条删除。
@@ -1437,14 +1515,9 @@ const SCRIPT = `(() => {
1437
1515
  window.fetch = function (input, init) {
1438
1516
  let url = "";
1439
1517
  try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (e5) { url = ""; }
1440
- /* 网络延迟采样(0.6.18):**复用这处已有的包装**,零新增请求。
1441
- t0 取在真正发请求之前、响应到达之后读差值 —— 量到的就是这一趟的真实往返。
1442
- ⚠️ 只在同源且"像 API"的请求上量(与下面会话过期探测同一组判定):
1443
- 静态壳/图片/字体不该代表"网络延迟";telemetry 那条 /dsh-remote/telemetry/... 也
1444
- 不在 apiish 里(它既不含 "/api/" 也不含 "/remote/",只有 "-remote/"),
1445
- 所以一次性的遥测上报不会污染样本。
1446
- ⚠️ 失败(reject)/超时(>20s)不进样本 —— 那不是"延迟",是"没通"(noteRtt 里已过滤)。 */
1447
- const t0 = maNowMs();
1518
+ /* ⚠️ 2026-10-10:这里**不再**做网络延迟采样 —— 采样已独立成 maInstallNetSampler()
1519
+ (与菜单 DOM 解耦、幂等、自愈)。本包装只负责「会话过期探测」这一件事,
1520
+ 否则同一趟请求会被量两次。 */
1448
1521
  const p = origFetch.call(this, input, init);
1449
1522
  try {
1450
1523
  const abs = url.slice(0, 8).toLowerCase();
@@ -1453,7 +1526,7 @@ const SCRIPT = `(() => {
1453
1526
  if (sameOrigin && apiish && p && typeof p.then === "function") {
1454
1527
  p.then((res) => {
1455
1528
  try { noteStatus(res && res.status); } catch (e6) { /* 忽略 */ }
1456
- try { noteRtt(maNowMs() - t0); } catch (e6b) { /* 忽略 */ }
1529
+ /* 延迟采样已移到 maInstallNetSampler,这里只报会话过期 */
1457
1530
  }, () => { /* 网络错误不算会话过期,也不算延迟样本 */ });
1458
1531
  }
1459
1532
  } catch (e7) { /* 忽略 */ }
@@ -1497,6 +1570,11 @@ const SCRIPT = `(() => {
1497
1570
  let done = false;
1498
1571
  const boot = () => {
1499
1572
  if (!HOSTISH()) return;
1573
+ /* 📶 **先**把延迟采样器挂上(2026-10-10):它必须在"任何请求发生之前"就位。
1574
+ 即便 buildRemoteMenu() 因为任何原因没能建出菜单(或抛错),采样器也照挂不误 ——
1575
+ 旧实现把包装写在 buildRemoteMenu 内部的一次性分支里,那正是"请求明明在打、
1576
+ 界面却永远测速中"的结构性原因。 */
1577
+ try { maInstallNetSampler(); } catch (eSampler0) { /* 忽略 */ }
1500
1578
  /* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页时同样需要返回设备列表的出口)。
1501
1579
  失败也不影响手机端布局适配 —— 两条链路各自独立。 */
1502
1580
  if (!fabDone) { fabDone = true; try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ } }
@@ -1964,6 +2042,12 @@ const SCRIPT = `(() => {
1964
2042
  const pump = () => {
1965
2043
  if (maTorn) return;
1966
2044
  if (bootUseful()) { try { boot(); } catch (eBoot) { /* 幂等重试 */ } }
2045
+ /* 📶 采样器**自愈**(2026-10-10):本函数每拍都跑(MutationObserver/1s 兜底),
2046
+ 所以只要 window.fetch 被谁换掉(我们自己注入的 E2EE shim 会在"解锁"时再包一层、
2047
+ 官方若在运行期替换 fetch 同理),下一拍就会把采样器重新挂回去 —— 幂等,已挂上时零开销。
2048
+ 同时顺手重绘一次:官方 SPA 换掉菜单子树后,数字不必等下一次请求才回来。 */
2049
+ try { maInstallNetSampler(); } catch (eSampler) { /* 忽略 */ }
2050
+ try { if (maNetRender) maNetRender(); } catch (eNetRender) { /* 忽略 */ }
1967
2051
  try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
1968
2052
  /* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表
1969
2053
  驱动,官方 frame/输入框一挂上就会在**这一拍或下一拍**(≤1s)命中 —— 这正是契约要求的