@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

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.
Files changed (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -82,8 +82,12 @@ function shQuote(s) {
82
82
 
83
83
  /** 默认配置目录(可被 entry config 的 relayDir / DSH_RELAY_DIR 环境变量覆盖)。 */
84
84
  const DEFAULT_RELAY_DIR = process.env.DSH_RELAY_DIR || join(homedir(), ".dsh-remote");
85
- // 默认云端服务地址(SaaS 入口;自建用户在设置页/面板切换)
86
- const DEFAULT_API = "https://n.risegao.cn:13443/relay-api";
85
+ // 默认云端服务地址(SaaS 入口;自建用户在设置页/面板切换)。
86
+ // DSH_RELAY_DEFAULT_API 只改「配置里根本没写 api_url」时的兜底:测试脚本把它指向本地死端口,
87
+ // 这样任何**在临时目录被拆掉之后**才跑到的后台请求(如装机上报的重试)都不会打生产。
88
+ // 真实事故形态见 test/wechat-bind-telemetry.test.mjs 的注释:用例拆目录 → 读到空配置 → 回落生产。
89
+ const DEFAULT_API = String(process.env.DSH_RELAY_DEFAULT_API || "").trim()
90
+ || "https://n.risegao.cn:13443/relay-api";
87
91
  const DEFAULT_APP_URL = "https://n.risegao.cn:13443/app/";
88
92
 
89
93
  /**
@@ -102,7 +106,15 @@ function hardenFile(file) {
102
106
  try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
103
107
  if (process.platform !== "win32") return;
104
108
  const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
105
- if (!who) return;
109
+ // 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
110
+ // 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
111
+ if (!who) {
112
+ if (!hardenWarned) {
113
+ hardenWarned = true;
114
+ console.warn(`⚠️ 无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`);
115
+ }
116
+ return;
117
+ }
106
118
  let r;
107
119
  try {
108
120
  r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
@@ -2441,7 +2453,7 @@ const PLUGIN_ID = "dsh-remote-web";
2441
2453
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
2442
2454
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
2443
2455
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
2444
- const PLUGIN_VERSION = "0.6.9";
2456
+ const PLUGIN_VERSION = "0.6.10-beta.2";
2445
2457
  const UPDATE_LOG = ".dsh-update.log";
2446
2458
  const UPDATE_MARKER = ".dsh-update-running";
2447
2459
 
@@ -3517,8 +3529,10 @@ function ensureConnection(relayDir, opts = {}) {
3517
3529
  // 重装即变,不可跨机器关联);
3518
3530
  // · 只发白名单事件(TELEMETRY_EVENT_NAMES)与白名单 fail_code(TELEMETRY_FAIL_CODES),
3519
3531
  // 原始错误文本一律不透传(它可能含路径/主机名);
3520
- // · 允许的字段**只有**:install_id / 事件名 / fail_code / 版本号 / os(process.platform) /
3521
- // arch(process.arch) / node(仅主版本号)——唯一构造点是 telemetryEventOf();
3532
+ // · 允许的字段**只有**:install_id / 事件名 / fail_code / 插件版本 / **宿主 DSH 版本** /
3533
+ // **更新通道** / os(process.platform) / arch(process.arch) / node(仅主版本号)——唯一构造点是
3534
+ // telemetryEventOf()。宿主版本与通道都是**非识别性的版本串**(形如 0.1.5-rc.2 / beta),
3535
+ // 它们回答的是"这个插件跑在哪个 DSH 上、走的哪条发布通道",不指向任何个人或机器;
3522
3536
  // · 禁止采集(代码与 docs/telemetry.md 双写死):手机号、邮箱、账号 ID、任何会话内容或文件内容、
3523
3537
  // 真实 hostname / 用户名 / 文件路径、密码与密钥、设备指纹(machine_fp)、原始 IP、精确地理位置;
3524
3538
  // · 可关闭:DSH_REMOTE_TELEMETRY=0 → 完全关闭(不生成 install_id、不落任何文件、不发任何请求);
@@ -3526,14 +3540,24 @@ function ensureConnection(relayDir, opts = {}) {
3526
3540
  //
3527
3541
  // 契约(服务端冻结):POST <api_url>/api/telemetry/events,headers
3528
3542
  // { content-type: application/json, x-dsh-client: dsh-remote/<PLUGIN_VERSION> }(无 Authorization),
3529
- // body { install_id, source: "plugin", events: [{ name, at, version, os, arch, node, fail_code? }] },
3543
+ // body { install_id, source: "plugin", events: [{ name, at, version, harness_version, channel, os, arch, node, fail_code? }] },
3530
3544
  // 单批 ≤ 20 条、body ≤ 32KB。
3545
+ // `version` = 插件自身版本;`harness_version` = 插件**运行所在的 DSH 宿主**版本(运行时探测,未知为 ""),
3546
+ // 两者都不是一回事 —— 2026-09 生产问题的根因就是只有前者、答不出"是不是 DSH 兼容性问题"。
3547
+ // `harness_version` / `channel` 必须匹配服务端 TELEMETRY_MISC_RE = /^[A-Za-z0-9._-]{0,32}$/,
3548
+ // 否则会被服务端**静默清空**:所以这里先自判,不合法就发 ""(未知),绝不发一个被改写过的值。
3531
3549
 
3532
- /** 事件名白名单:只用这些,其它一律不发。 */
3550
+ /**
3551
+ * 事件名白名单:只用这些,其它一律不发。
3552
+ * 2026-09 新增 wechat_bound / wechat_unbound:微信机器人通道的**绑定态跳变**(两态模型,
3553
+ * 见 docs/wechat-bot-channel.md §9)—— 这一对该不该有人用、用了之后会不会掉,此前完全看不见。
3554
+ * 与服务端 TELEMETRY_EVENTS 必须**逐字对齐**(少一个 = 服务端静默丢弃,事件看起来"没上报")。
3555
+ */
3533
3556
  const TELEMETRY_EVENT_NAMES = new Set([
3534
3557
  "install_started", "install_failed", "runtime_ready", "bridge_started", "bridge_registered",
3535
3558
  "tunnel_disconnected", "first_remote_ok", "plugin_loaded", "panel_opened", "harness_restart",
3536
3559
  "update_started", "update_failed",
3560
+ "wechat_bound", "wechat_unbound",
3537
3561
  ]);
3538
3562
  /**
3539
3563
  * fail_code 白名单 —— **必须与服务端 TELEMETRY_FAIL_CODES 完全一致**。
@@ -3710,9 +3734,92 @@ function telemetryMarkOnce(relayDir, name) {
3710
3734
  } catch { /* 非关键 */ }
3711
3735
  }
3712
3736
 
3737
+ /**
3738
+ * 微信通道「上次观测到的 bound」的跨进程记忆键(与 first_remote_ok 共用同一份
3739
+ * .telemetry-once.json,0600)。**刻意不新增第二个落盘机制**:这份文件就是本文件里
3740
+ * "跨进程只记一次/只记一个值"的既有约定,绑定的基线属于同一类事实。
3741
+ * 取值:1 = 上次看到已绑定,0 = 上次看到未绑定,键不存在 = **从未观测过**(null)。
3742
+ */
3743
+ const TELEMETRY_WECHAT_BOUND_KEY = "wechat_bound_state";
3744
+ /**
3745
+ * 读回绑定的基线(上次观测值)。键不存在 / 文件缺失 / 损坏 → null(= 从未观测)。
3746
+ * 这里**不能**把 null 当成 false:null 是"没观测过"(首次观测要播种基线、不发事件),
3747
+ * false 是"观测过且未绑定"(之后的 true 才算跳变)。两者混淆会系统性虚高绑定数。
3748
+ */
3749
+ function telemetryWeChatBoundBaseline(relayDir) {
3750
+ const v = telemetryOnceFlags(relayDir)[TELEMETRY_WECHAT_BOUND_KEY];
3751
+ if (v === 1 || v === true) return true;
3752
+ if (v === 0 || v === false) return false;
3753
+ return null;
3754
+ }
3755
+ /** 落盘本次观测到的 bound(0600,写不进去静默)。写失败 → 退化为"每次都是首次观测":宁可少报,绝不重复计数。 */
3756
+ function telemetryMarkWeChatBound(relayDir, bound) {
3757
+ try {
3758
+ if (!telemetryDirReady(relayDir)) return;
3759
+ writeFileSync(join(relayDir, TELEMETRY_ONCE_FILE),
3760
+ JSON.stringify({ ...telemetryOnceFlags(relayDir), [TELEMETRY_WECHAT_BOUND_KEY]: bound ? 1 : 0 }), { mode: 0o600 });
3761
+ } catch { /* 非关键 */ }
3762
+ }
3763
+
3764
+ /**
3765
+ * 版本类字符串的清洗。服务端只接受 TELEMETRY_MISC_RE = /^[A-Za-z0-9._-]{0,32}$/,
3766
+ * 不匹配的字符串字段会被**静默清空**(数据看起来"没上报"而不是"上报错了")。
3767
+ * 所以这里先判:合法且非空 → 原样发;否则发 ""(= 未知)。
3768
+ * **绝不改造原始值**——被截断/替换过的版本号比"未知"更糟:它会污染归因,且无从分辨。
3769
+ */
3770
+ const TELEMETRY_TOKEN_RE = /^[A-Za-z0-9._-]{1,32}$/;
3771
+ function telemetryToken(v) {
3772
+ const s = String(v ?? "");
3773
+ return TELEMETRY_TOKEN_RE.test(s) ? s : "";
3774
+ }
3775
+
3776
+ /**
3777
+ * 宿主(DSH / DeepSeek Harness)版本 —— **运行时经验获取,不猜、不硬编码**。
3778
+ *
3779
+ * 为什么必须有(2026-09 生产问题):注册 48 人只有 27 人成功,但这个问题当天**答不出来** ——
3780
+ * 遥测里的 `version` 是**插件自己**的版本,插件跑在哪个 DSH 上从来没上报过。这类数据**不能回填**,
3781
+ * 只能从改版后的新装机开始积累。
3782
+ *
3783
+ * 取值来源(唯一):本进程的**主模块** process.argv[1]。dsh CLI 的入口是 `<pkg>/lib/bin.js`
3784
+ * (bin 名 `dsh` → 软链 → `<...>/@deepseek-ai/dsh/lib/bin.js`),而 DSH 自己就是用
3785
+ * `<pkg>/lib/../package.json` 读版本的(dsh/lib/bin.js 的 readVersion()),所以"入口的上一级
3786
+ * package.json"是**宿主保证的布局**,不是我们的假设。
3787
+ * 本机实测(dsh 0.1.5-rc.2,真实 `dsh web` 进程的 argv):
3788
+ * argv[1] = /opt/homebrew/bin/dsh(软链)
3789
+ * → realpathSync = /opt/homebrew/lib/node_modules/@deepseek-ai/dsh/lib/bin.js
3790
+ * → ../package.json = { "name": "@deepseek-ai/dsh", "version": "0.1.5-rc.2" } → 采到 "0.1.5-rc.2"
3791
+ * 为什么不用另外三条路(都实测过):
3792
+ * · 环境变量:DSH 进程**不设置任何 DSH_* 版本变量**(实测 `ps eww`:只有 PATH/HOME/proxy 那几个,
3793
+ * 连 DSH_HOME 都没有)→ 无从取值;
3794
+ * · 起 `dsh --version` 子进程:要拉起一个 node(慢),PATH 里没有 dsh 时还要兜底,且**可能挂住** ——
3795
+ * 违反"遥测不得阻塞/挂起"的前提,直接排除;
3796
+ * · cordis 上下文 / 模块解析:宿主只 provide 了 `dshHomePath`,没有版本服务;profile 的
3797
+ * node_modules 里也**没有** @deepseek-ai/dsh(实测),require.resolve 够不到宿主包。
3798
+ * 代价只是读一个本地小 JSON:不起进程、不联网、不可能挂住。任何异常 / 非 dsh 启动
3799
+ * (Electron、被嵌入、直接 node 跑别的脚本、测试进程)→ 返回 ""(未知),绝不抛、绝不阻塞遥测。
3800
+ */
3801
+ const HARNESS_PACKAGE_NAME = "@deepseek-ai/dsh";
3802
+
3803
+ /** 由主模块路径解析宿主版本;读不到 / 不是 DSH / 版本串不合法(含空)→ ""。 */
3804
+ function harnessVersionFromEntry(entry) {
3805
+ try {
3806
+ const real = realpathSync(String(entry || "")); // 软链(/opt/homebrew/bin/dsh)先落回真实文件,否则"上一级"会指错目录
3807
+ const manifest = JSON.parse(readFileSync(join(dirname(real), "..", "package.json"), "utf8"));
3808
+ if (!manifest || manifest.name !== HARNESS_PACKAGE_NAME) return ""; // 不是 DSH 启动(如 Electron / 测试进程)
3809
+ return telemetryToken(manifest.version);
3810
+ } catch {
3811
+ return ""; // 非关键:读不到就是"未知"
3812
+ }
3813
+ }
3814
+
3815
+ /** 本进程运行所在的宿主版本(未知 = "")。每次调用现读:事件量很小,且绝不该缓存出过期结论。 */
3816
+ function harnessVersion() {
3817
+ return harnessVersionFromEntry(process.argv[1]);
3818
+ }
3819
+
3713
3820
  /**
3714
3821
  * 【唯一的 payload 构造点】把事件名 + 少量上下文编译成一条遥测事件。
3715
- * 字段仅限契约白名单:name / fail_code / at / version / os / arch / node。
3822
+ * 字段仅限契约白名单:name / fail_code / at / version / harness_version / channel / os / arch / node。
3716
3823
  * 这里**绝不**写入手机号、邮箱、账号 ID、会话或文件内容、hostname、用户名、文件路径、
3717
3824
  * 密码/密钥、machine_fp、IP、地理位置等任何可识别信息(见 docs/telemetry.md「不采集什么」)。
3718
3825
  * 返回 null = 事件名不在白名单 → 调用方一律不发。
@@ -3722,7 +3829,9 @@ function telemetryEventOf(name, extra = {}) {
3722
3829
  const ev = {
3723
3830
  name,
3724
3831
  at: Date.now(),
3725
- version: PLUGIN_VERSION,
3832
+ version: PLUGIN_VERSION, // 插件自身版本(≠ 宿主版本)
3833
+ harness_version: harnessVersion(), // 宿主 DSH 版本(运行时探测;未知 = "")
3834
+ channel: telemetryToken(UPDATE_TAG), // 更新通道 latest/beta(与"一键更新"同源;未知 = "")
3726
3835
  os: process.platform, // 仅平台名(darwin/linux/win32),非主机名
3727
3836
  arch: process.arch, // 仅架构(arm64/x64)
3728
3837
  node: String(process.versions?.node || "").split(".")[0], // 仅主版本号,如 "22"
@@ -4004,6 +4113,55 @@ function telemetryStop(relayDir) {
4004
4113
  } catch { /* 静默 */ }
4005
4114
  }
4006
4115
 
4116
+ /**
4117
+ * 微信机器人通道的**绑定态跳变** → 匿名遥测(两态模型:未绑定 / 已绑定,见 docs/wechat-bot-channel.md §9)。
4118
+ *
4119
+ * 触发点:每一次把面板请求代理到 bridge 控制面之后顺手调用一次(本文件唯一的调用点)——
4120
+ * **不新增定时器、不新增轮询**,只复用面板本来就会发生的读取节奏。
4121
+ *
4122
+ * 只报**跳变**:
4123
+ * unbound → bound = wechat_bound
4124
+ * bound → unbound = wechat_unbound
4125
+ * 稳态(连续多次读到同一个 bound)**一条都不发**:面板在扫码/绑定期间会反复轮询 status,
4126
+ * 若按"每次读到 bound:true 就记一条",同一台机器的一次绑定会被刷成几十条,指标直接失去意义。
4127
+ *
4128
+ * ★ 首次观测**不是**跳变:进程启动后第一次读到这个文件时,若它已经是 bound:true,说明这次绑定
4129
+ * 可能发生在几天前(宿主/面板过一段时间才会被打开)—— 此时只**播种基线**、不发事件。
4130
+ * 把"启动时就已经绑好"当成新绑定,会把历史存量每天都虚报一遍(这是最容易做错、也最难发现的一种虚高)。
4131
+ * 基线与"是否观测过"记在既有的跨进程文件 .telemetry-once.json(0600,与 first_remote_ok 同一份):
4132
+ * 复用已有持久化约定,既不另造机制,也绝不去写 bridge 拥有的 .wechat-state.json。
4133
+ *
4134
+ * 隐私边界:只取 `bound` 一个布尔量,事件本体仍是 telemetryEventOf() 那 9 个字段(版本/os/arch/node…)。
4135
+ * **绝不**上报 bot_id、bot_token、被绑定的微信用户标识、手机号或 .wechat-state.json 里的任何其它字段
4136
+ * ——这些字段在本函数里连读都不读。
4137
+ * 静默边界:文件缺失 / 内容损坏 / 权限不足 / 半写(JSON 截断)= **本次没有观测**,
4138
+ * 既不抛异常进面板路由,也**不当作 unbound**(把它当 unbound 会在下次读到 false→true 之外的假跳变)。
4139
+ * 开关:DSH_REMOTE_TELEMETRY=0 → 直接返回,连文件都不读、连基线都不落(与其余遥测同一语义)。
4140
+ *
4141
+ * @returns {boolean|null} 本次观测到的 bound;未观测(开关关闭 / 读不出)为 null
4142
+ */
4143
+ function telemetryObserveWeChat(relayDir) {
4144
+ try {
4145
+ if (!telemetryEnabled()) return null; // 关闭:零读取、零落盘、零请求
4146
+ let state = null;
4147
+ try {
4148
+ state = JSON.parse(readFileSync(join(relayDir, WECHAT_STATE_FILE), "utf8"));
4149
+ } catch {
4150
+ return null; // 缺失 / 损坏 / 半写:本次没有观测(不是 unbound)
4151
+ }
4152
+ if (!state || typeof state !== "object" || typeof state.bound !== "boolean") return null;
4153
+ const bound = state.bound;
4154
+ const baseline = telemetryWeChatBoundBaseline(relayDir);
4155
+ if (baseline === bound) return bound; // 稳态:不重复计数
4156
+ telemetryMarkWeChatBound(relayDir, bound); // 先落基线再入队:宁可丢一条,也绝不重复计数
4157
+ if (baseline === null) return bound; // 首次观测:只播种基线(见上方 ★)
4158
+ telemetryRecord(relayDir, bound ? "wechat_bound" : "wechat_unbound");
4159
+ return bound;
4160
+ } catch {
4161
+ return null; // 遥测异常绝不冒泡到面板 / bridge 流程
4162
+ }
4163
+ }
4164
+
4007
4165
  /** 内部接口(仅供本仓库测试与隐私审计;不属于插件对外契约,也不被面板/浏览器半使用)。 */
4008
4166
  export const __telemetryInternals = {
4009
4167
  enabled: telemetryEnabled,
@@ -4015,8 +4173,18 @@ export const __telemetryInternals = {
4015
4173
  // 归因函数也暴露出来:Windows 装机失败此前全落到 unknown,需要能被用例逐条锁住
4016
4174
  failCodeFromText: (text) => telemetryFailCodeFromText(text),
4017
4175
  failCodeFromError: (e) => telemetryFailCodeFromError(e),
4176
+ // 宿主版本探测与字符串清洗也暴露出来:这两条是"能不能答出兼容性问题"的关键,
4177
+ // 必须能被用例逐条锁住(含未知/非法值的降级行为)。
4178
+ harnessVersion: () => harnessVersion(),
4179
+ harnessVersionFromEntry: (entry) => harnessVersionFromEntry(entry),
4180
+ harnessToken: (v) => telemetryToken(v),
4181
+ updateTag: UPDATE_TAG,
4018
4182
  eventNames: [...TELEMETRY_EVENT_NAMES],
4019
4183
  failCodes: [...TELEMETRY_FAIL_CODES],
4184
+ // 微信通道绑定态观测(跳变判定 + 基线落盘)也暴露出来:这是"首次观测 != 跳变"这条
4185
+ // 最容易被写错、且写错就会让绑定数系统性虚高的规则,必须能被用例直接逐条锁住。
4186
+ observeWeChat: (relayDir) => telemetryObserveWeChat(relayDir),
4187
+ wechatBoundBaseline: (relayDir) => telemetryWeChatBoundBaseline(relayDir),
4020
4188
  queueMax: TELEMETRY_QUEUE_MAX,
4021
4189
  batchMax: TELEMETRY_BATCH_MAX,
4022
4190
  bodyMax: TELEMETRY_BODY_MAX,
@@ -4104,6 +4272,235 @@ function maybeReportInstall(relayDir, opts = {}) {
4104
4272
  reportInstallOnce(relayDir, null, opts).catch(() => { /* 静默失败:不影响 UI */ });
4105
4273
  }
4106
4274
 
4275
+ // ---------- 微信机器人通道(bridge 控制面代理) ----------
4276
+ //
4277
+ // 微信通道**不在本进程里**:它跑在 bridge(clients/dsh-remote/wechat-runtime.mjs)中,那里只
4278
+ // bind 127.0.0.1 的控制面。发现方式与鉴权(docs/wechat-bot-channel.md §3/§8/§10):
4279
+ // · 端口:<relayDir>/.wechat-control.json = {port,pid,started_at,header} —— **不含任何密钥**;
4280
+ // · 密钥:<relayDir>/.dsh-config.json 的 bridge_secret(与 bridge 同源),放进
4281
+ // `x-dsh-bridge-secret` 头,**只在宿主进程内使用,绝不下发浏览器**。
4282
+ //
4283
+ // 本半边只做代理:面板 → /dsh-remote/wechat/* → 控制面 /wechat/*。转发回来的一切按键名再脱敏一次
4284
+ // (任何名字含 token 的字段一律丢弃)——bridge 侧已有 sanitizeAccount,这里是第二道闸门,
4285
+ // 即使上游某天回归了漏脱敏、或新增了字段,浏览器也拿不到 bot_token(§8「面板 API 永不回显 token」)。
4286
+ const WECHAT_CONTROL_FILE = ".wechat-control.json";
4287
+ const WECHAT_CONTROL_HEADER = "x-dsh-bridge-secret";
4288
+ /**
4289
+ * 微信通道的**面板状态文件** `{ bound, bot_id, bound_at, connected_at, last_push_ok_at, last_error }`
4290
+ * (bridge 侧写、0600;绑定模型只有「已绑定 / 未绑定」两态,见 docs/wechat-bot-channel.md §9)。
4291
+ * 宿主半边**只读不写** —— 它是 bridge 的财产;本半边只借 `bound` 这一个布尔量做匿名遥测的跳变判定
4292
+ * (其余字段一个都不读、更不可能外发,见下方 telemetryObserveWeChat 的隐私边界)。
4293
+ */
4294
+ const WECHAT_STATE_FILE = ".wechat-state.json";
4295
+
4296
+ /** 控制面单次调用超时(本地回环,正常在毫秒级;超时 = bridge 卡住或端口被别的东西占了)。 */
4297
+ function wechatControlTimeoutMs() {
4298
+ const n = Number(process.env.DSH_WECHAT_CONTROL_TIMEOUT_MS || 0);
4299
+ return Number.isFinite(n) && n > 0 ? n : 6000;
4300
+ }
4301
+ /**
4302
+ * `GET /wechat/bind/poll` 是**长轮询**(bridge 侧一次可以挂到 35s 才回,见 wechat-channel.mjs 的
4303
+ * QR_LONG_POLL_TIMEOUT_MS)。对这一个路由沿用「短超时」会制造**假超时**——bridge 明明在正常等待
4304
+ * 扫码,面板却报「后台服务卡住了」,用户会去重启一个完全正常的后台服务。故只给它放宽,
4305
+ * 其余路由一律短超时(那种超时是真的卡住了)。
4306
+ */
4307
+ const WECHAT_POLL_TIMEOUT_MS = 40_000;
4308
+
4309
+ /** pid 是否还活着(EPERM = 存在但属于别人 → 视为活着)。 */
4310
+ function wechatProcessAlive(pid) {
4311
+ try {
4312
+ process.kill(pid, 0);
4313
+ return true;
4314
+ } catch (e) {
4315
+ return !!(e && e.code === "EPERM");
4316
+ }
4317
+ }
4318
+
4319
+ /**
4320
+ * 读控制面发现文件。**不抛**:任何异常都翻成 {error:{code,error}} —— 因为每种失败对应的用户动作
4321
+ * 都不同(启动后台服务 / 重启 / 一键更新),绝不能塌成一句「失败」。
4322
+ * @returns {{port:number,pid:number}|{error:{code:string,error:string}}}
4323
+ */
4324
+ function readWeChatControl(relayDir) {
4325
+ let raw;
4326
+ try {
4327
+ raw = readFileSync(join(relayDir, WECHAT_CONTROL_FILE), "utf8");
4328
+ } catch {
4329
+ return {
4330
+ error: {
4331
+ code: "no_control_file",
4332
+ error: "本机后台服务还没有运行微信机器人通道(找不到发现文件),通常是两个原因之一:后台服务没启动,或它的版本比面板旧、不支持微信机器人。请先到「📱 远程访问」面板启动后台服务;若已在运行,点那里的「一键更新」升级后再回到本页。",
4333
+ },
4334
+ };
4335
+ }
4336
+ let info = null;
4337
+ try { info = JSON.parse(raw); } catch { info = null; }
4338
+ const port = info && Number.isInteger(info.port) ? info.port : 0;
4339
+ if (!info || typeof info !== "object" || port <= 0 || port > 65535) {
4340
+ return {
4341
+ error: {
4342
+ code: "bad_control_file",
4343
+ error: `微信机器人通道的发现文件(${WECHAT_CONTROL_FILE})内容无法识别,可能是写入中断或文件损坏。到「📱 远程访问」面板重启一次后台服务即可重建它,然后回到本页重试。`,
4344
+ },
4345
+ };
4346
+ }
4347
+ // 发现文件是**上一次**进程写下的:bridge 退出后它会残留。这时如实说「残留」,
4348
+ // 而不是让用户对着「连接被拒绝」去猜——两种情况的处置动作不一样。
4349
+ const pid = Number.isInteger(info.pid) && info.pid > 0 ? info.pid : 0;
4350
+ if (pid && !wechatProcessAlive(pid)) {
4351
+ return {
4352
+ error: {
4353
+ code: "stale_control_file",
4354
+ error: `微信机器人通道的发现文件是上一次后台服务留下的(进程 ${pid} 已不在),现在没有进程在监听。到「📱 远程访问」面板启动/重启后台服务后回到本页重试。`,
4355
+ },
4356
+ };
4357
+ }
4358
+ return { port, pid };
4359
+ }
4360
+
4361
+ /**
4362
+ * 递归丢弃任何**名字里含 token** 的字段(§8:面板 API 永不回显 bot_token)。
4363
+ * 只按键名过滤,其它字段照原样透传 —— 面板需要的字段一个不少。
4364
+ */
4365
+ function scrubWeChatTokens(value, depth = 0) {
4366
+ if (depth > 6 || value === null || typeof value !== "object") return value;
4367
+ if (Array.isArray(value)) return value.map((v) => scrubWeChatTokens(v, depth + 1));
4368
+ const out = {};
4369
+ for (const key of Object.keys(value)) {
4370
+ if (/token/i.test(key)) continue;
4371
+ out[key] = scrubWeChatTokens(value[key], depth + 1);
4372
+ }
4373
+ return out;
4374
+ }
4375
+
4376
+ /**
4377
+ * 把一次面板请求代理到 bridge 控制面。
4378
+ *
4379
+ * @param {string} relayDir 配置目录
4380
+ * @param {string} routePath 控制面路径(如 "/wechat/status")
4381
+ * @param {{method?:string, body?:any, timeoutMs?:number}} [init]
4382
+ * @returns {Promise<{ok:true, body:any}|{ok:false, status:number, code:string, error:string}>}
4383
+ * 失败一律带**各不相同**的人话文案(code 供面板/测试分流);绝不把密钥或上游原始错误回给浏览器。
4384
+ */
4385
+ async function wechatControlCall(relayDir, routePath, init) {
4386
+ const ctl = readWeChatControl(relayDir);
4387
+ if (ctl.error) return { ok: false, status: 503, code: ctl.error.code, error: ctl.error.error };
4388
+
4389
+ const secret = readBridgeSecret(relayDir);
4390
+ if (!secret) {
4391
+ // 没有密钥就不能调用控制面(bridge 侧同样会 403)。如实说是「本机还没拿到设备密钥」,
4392
+ // 而不是伪装成连接故障——此时用户要做的动作是去登录 / 一键更新,不是重启。
4393
+ return {
4394
+ ok: false,
4395
+ status: 503,
4396
+ code: "no_secret",
4397
+ error: "本机配置里还没有设备密钥(bridge_secret),无法安全地调用后台服务的微信通道。请先到「📱 远程访问」面板登录一次(密钥会自动补齐),或点那里的「一键更新」重装运行环境,然后回到本页重试。",
4398
+ };
4399
+ }
4400
+
4401
+ const opts = init || {};
4402
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : wechatControlTimeoutMs();
4403
+ const hasBody = opts.body !== undefined && opts.body !== null;
4404
+ const headers = { accept: "application/json", [WECHAT_CONTROL_HEADER]: secret };
4405
+ if (hasBody) headers["content-type"] = "application/json";
4406
+
4407
+ let res;
4408
+ try {
4409
+ res = await fetch(`http://127.0.0.1:${ctl.port}${routePath}`, {
4410
+ method: opts.method || "GET",
4411
+ headers,
4412
+ body: hasBody ? JSON.stringify(opts.body) : undefined,
4413
+ signal: AbortSignal.timeout(timeoutMs),
4414
+ });
4415
+ } catch (e) {
4416
+ const name = String((e && e.name) || "");
4417
+ const cause = String((e && e.cause && e.cause.code) || (e && e.code) || "");
4418
+ if (name === "TimeoutError" || name === "AbortError" || /timeout/i.test(name)) {
4419
+ return {
4420
+ ok: false,
4421
+ status: 504,
4422
+ code: "timeout",
4423
+ error: `等待后台服务的微信通道响应超时(超过 ${Math.round(timeoutMs / 1000)} 秒)。它可能正忙、卡住了,或端口被别的东西占住了。稍后重试;一直这样请到「📱 远程访问」面板重启后台服务。`,
4424
+ };
4425
+ }
4426
+ if (cause === "ECONNREFUSED") {
4427
+ return {
4428
+ ok: false,
4429
+ status: 502,
4430
+ code: "refused",
4431
+ error: "后台服务的微信通道没有在监听(连接被拒绝)——多半是它刚刚重启完,或者已经退出了。等几秒再试;仍未恢复请到「📱 远程访问」面板重启后台服务。",
4432
+ };
4433
+ }
4434
+ return {
4435
+ ok: false,
4436
+ status: 502,
4437
+ code: "unreachable",
4438
+ error: `连接后台服务的微信通道失败${cause ? `(${cause})` : ""}。请确认「📱 远程访问」面板里的后台服务正在运行,然后重试。`,
4439
+ };
4440
+ }
4441
+
4442
+ let text = "";
4443
+ try { text = await res.text(); } catch { text = ""; }
4444
+ let body = null;
4445
+ try { body = text ? JSON.parse(text) : null; } catch { body = null; }
4446
+ const upstreamError = body && typeof body === "object" && typeof body.error === "string" ? body.error : "";
4447
+
4448
+ // 鉴权/版本这类「上游说不出缘由」的失败,由本半边给出可据以行动的文案;
4449
+ // 上游自己的业务错误(配对码不合法、二维码取不到…)则原样透传——那是写给用户看的原文。
4450
+ if (res.status === 401) {
4451
+ return {
4452
+ ok: false,
4453
+ status: 502,
4454
+ code: "unauthorized",
4455
+ error: "后台服务拒绝了这次调用(设备密钥不匹配)。密钥可能刚被轮换,或后台服务是用旧密钥启动的。到「📱 远程访问」面板点一次「一键更新」补齐并重启后台服务,然后回到本页重试。",
4456
+ };
4457
+ }
4458
+ if (res.status === 403) {
4459
+ return {
4460
+ ok: false,
4461
+ status: 502,
4462
+ code: "forbidden",
4463
+ error: "后台服务没有配置设备密钥,因此关闭了微信通道的控制面(这是安全默认:没有密钥就拒绝一切,而不是放行)。到「📱 远程访问」面板点「一键更新」补齐运行环境与密钥后重试。",
4464
+ };
4465
+ }
4466
+ if (res.status === 404) {
4467
+ return {
4468
+ ok: false,
4469
+ status: 502,
4470
+ code: "no_such_route",
4471
+ error: "后台服务不认识这个微信通道接口,说明它比当前面板旧。到「📱 远程访问」面板点「一键更新」升级后台服务,然后回到本页重试。",
4472
+ };
4473
+ }
4474
+ if (!res.ok) {
4475
+ return {
4476
+ ok: false,
4477
+ status: 502,
4478
+ code: `upstream_${res.status}`,
4479
+ error: upstreamError || `后台服务的微信通道返回了 HTTP ${res.status},没有给出原因。稍后重试;一直这样点「📱 远程访问」里的「一键更新」升级后台服务。`,
4480
+ };
4481
+ }
4482
+ if (body === null || typeof body !== "object") {
4483
+ return {
4484
+ ok: false,
4485
+ status: 502,
4486
+ code: "bad_response",
4487
+ error: "后台服务的微信通道返回了无法解析的内容(不是 JSON),多半是版本不匹配。到「📱 远程访问」面板点「一键更新」升级后台服务后重试。",
4488
+ };
4489
+ }
4490
+ return { ok: true, body: scrubWeChatTokens(body) };
4491
+ }
4492
+
4493
+ /** 代理一次微信通道调用并回写响应(成功体原样透传;失败体统一 {ok:false, code, error})。 */
4494
+ async function sendWeChatProxy(relayDir, res, routePath, init) {
4495
+ const r = await wechatControlCall(relayDir, routePath, init);
4496
+ // 匿名遥测:顺手观测一次绑定态(只认跳变,稳态不重复计数;DSH_REMOTE_TELEMETRY=0 时是零副作用 no-op)。
4497
+ // 放在这里而不是只放 status 路由:bind/verify 与 unbind 也会让 bridge 改写状态文件,
4498
+ // 那时面板可能还没轮到下一次轮询 —— 把观测点挂在唯一的代理出口上,六条路由一个不漏。
4499
+ telemetryObserveWeChat(relayDir);
4500
+ if (r.ok) return sendJson(res, 200, r.body);
4501
+ return sendJson(res, r.status, { ok: false, code: r.code, error: r.error });
4502
+ }
4503
+
4107
4504
  function registerRoutes(ctx, relayDir) {
4108
4505
  // 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
4109
4506
  let profileDir = join(homedir(), ".dsh", "profiles", "web");
@@ -4293,6 +4690,59 @@ function registerRoutes(ctx, relayDir) {
4293
4690
  sendJson(res, 200, { ok: true, retried: true, action: action && action.action ? action.action : "none", connect });
4294
4691
  },
4295
4692
  },
4693
+ // ── 🤖 微信机器人通道(设置页「🤖 微信机器人」栏目) ──────────────────────
4694
+ // 这六条只做代理:真正的协议与扫码状态机在 bridge 里(docs/wechat-bot-channel.md §3/§10)。
4695
+ // 失败一律是 5xx + {ok:false, code, error:<人话>}:面板按 code/文案分流,绝不塌成「失败」两个字。
4696
+ // 路由与契约一一对应,不多也不少(多出来的字段都可能变成 bot_token 的泄漏面)。
4697
+ {
4698
+ method: "GET",
4699
+ path: "/dsh-remote/wechat/status",
4700
+ handler: async (_req, res) => {
4701
+ await sendWeChatProxy(relayDir, res, "/wechat/status");
4702
+ },
4703
+ },
4704
+ {
4705
+ method: "POST",
4706
+ path: "/dsh-remote/wechat/bind/start",
4707
+ handler: async (_req, res) => {
4708
+ await sendWeChatProxy(relayDir, res, "/wechat/bind/start", { method: "POST" });
4709
+ },
4710
+ },
4711
+ {
4712
+ method: "GET",
4713
+ path: "/dsh-remote/wechat/bind/poll",
4714
+ handler: async (_req, res) => {
4715
+ // 长轮询:只有这一条放宽超时(理由见 WECHAT_POLL_TIMEOUT_MS)
4716
+ await sendWeChatProxy(relayDir, res, "/wechat/bind/poll", { timeoutMs: WECHAT_POLL_TIMEOUT_MS });
4717
+ },
4718
+ },
4719
+ {
4720
+ method: "POST",
4721
+ path: "/dsh-remote/wechat/bind/verify",
4722
+ handler: async (req, res) => {
4723
+ // 只转发手机微信上那串数字配对码;不认识的字段一律不带过去(控制面只认 {code})。
4724
+ const body = await readJsonBody(req);
4725
+ if (body.__parseError) {
4726
+ return sendJson(res, 400, { ok: false, code: "bad_request", error: "配对码提交的数据不是合法 JSON,请重新输入。" });
4727
+ }
4728
+ const code = String(body.code == null ? "" : body.code).trim();
4729
+ await sendWeChatProxy(relayDir, res, "/wechat/bind/verify", { method: "POST", body: { code } });
4730
+ },
4731
+ },
4732
+ {
4733
+ method: "POST",
4734
+ path: "/dsh-remote/wechat/bind/cancel",
4735
+ handler: async (_req, res) => {
4736
+ await sendWeChatProxy(relayDir, res, "/wechat/bind/cancel", { method: "POST" });
4737
+ },
4738
+ },
4739
+ {
4740
+ method: "POST",
4741
+ path: "/dsh-remote/wechat/unbind",
4742
+ handler: async (_req, res) => {
4743
+ await sendWeChatProxy(relayDir, res, "/wechat/unbind", { method: "POST" });
4744
+ },
4745
+ },
4296
4746
  {
4297
4747
  method: "GET",
4298
4748
  path: "/dsh-remote/account",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.9",
3
+ "version": "0.6.10-beta.2",
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",