@mrrisega/dsh-remote 0.6.10-beta.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,82 +9,32 @@
9
9
  <img alt="License" src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-2ea44f?style=flat-square">
10
10
  </p>
11
11
 
12
- > **离开电脑也能用手机 100% 全功能接管电脑上的 DeepSeek Harness(`dsh web`)**——发消息、看工具执行、审批权限、改设置、管凭据(含特权操作),操作体验与坐在电脑前完全一致。免内网穿透,局域网即连。
12
+ > **离开电脑也能用手机 100% 全功能接管电脑上的 DeepSeek Harness(`dsh web`)**——发消息、看工具执行、审批权限、改设置、管凭据,体验与坐在电脑前一致。免内网穿透,局域网即连。
13
13
  >
14
14
  > **一条命令安装**:`npx @mrrisega/dsh-remote`
15
15
 
16
- dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运行一个守护进程(bridge),
17
- 主动连接中继服务器(relay-router)注册为在线设备;手机浏览器打开 PWA 页面,
18
- 登录后选择设备,即可经隧道进入电脑上的 `dsh web`(HTTP / WebSocket 全量透传)。
16
+ ## 核心功能
17
+
18
+ - **100% 全功能接管**:`dsh web` 的 HTTP / WebSocket 全量透传 —— 对话、工具执行与输出、审批、设置、凭据(含特权操作)都能在手机上完成,不是阉割版。
19
+ - **免内网穿透**:电脑端 bridge 主动连中继注册为在线设备,手机浏览器打开 PWA 即可用;不需要公网 IP,也不用配路由器。
20
+ - **端到端加密(E2EE)**:内容在离开手机前用你的账号密码派生密钥加密、进入电脑后才解密;中继只见路由元数据(路径、大小、时间),读不到内容。
21
+ - **微信机器人通道**:任务完成 / 出错 / 需要拍板时推到微信;在微信里**回一个数字**就能完成审批,会员还能直接派活、切换会话。
22
+ - **两端体验一致**:手机 PWA 还原电脑端界面;电脑端 `dsh web` 侧栏新增「📱 远程访问」入口。
23
+ - **一条命令 + 开机自启**:自动装好 bridge 与 dsh web 插件,并按平台配置自启动与崩溃自愈(macOS launchd / Linux systemd --user / Windows 任务计划程序)。
24
+ - **两种模式随时切**:官方云服务(手机号登录、含超管后台)或自建 relay(访问密钥认证),面板一键切换,互不影响。
25
+ - **插件市场可装**:在 dsh 插件市场搜索 **`dsh-remote-web`** 即可;桌面运行环境由插件在后台自动补齐。
19
26
 
20
27
  ```
21
28
  手机浏览器
22
- └─ /app/ (PWA) 登录 → /_devices 实时设备列表 → 选择设备
29
+ └─ /app/ (PWA) 登录 → /_devices 设备列表 → 选择设备
23
30
  │
24
31
  nginx (HTTPS)
25
- ├─ /app/ → 静态 PWA (native.html)
26
- ├─ /_devices /_login /remote/ /_bridge / → relay-router
27
- └─ /_bridge → relay-router (WebSocket)
28
- └→ bridge (电脑端) → 127.0.0.1:3080 (dsh web)
32
+ ├─ /app/ → 静态 PWA
33
+ ├─ /_devices /_login /remote/ → relay-router
34
+ └─ /_bridge → relay-router (WebSocket)
35
+ └→ bridge (电脑端) → 127.0.0.1:3080 (dsh web)
29
36
  ```
30
37
 
31
- ## 0.6.7 速览(Windows 可用版)
32
-
33
- > **预发中**:先以 `0.6.7-beta.x` 发布在 npm `beta` 通道(`latest` 仍是 0.6.6),
34
- > 待 Windows 真机验证通过后转正式版。安装预发版:
35
- > `npx @mrrisega/dsh-remote@0.6.7-beta.3`;若 dsh web 已起不来,用
36
- > `npx @mrrisega/dsh-remote@0.6.7-beta.3 repair`(只修 profile,不联网)。
37
-
38
-
39
- 0.6.6 及更早的插件半整套「服务状态 / 启停 / 重启」是按 macOS/Linux 写死的
40
- (launchctl / systemd / pgrep / ps / /bin/sh),**在 Windows 上安装一切正常、运行期必死**:
41
- 面板红字「读取状态失败: process.getuid is not a function」、状态永远停在「查询中…」、
42
- 运行环境补不上、点「重启 DeepSeek harness」还会把 dsh web 打挂。本轮全部修掉:
43
-
44
- - **Windows 不再 500**:`process.getuid` 在 Windows 上根本不存在(不是返回 undefined),
45
- 旧的 `launchTarget()` 无条件调它 → 所有读状态接口抛错。现在非 macOS 直接短路 launchd 查询。
46
- - **Windows 能补上运行环境**:`spawn("npx.cmd")` 在 Node ≥20.12 起不带 shell 会抛 EINVAL。
47
- 现在用**当前 node 直接跑 npm 自带的 `npx-cli.js`**(不经 cmd.exe),退回 `.cmd` 时必带 `shell: true`。
48
- - **「重启 DeepSeek harness」不再打挂进程**:Windows 改用 node 跑的 `.mjs` 助手(不再 `/bin/sh`),
49
- 且**先校验 node/入口/工作目录存在再动旧进程**,spawn 真正成功才回报成功。
50
- - **Windows 进程发现**:安装器落 `.dsh-watcher.pid` / `.dsh-bridge.pid`,插件按 pid 判活
51
- (不再依赖 Windows 上不存在的 `pgrep`/`ps`),必要时用 PowerShell 扫 node 进程兜底。
52
- - **Windows 自启动**:安装器在**任务计划程序**注册登录任务 `dsh-remote-bridge`,卸载时一并删除;
53
- 插件自愈也会隐藏地把 bridge 拉起来(日常使用看不到控制台窗口)。
54
- - **面板不再无限转圈**:`bridge-status` 轮询连续失败 3 次会把原因摆到连接卡上(原先静默 catch)。
55
-
56
- > 平台支持:**macOS / Linux / Windows** 均可运行。自启动方式按平台分别是
57
- > launchd、systemd --user、任务计划程序(Windows)。macOS/Linux 的行为与 0.6.6 完全一致。
58
-
59
- ## 0.6.4 速览(首次安装不再卡)
60
-
61
- - **登录后 bridge 自动连上,全程零刷新**:面板自动补运行环境 → 拉起 bridge → 显示「正在连接中继…」,连上后自动变成「已连接 ✅」并刷新二维码与设备列表;失败有可读原因、自动重试倒计时和「复制诊断信息」。
62
- - **手机端自动等待电脑上线**:空设备列表不再让人干等或手动刷新,电脑一上线设备自己出现。
63
- - **安装引导改版**:有插件市场入口就在市场里搜索 **`dsh-remote-web`** 安装(不用敲命令;搜索词是包名 `dsh-remote-web`,不是 `dsh-remote`);没有就复制一条命令;两条路都收敛到「登录后稍等,电脑会自己出现」。
64
- - **安装输出更干净**:不再重复打印同一段引导,结尾只给一份汇总(地址 / 自启动服务 / 下一步);装完当下 dsh web 没开着也会如实说明「打开 dsh web 后 bridge 会自动启动」,不再像报错。
65
- - **macOS 26 自启动死角已修**:macOS 26 会把 `gui/<uid>` 会话域置为 on-demand-only,`RunAtLoad` / `KeepAlive` 失效(服务只登记不启动,`runs = 0`)。现在优先用 `user/<uid>` 域启动(该域仍支持开机自启与崩溃自愈),失败才回退 `gui/<uid>` + `kickstart`(只拉起这一次,会**明确告知**不支持崩溃自愈),再不行退化为后台进程(现在能用、无自启)。自启动服务的 PATH 也补了 `/usr/sbin`、`/sbin`(bridge 要调 `ioreg`)。
66
-
67
- **0.6.2 起已有**
68
-
69
- - **插件市场安装即可用**:市场只装「面板插件」,桌面运行环境(bridge + 自启动)由插件在后台自动补齐;修掉了
70
- 「装完 bridge 起不来且永远不自愈」的三处根因(运行环境缺失仍写自启动 → launchd 崩溃循环;崩溃循环被误判成
71
- 「运行中」→ 自愈停摆;自愈顺序把运行环境排到最后)。
72
- - **首次安装/更新后一键重启 DeepSeek harness**:面板顶部醒目提示「首次安装需要重启 DeepSeek harness」+
73
- 「重启」按钮,面板最底部常驻「🔄 重启 DeepSeek harness」;重启后页面自动恢复,不用手动刷新。
74
- - **状态不再说谎**:面板把「运行环境安装中 / 启动失败(已自动转入修复)」如实展示,不再把崩溃循环显示成「运行中」。
75
-
76
- **0.6.1 起已有**
77
-
78
- - **首次安装体验修复**:装完插件立即登录,二维码与已授权设备列表不再报「尚未登录」红字;中继未就绪时面板自动退避重试,不用再手动刷新页面。
79
- - **企微交流群**:README 底部扫码入群;服务端可在管理后台「推广」页配置交流群二维码,设置面板「加入交流群」按钮与用户反馈页会同步展示。
80
-
81
- **0.6.0 起已有**
82
-
83
- - **扫码即加密,不再要求再输一次密码**:用电脑生成的二维码/一次性链接进入后,由电脑自动授权开启端到端加密;密码登录点设备也自动解锁。全程不输 E2EE 密码、不存服务端。
84
- - **记住本机:刷新/重开不掉回明文**:解锁一次后,手机 App 刷新、镜像页刷新都自动恢复加密(退出登录即清除;浏览器无痕可不用此功能)。
85
- - **移动端体验**:选完会话抽屉自动收起;加密状态徽标几秒后自动缩成小 🔒 不挡界面;修复了移动端设置里“模型提供方目录加载失败”。
86
- - **电脑端侧栏**:官方「设置」旁新增「📱 远程访问」快捷按钮(与官方按钮共存、不遮挡),一键进入远程访问面板。
87
-
88
38
  ## 界面预览
89
39
 
90
40
  | 手机端进入 dsh web,100% 还原电脑端体验 | 手机端密钥登录后的在线设备列表 |
@@ -100,155 +50,63 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
100
50
  | | 开源版(本仓库) | SaaS 云服务版 |
101
51
  |---|---|---|
102
52
  | 服务器 | 你自己部署(任意有公网 IP 的机器) | 由服务商托管 |
103
- | 账号体系 | 无需账号:访问密钥认证(`/_login` 换本地 JWT) | 手机号 + 短信验证码 |
104
- | 后台管理 | 无(密钥即实例管理员) | 超管后台(用户/套餐/审计) |
53
+ | 账号体系 | 无需账号:访问密钥认证 | 手机号 + 短信验证码 |
54
+ | 后台管理 | 无(密钥即实例管理员) | 超管后台(用户 / 套餐 / 审计) |
105
55
  | 许可证 | 本仓库(见下文 License) | 商业授权,闭源 |
106
56
 
107
- 两者可随时切换:电脑端插件面板「连接模式」一键切换,互不影响。
108
-
109
- > 👉 **不想自建服务器 / 没有公网 IP?** 直接使用**官方云服务版**(托管 relay,无公网、4G、异地也稳定,多设备 + 超管后台):
110
- > **https://n.risegao.cn:13443/app/** (免费版 + PRO ¥19/月 + Pro Max ¥49/月,新用户送 7 天 PRO)。
111
- > 客户端仍是同一条命令安装 `npx @mrrisega/dsh-remote`,登录后选择「云服务模式」即可。
57
+ > 👉 **不想自建服务器 / 没有公网 IP?** 直接用**官方云服务版**(无公网、4G、异地都稳定,多设备 + 超管后台):
58
+ > **https://n.risegao.cn:13443/app/** —— 免费版 + PRO ¥19/月 + Pro Max ¥49/月,新用户送 7 天 PRO。
59
+ > 客户端仍是同一条安装命令,登录后选「云服务模式」即可。
112
60
 
113
61
  ## 安装
114
62
 
115
- 需要 Node.js ≥ 20(macOS / Linux / Windows 均可)。电脑端**一条命令**完成安装:
116
- 自动安装 bridge 与 dsh web 插件、写入配置、创建开机自启(macOS launchd / Linux systemd --user /
117
- Windows 任务计划程序):
63
+ 需要 Node.js ≥ 20(macOS / Linux / Windows 均可)。电脑端**一条命令**:
118
64
 
119
65
  ```bash
120
66
  npx @mrrisega/dsh-remote
121
67
  ```
122
68
 
123
- 安装完成后**无需任何命令**:打开 dsh web → 设置 → 「远程访问」,注册/登录手机号即可
124
- (注册在手机端完成,登录后 bridge 自动启动;自建用户在同一面板切「自建服务」标签)。
125
- 原来的独立设置页(`dsh-remote settings`)已移除,避免与插件面板重复造成困惑。
69
+ 装完**无需任何命令**:打开 `dsh web` → 设置 → 「远程访问」,注册 / 登录手机号即可
70
+ (注册在手机端完成;登录后 bridge 自动启动)。自建用户在同一个面板切「自建服务」标签。
126
71
 
127
- 自建模式(自己部署了 relay-router,无需账号体系):
72
+ 自建模式(已部署 relay-router,无需账号体系):
128
73
 
129
74
  ```bash
130
75
  npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访问密钥>
131
76
  ```
132
77
 
133
- 其他命令:`status`(查看状态)、`run`(前台调试)、`settings`(仅显示登录指引)、
134
- `plugin`(重装/卸载 dsh web 插件)。运行 `npx @mrrisega/dsh-remote --help` 查看完整说明。
135
-
136
- > **版本与更新 / 卸载**:dsh 官方插件市场目前不提供更新按钮,也不会改写用户补丁(因此市场
137
- > 卸载会提示「仍通过 insert 引用 dsh-remote-web」而拒绝)。插件设置面板里已内置管理入口
138
- > (dsh web → 设置 → 「远程访问」→「🔄 版本与更新」卡片):显示当前版本、自动检测 npm 新版、
139
- > **一键在线更新**(后台补运行环境并重启 bridge,完成后重启 dsh web 生效)、以及**彻底卸载**
140
- > (移除补丁 include / 依赖 / bundle 与本地文件,之后市场卸载或直接重启均可完成卸载)。
141
-
142
- 源码安装(开发 / 自建服务器):`git clone https://github.com/mrRisega/dsh-remote.git`
143
- 并 `npm install`,见下文各组件说明。
78
+ 其他命令:`status` 查看状态、`run` 前台调试、`plugin` 重装 / 卸载 dsh web 插件;`--help` 看完整说明。
79
+ 升级与卸载都在面板里:dsh web → 设置 → 「远程访问」→「🔄 版本与更新」(检测新版 / 一键在线更新 / 彻底卸载)。
144
80
 
145
81
  ## 安全与隐私
146
82
 
147
- **传输与会话**
148
-
149
- - 全程 HTTPS/WSS(TLS)加密传输;远程访问需先在电脑端登录你的手机号账号,登录会话不
150
- 在中继之外暴露,bridge 按需代持浏览器会话仅供你本人手机使用。
151
- - 电脑端生成的访问链接为**一次性扫码登录**:30 分钟有效、访问一次即失效、可随时在
152
- 「已授权设备」里取消配对;链接不会二次使用,不用时也可点「刷新」立即作废旧链接。
153
-
154
- **端到端加密(E2EE,灰度开启中)**
155
-
156
- - 开启后,手机 ↔ 电脑之间远程操作的**消息内容**(对话、工具执行、审批、凭据与文件等
157
- 正文及 WebSocket 消息)在离开手机前用**你的账号密码派生密钥**加密、进入电脑后才解密;
158
- 中继(router/nginx/enterprise)只可见路由所需信息——目标路径、数据大小与时间——**无法读取内容**。
159
- - **服务端与中继不保存你的密码明文,也不保存解密密钥**:只保存密码经不可逆 KDF 派生的
160
- 校验值用于登录,与加密密钥域分离(详见 [docs/e2ee-protocol.md](docs/e2ee-protocol.md))。
161
- - 请**牢记账号密码**:修改/重置密码会使全部旧设备会话失效,且服务端不保存你的内容、
162
- 无法代为解密旧会话;改密后需在电脑端面板重新登录并重启 bridge,手机端重新解锁。
163
- - 电脑端本机配置(`.dsh-config.json`)会保存账号密码用于自动登录,等于该账号
164
- 内容的“解密权”,请妥善保护电脑;电脑被他人使用期间请退出登录。
165
- - macOS / Linux:文件权限 **0600**(仅本人可读写)。
166
- - Windows:**`0600` 在 Windows 上无效**(Windows 用 ACL,不是 POSIX 权限位)——
167
- 早期版本只写了 `mode: 0o600`,实测该文件拿到的仍是用户目录的**默认继承 ACL**,
168
- 等于没有这层保护。0.6.7-beta.3 起安装器/插件/bridge 都会显式收紧:`icacls` 断开继承、
169
- 只授予当前用户。若收紧失败(无 icacls / 权限异常),安装日志会明确告警,不会假装成功。
170
- - 无论哪个平台:**不要把 `.dsh-remote` 目录交给备份/同步盘/他人排查**——
171
- 里面的 `.dsh-config.json`(明文账号密码)与 `.harness-cookie.json`(dsh web 会话 Cookie,
172
- 等于该会话的完整访问权)被复制走就等于把钥匙一起给了对方。发支持包前请先删除这两项。
173
-
174
- **边界与建议(如实告知)**
175
-
176
- - E2EE 保护的是**内容**:HTTPS 下的静态页面壳与路由元数据(页面骨架、路径、大小、时间、
177
- 是否加密)仍对中继可见;既有登录与审计不受影响。
178
- - E2EE 为**分阶段开启的功能**:以服务端开关逐步放量(默认关闭 = 走 HTTPS 明文回退),面板会显示
179
- 当前加密状态与原因(已启用 / 等待服务端开启 / 普通安全连接等),不会静默降级。
180
- - 建议上线前用**真机回归**一次完整链路:手机登录解锁 → 🔒 加密访问(对话/工具/审批/凭据)→
181
- 明文回退提示 → 修改密码后旧会话全部失效、重新登录恢复。
83
+ - **传输**:全程 HTTPS / WSS;远程访问需先在电脑端登录你的账号,一次性扫码链接 30 分钟有效、访问一次即失效、可随时取消配对。
84
+ - **端到端加密(E2EE)**:开启后内容在中继侧不可读(中继只见路径、大小、时间)。这是**分阶段开启**的功能(服务端开关逐步放量,默认关闭 = 走 HTTPS),面板会显示当前加密状态与原因,不会静默降级。服务端只保存密码经不可逆 KDF 派生的校验值,**不保存明文密码、也不保存解密密钥**——请牢记密码,改密会使旧设备会话全部失效。协议见 [docs/e2ee-protocol.md](docs/e2ee-protocol.md)。
85
+ - ⚠️ 电脑端 `.dsh-config.json` 保存账号密码用于自动登录(等于该账号内容的解密权),**不要把 `.dsh-remote` 目录交给备份 / 同步盘 / 他人排查**——要发支持包请先删掉 `.dsh-config.json` 与 `.harness-cookie.json`。
182
86
 
183
87
  ## 匿名装机统计与隐私
184
88
 
185
- 生产诊断发现「注册了但设备一直没连上」的用户没有任何账号数据、无法归因,因此插件内置了一条
186
- **匿名装机统计**通道,只上报**装机与连接是否成功**:
187
-
188
- - **采**:事件名(安装开始/失败原因/运行环境就绪/bridge 是否注册成功/首次远程打通/面板打开/重启/更新、
189
- **微信机器人通道的绑定/解绑**)、
190
- 失败码(白名单,如 `npm_unreachable`、`npm_eacces`、`runtime_install_timeout`)、插件版本号、
191
- 系统平台(`darwin`/`linux`/`win32`)、架构(`arm64`/`x64`)、node 主版本号,
192
- 以及一个**本机随机 ID**(`crypto.randomUUID()`,非硬件派生、重装即变、不可跨机器关联);
193
- - **不采**:手机号 / 邮箱 / 账号 ID、任何会话或文件内容、真实 hostname / 用户名 / 文件路径、
194
- 密码与密钥、设备指纹 `machine_fp`、原始 IP、精确地理位置;请求**不带 Authorization**(匿名、与账号解耦);
195
- - **可关**:`export DSH_REMOTE_TELEMETRY=0` → 完全关闭(不生成随机 ID、不落任何文件、不发任何请求);
196
- 也可在中继/Nginx 侧直接丢弃 `POST /api/telemetry/events`;
197
- - **可查**:面板「关于 dsh-remote」卡片底部有一行说明与链接,完整字段清单与核实方法见
198
- [docs/telemetry.md](docs/telemetry.md)。
199
-
200
- ## 数据与隐私
201
-
202
- 上一节讲的是「匿名装机统计」这条通道本身;这一节回答更常见的问题:**你的数据落在哪里、我们到底统计什么**。
203
-
204
- **开源部分采集 / 不采集**
205
-
206
- - **采**(开源部分只有一件事):装机与连接是否成功这条**匿名**统计通道,字段清单见上一节。
207
- - **不采**:**你的会话内容**(对话、工具执行、审批、凭据、文件正文与 WebSocket 消息——中继侧不采集内容)、
208
- **你的账号**(匿名通道不接受任何账号/设备关联,请求不带 Authorization)、**原始 IP**
209
- (匿名通道不上报 IP,也不做任何按 IP 的关联分析)、真实 hostname / 用户名 / 文件路径。
210
- - **官方云服务额外记录的**:只有**接入事件**(注册 / 设备接入 / 真实登录 / 首次打通 / 首次看到安装引导),
211
- 同样是事件级、不含任何内容。**开源部分不产生也不上报这类事件。**
212
- - **装机漏斗统计的粒度**:云服务的漏斗统计**建立在审计日志之上**,因此口径是「注册 / 新增设备 /
213
- 真实登录 / 首次打通」这类**事件条数**——**不是内容,也不是行为轨迹**。
214
- 桥接进程每次启动都会重新登记设备,**重复登记不记为新增**,所以「新增设备数」对得上真实装机量,
215
- 不会被反复重连刷高。
216
- - 完整字段级清单、可核实方法与自行关闭方式见 [docs/telemetry.md](docs/telemetry.md)。
217
-
218
- **如何关闭**
219
-
220
- ```bash
221
- export DSH_REMOTE_TELEMETRY=0 # 完全关闭匿名装机统计:不生成随机 ID、不落文件、不发请求
222
- ```
89
+ 为了让「装完却连不上」可归因,插件内置一条**匿名装机统计**通道,只上报**装机与连接是否成功**:
223
90
 
224
- 也可在网络侧直接丢弃 `POST /api/telemetry/events`(中继 / Nginx / 防火墙),
225
- 关闭后不影响面板、bridge 与连接流程的任何功能。
91
+ - **采**:事件名(安装开始/失败原因/运行环境就绪/bridge 是否注册成功/首次远程打通/面板打开/重启/更新、微信机器人通道的绑定与解绑)、白名单失败码、插件版本、系统平台与架构、Node 主版本,以及一个**本机随机 ID**(`crypto.randomUUID()`,非硬件派生、重装即变、不可跨机器关联)。
92
+ - **不采**:手机号 / 邮箱 / 账号 ID、任何会话或文件内容、真实 hostname / 用户名 / 文件路径、密码与密钥、设备指纹、原始 IP、精确地理位置;请求**不带 Authorization**(匿名、与账号解耦)。
93
+ - **关闭**:`export DSH_REMOTE_TELEMETRY=0` —— 不生成随机 ID、不落任何文件、不发任何请求;也可在网络侧直接丢弃 `POST /api/telemetry/events`。关闭不影响面板、bridge 与连接流程的任何功能。
94
+ - **自建模式**:统计只发往你自己配置的 `api_url`(不经第三方);没有账号体系与云侧统计,数据只落在你自己的服务器。
226
95
 
227
- **自建模式(self-hosted)**
228
-
229
- 自己部署 `relay-router` 时,**数据只落到你自己的服务器**:
230
-
231
- - 匿名统计发往你在 `.dsh-config.json` 里配置的 `api_url`(即你的实例),不经过任何第三方服务;
232
- - 没有账号体系、也没有管理端后台,不存在「注册 / 接入事件」的云侧统计;
233
- - 远程操作的内容只经过**你自己的**中继;开启 E2EE 后端到端加密(手机 ↔ 电脑),中继也读不到内容;
234
- - 是否保留数据、保留多久,完全由你决定;不想留任何统计就 `DSH_REMOTE_TELEMETRY=0`。
96
+ 完整字段清单与可核实方法见 [docs/telemetry.md](docs/telemetry.md)。
235
97
 
236
98
  ## 自建部署(开源版)
237
99
 
238
- 1. 在有公网 HTTPS 入口的服务器上部署 `relay-router`(见 [docs/self-hosting.md](docs/self-hosting.md)):
239
-
240
- ```bash
241
- git clone https://github.com/mrRisega/dsh-remote.git && cd dsh-remote
242
- npm install
243
- bash deploy/install-open.sh # 生成 open.env(0600)并启动 router
244
- ```
245
-
246
- 或 Docker:`DSH_LOCAL_JWT_SECRET=… DSH_LOCAL_ACCESS_KEYS=… docker compose up -d`
100
+ ```bash
101
+ git clone https://github.com/mrRisega/dsh-remote.git && cd dsh-remote
102
+ npm install
103
+ bash deploy/install-open.sh # 生成 open.env(0600)并启动 router
104
+ ```
247
105
 
248
- 2. nginx 反代:参考 [deploy/nginx-13443-remote-router.conf](deploy/nginx-13443-remote-router.conf)
249
- (`/app/` 静态 PWA、`/_bridge` WebSocket 升级、其余路径转 router)。
250
- 3. 手机打开 `https://<你的域名>/app/`,用访问密钥登录。
251
- 4. 被控电脑执行上面的 `npx … setup --server … --key …`。
106
+ 或 Docker:`DSH_LOCAL_JWT_SECRET=… DSH_LOCAL_ACCESS_KEYS=… docker compose up -d`。
107
+ nginx 反代参考 [deploy/nginx-13443-remote-router.conf](deploy/nginx-13443-remote-router.conf);
108
+ 之后手机打开 `https://<你的域名>/app/` 用访问密钥登录,被控电脑执行上面的 `setup --server … --key …`。
109
+ 完整步骤见 [docs/self-hosting.md](docs/self-hosting.md)。
252
110
 
253
111
  ## 组件
254
112
 
@@ -258,31 +116,30 @@ export DSH_REMOTE_TELEMETRY=0 # 完全关闭匿名装机统计:不生成
258
116
  | bridge | `clients/dsh-remote/` | 电脑端守护进程:连 router 注册,把转发帧代理到本地 `dsh web`;心跳自愈 |
259
117
  | PWA | `clients/dsh-web/native.html` | 手机端:登录 / 注册 / 设备选择(单文件,零构建) |
260
118
  | dsh web 插件 | `packages/dsh-remote-web/` | 设置页「远程访问」面板:连接模式 / 账号 / bridge 启停 / 反馈 |
261
- | 部署脚本 | `deploy/` | `install-open.sh` 自建引导、nginx 参考配置、Dockerfile / compose |
119
+ | 部署脚本 | `deploy/` | `install-open.sh`、nginx 参考配置、Dockerfile / compose |
262
120
 
263
- 测试:`npm test`(router 契约 + 插件 + bridge 全部单测与回归)。
121
+ 测试:`npm test`。
264
122
 
265
- ## 企微交流群
123
+ ## 交流与反馈
266
124
 
267
- 扫码加入 **企微交流群**:安装/使用答疑、问题反馈、版本更新都会在群里同步,欢迎来聊。
125
+ 扫码加入 **企微交流群**(安装答疑、问题反馈、版本更新同步):
268
126
 
269
127
  <img src="image/企微交流群.jpg" alt="企微交流群" width="240">
270
128
 
271
- > 也可以在仓库提 [Issue](https://github.com/mrRisega/dsh-remote/issues);安全相关问题请按 [SECURITY.md](SECURITY.md) 私下反馈。
129
+ 也可以在仓库提 [Issue](https://github.com/mrRisega/dsh-remote/issues);安全相关问题请按 [SECURITY.md](SECURITY.md) 私下反馈。
272
130
 
273
131
  ## 文档
274
132
 
275
- - [docs/self-hosting.md](docs/self-hosting.md) — 开源自建完整指南(含安全提示)
276
- - [docs/telemetry.md](docs/telemetry.md) — 匿名装机统计:采集/不采集清单与关闭方法
277
- - [CONTRIBUTING.md](CONTRIBUTING.md) — 贡献指南
278
- - [SECURITY.md](SECURITY.md) — 安全策略与漏洞报告流程
279
133
  - [CHANGELOG.md](CHANGELOG.md) — 版本记录
134
+ - [docs/self-hosting.md](docs/self-hosting.md) — 开源自建完整指南
135
+ - [docs/telemetry.md](docs/telemetry.md) — 匿名装机统计:采集 / 不采集清单与关闭方法
136
+ - [docs/e2ee-protocol.md](docs/e2ee-protocol.md) — 端到端加密协议
137
+ - [CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md)
280
138
 
281
139
  ## License
282
140
 
283
- 本仓库使用 **PolyForm Noncommercial 1.0.0**([LICENSE](LICENSE)):
284
- 个人、研究与非商业用途免费;商业用途(含内部自用与对外服务)需要商业授权,
285
- 见 [COMMERCIAL-LICENSE.md](COMMERCIAL-LICENSE.md)。
141
+ 本仓库使用 **PolyForm Noncommercial 1.0.0**([LICENSE](LICENSE)):个人、研究与非商业用途免费;
142
+ 商业用途(含内部自用与对外服务)需要商业授权,见 [COMMERCIAL-LICENSE.md](COMMERCIAL-LICENSE.md)。
286
143
 
287
- > 说明:本仓库是**源码公开、非商业许可**的项目,不属于 OSI 意义上的“开源”;
144
+ > 本仓库是**源码公开、非商业许可**的项目,不属于 OSI 意义上的「开源」;
288
145
  > 云服务(多用户账号、超管后台)为闭源商业组件,不在本仓库内。
@@ -1477,3 +1477,192 @@ test("★ 多审批 + 提醒混排:回执仍按 FIFO 走审批(提醒不参与
1477
1477
  await ilink.close();
1478
1478
  }
1479
1479
  });
1480
+
1481
+ // ---------------------------------------------------------------------------
1482
+ // 未知指令:绝不静默,而且帮助文案必须**按档位分层**
1483
+ //
1484
+ // 原先这条路径透传上游的静态 HELP_TEXT —— 那份清单把 /new、/ls、/use、/summary 与
1485
+ // "直接发一句话"一并列成"你能用的"。免费用户敲错一个字,拿到的就是这张**会员清单**,
1486
+ // 照着做再吃一次拒绝,于是"打错命令"被理解成"这功能坏了"。
1487
+ // 业主红线:话术与权限必须一致、不多承诺。
1488
+ // ---------------------------------------------------------------------------
1489
+
1490
+ test("★ 免费用户敲未知指令:要回显命令 + 给**分层**帮助,不得把会员指令列成『你能用的』", async () => {
1491
+ const ilink = await fakeIlink();
1492
+ try {
1493
+ const sub = makeFakeSubscriber();
1494
+ const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "free" });
1495
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/reusme" } }] });
1496
+ const t = lastText(ilink.state);
1497
+ assert.ok(t, "★未知指令绝不能静默(一个字都不回 = 用户以为机器人死了)");
1498
+ assert.match(t, /不认识这条指令:\/reusme/, "必须原样回显他敲的那条,他才知道哪个字打错了");
1499
+ assert.match(t, /现在能用的/, "必须给分层帮助(免费档的『现在能用的』)");
1500
+ assert.match(t, /会员功能/, "会员指令要明确归到『会员功能』下");
1501
+ // 关键:不能把会员指令混进"能用"的那一段
1502
+ const usable = t.split("会员功能")[0];
1503
+ assert.ok(!/\/new/.test(usable), "★『现在能用的』里不得出现 /new(免费档做不到)");
1504
+ assert.ok(!/\/ls/.test(usable), "★『现在能用的』里不得出现 /ls");
1505
+ } finally {
1506
+ await ilink.close();
1507
+ }
1508
+ });
1509
+
1510
+ test("★ 付费用户敲未知指令:给完整清单(不因修免费档把付费档的信息也砍掉)", async () => {
1511
+ const ilink = await fakeIlink();
1512
+ try {
1513
+ const sub = makeFakeSubscriber();
1514
+ const { rt } = await boundRuntime(ilink, { subscriber: sub, tier: "pro" });
1515
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/reusme" } }] });
1516
+ const t = lastText(ilink.state);
1517
+ assert.match(t, /不认识这条指令:\/reusme/);
1518
+ assert.match(t, /\/new/, "付费档应当看到 /new");
1519
+ assert.match(t, /\/ls/, "付费档应当看到 /ls");
1520
+ assert.ok(!/会员功能/.test(t), "付费档不该再出现『会员功能』这段(他就是会员)");
1521
+ } finally {
1522
+ await ilink.close();
1523
+ }
1524
+ });
1525
+
1526
+ // ---------------------------------------------------------------------------
1527
+ // 细粒度权益(后台可配权限之后才可能出现)
1528
+ //
1529
+ // 管理员可以只勾细粒度 `assign.new` 而**不勾**粗粒度根 `assign`。旧代码拿
1530
+ // `#can("assign")` 当"是不是付费用户"的代理 —— 那种配置下返回假,于是**付费用户**
1531
+ // 拿到免费档的帮助、完成小结后被塞"接着交代属于会员功能"、额度提醒里被告知
1532
+ // "派活是会员能力"。三处都在对已经付了钱的人说不成立的话。
1533
+ // ---------------------------------------------------------------------------
1534
+
1535
+ /** 只给细粒度 assign.new、不给粗根 assign 的付费权益包。 */
1536
+ const FINE_GRAINED_PRO = Object.freeze({
1537
+ rev: "rev-fine", plan: "pro",
1538
+ caps: ["notify", "approve", "status", "stop", "assign.new", "sessions"],
1539
+ limits: { messages_per_month: 0 }
1540
+ });
1541
+
1542
+ test("★★ 细粒度权益(只有 assign.new、没有粗根 assign)的付费用户:帮助不得降级成免费档", async () => {
1543
+ const ilink = await fakeIlink();
1544
+ try {
1545
+ const { rt } = await boundRuntime(ilink, {
1546
+ subscriber: makeFakeSubscriber(), tier: "pro", entitlements: FINE_GRAINED_PRO
1547
+ });
1548
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
1549
+ const t = lastText(ilink.state);
1550
+ assert.ok(!/通知版/.test(t), "★他能开新任务,不该被标成通知版(旧判据看粗根 assign 会误判)");
1551
+ assert.match(t, /\/new/, "他确实有 assign.new,帮助里必须出现 /new");
1552
+ assert.match(t, /\/ls/, "他确实有 sessions,帮助里必须出现 /ls");
1553
+ // 确实没有的才落在会员区
1554
+ const locked = t.split("会员功能")[1] || "";
1555
+ assert.match(locked, /summary/, "没有 summary 的才该落在会员区");
1556
+ assert.ok(!/\/new/.test(locked), "★已经有的能力绝不能落在会员区(那是少承诺,同样是不一致)");
1557
+ } finally {
1558
+ await ilink.close();
1559
+ }
1560
+ });
1561
+
1562
+ test("★ 细粒度权益用户的完成小结:不得塞『接着交代下一步属于会员功能』", async () => {
1563
+ const ilink = await fakeIlink();
1564
+ try {
1565
+ const { rt } = await boundRuntime(ilink, {
1566
+ subscriber: makeFakeSubscriber(), tier: "pro", entitlements: FINE_GRAINED_PRO
1567
+ });
1568
+ await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "s1", reason: "completed", at: 1 });
1569
+ const t = lastText(ilink.state);
1570
+ assert.ok(!/属于会员功能/.test(t), "★他有 assign.new,不该被告知接着交代是会员功能");
1571
+ } finally {
1572
+ await ilink.close();
1573
+ }
1574
+ });
1575
+
1576
+ test("★ 细粒度权益用户的额度提醒:不得把他已经有的能力说成会员能力", async () => {
1577
+ const ilink = await fakeIlink();
1578
+ try {
1579
+ // limits=2 → 阈值 min(ceil(1.6)=2, max(1,0)=1) = 1,发一条就到线(不必真发 85 条)
1580
+ // ⚠️ caps 必须含 assign.continue:quotaRuntime 预设了当前会话,纯文本派活走的是"接着聊"这条路;
1581
+ // 缺了它会被直接拒掉、用量不涨,提醒根本不会触发(前提就不成立)。
1582
+ const { rt } = await quotaRuntime(ilink, {
1583
+ caps: ["notify", "approve", "status", "stop", "assign.continue", "assign.new", "sessions"],
1584
+ limits: { messages_per_month: 2 }
1585
+ });
1586
+ await sendTask(rt, "派活");
1587
+ await waitFor(() => quotaNotices(ilink.state).length === 1);
1588
+ const t = quotaNotices(ilink.state)[0] || "";
1589
+ assert.ok(t, "前提:提醒确实发出来了");
1590
+ assert.ok(!/开新任务属于会员能力/.test(t), "★他有 assign.new,不该被告知开新任务是会员能力");
1591
+ assert.ok(!/派活属于会员能力/.test(t), "★同上");
1592
+ } finally {
1593
+ await ilink.close();
1594
+ }
1595
+ });
1596
+
1597
+ test("★ 免费档(有 assign.continue 没 assign.new)的额度提醒:只说缺的那一项,不否定他还能接着聊", async () => {
1598
+ const ilink = await fakeIlink();
1599
+ try {
1600
+ const { rt } = await quotaRuntime(ilink, {
1601
+ caps: ["notify", "approve", "status", "stop", "assign.continue"],
1602
+ limits: { messages_per_month: 2 }
1603
+ });
1604
+ await sendTask(rt, "接着聊");
1605
+ await waitFor(() => quotaNotices(ilink.state).length === 1);
1606
+ const t = quotaNotices(ilink.state)[0] || "";
1607
+ assert.ok(t, "前提:提醒确实发出来了");
1608
+ assert.match(t, /开新任务属于会员能力/, "缺的是开新任务,就只说这一项");
1609
+ assert.match(t, /接着当前任务回复仍然可用/, "★他确实还能接着聊 —— 不说不成立的话");
1610
+ } finally {
1611
+ await ilink.close();
1612
+ }
1613
+ });
1614
+
1615
+ // ---------------------------------------------------------------------------
1616
+ // 「/help 回一坨」的根治(2026-09-23 用户实测)
1617
+ //
1618
+ // 旧实现对付费档 `#helpText()` 直接 `return HELP_TEXT` —— 那是个**数组**,
1619
+ // 而发送前 `String(数组)` 会按逗号拼接、**一个换行都没有**:用户看到的正是
1620
+ // 「/help 回一坨,没有换行、也没有编号」。两处一起防:帮助改为逐行 join,
1621
+ // 且 reply() 收到数组时按行拼接(让"想给多行却给了数组"退化成正确的多行,而不是一坨)。
1622
+ // ---------------------------------------------------------------------------
1623
+
1624
+ test("★ reply 收到数组时必须按行拼接,绝不能退化成逗号串", async () => {
1625
+ const ilink = await fakeIlink();
1626
+ try {
1627
+ const { rt } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1628
+ await rt.reply("u", ["第一行", "· 第二行", "· 第三行"]);
1629
+ const t = lastText(ilink.state);
1630
+ assert.match(t, /第一行\n· 第二行\n· 第三行/, "★数组必须按 \\n 拼接");
1631
+ assert.ok(!/第一行,/.test(t), "★绝不能出现逗号拼接(String(数组) 的老行为 = 用户看到的一坨)");
1632
+ } finally {
1633
+ await ilink.close();
1634
+ }
1635
+ });
1636
+
1637
+ test("★ /help 必须逐行分隔:付费档也不许退化成一坨(无换行/无编号)", async () => {
1638
+ const ilink = await fakeIlink();
1639
+ try {
1640
+ const { rt } = await boundRuntime(ilink, {
1641
+ subscriber: makeFakeSubscriber(), tier: "pro",
1642
+ entitlements: {
1643
+ rev: "r-help", plan: "pro",
1644
+ caps: ["notify", "approve", "status", "stop", "assign", "sessions", "summary"],
1645
+ limits: { messages_per_month: 0 }
1646
+ }
1647
+ });
1648
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
1649
+ const t = lastText(ilink.state);
1650
+ const lines = t.split("\n");
1651
+ assert.ok(lines.length >= 6, `★帮助必须多行展示,实际只有 ${lines.length} 行:${t}`);
1652
+ assert.match(t, /现在能用的/, "应有一行小节标题");
1653
+ assert.ok(lines.some((l) => l.startsWith("· ")), "能力项应以「· 」开头(逐条,不是挤成一段)");
1654
+ assert.ok(!/现在能用的:,/.test(t) && !/,·/.test(t), "★不得是逗号拼接的一坨");
1655
+ // 免费档同样要多行(两条路都要能读)
1656
+ const freeIlink = await fakeIlink();
1657
+ try {
1658
+ const f = await boundRuntime(freeIlink, { subscriber: makeFakeSubscriber(), tier: "free" });
1659
+ await f.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
1660
+ const ft = lastText(freeIlink.state);
1661
+ assert.ok(ft.split("\n").length >= 6, `★免费档帮助也要多行,实际:${ft}`);
1662
+ } finally {
1663
+ await freeIlink.close();
1664
+ }
1665
+ } finally {
1666
+ await ilink.close();
1667
+ }
1668
+ });
@@ -40,7 +40,6 @@ import {
40
40
  extractInboundText,
41
41
  extractFromUserId,
42
42
  handleCommand,
43
- HELP_TEXT,
44
43
  classifyInbound,
45
44
  formatCompletion,
46
45
  isDestructiveTool,
@@ -934,21 +933,48 @@ export class WeChatRuntime {
934
933
  * 那等于把人骗到一个他做不到的清单上,然后每次尝试都吃一次拒绝。
935
934
  */
936
935
  #helpText() {
937
- if (this.#can("assign")) return HELP_TEXT; // 付费档:完整清单
936
+ // ★ 逐条**按实际能力**生成,不再用"档位名"或"有没有粗粒度 assign"来猜。
937
+ //
938
+ // 为什么必须改(2026-09-23,后台可配权限之后才暴露):管理员可以把 caps 配得很细 ——
939
+ // 只勾细粒度 `assign.new` 而不勾粗粒度根 `assign` 时,旧的 `#can("assign")` 返回**假**,
940
+ // 于是**付费用户**拿到一份写着"你没有这些功能"的免费档帮助;
941
+ // 反过来(勾了粗根又关掉某个子能力)则会把做不到的指令列成"你能用的"。
942
+ // 两个方向都违反业主红线「话术与权限必须一致、不多承诺」。
943
+ // 现在每一行都挂一个判据,能不能用由 `#can(...)` **逐条**决定。
944
+ const canNew = this.#can("assign.new");
945
+ const canContinue = this.#can("assign.continue");
946
+ const rows = [
947
+ { ok: this.#can("approve"), text: "· 回数字(如 1)回执最近一条需要你拍板的消息" },
948
+ { ok: this.#can("stop"), text: "· /stop 中断当前会话正在跑的回合" },
949
+ { ok: this.#can("status"), text: "· /status 查看绑定与推送状态" },
950
+ { ok: true, text: "· /quiet 暂停推送(回复任意消息恢复)" }, // 本地开关,不需要任何能力
951
+ { ok: true, text: "· /unbind 解除微信绑定" },
952
+ // 「接着当前任务聊」与「开新任务」是**两个独立能力**(业主 2026-09-22 拍板把 assign 拆成两半),
953
+ // 所以这里也拆成两行、各自判各自的:免费用户会看到第一行可用、第二行落在会员区。
954
+ // ⚠️ 行内**不要**再写"会员功能"三个字 —— 那是下面分区标题的专属词,
955
+ // 混进可用段会让"会员区之前不得出现 /new"这类结构断言失准(读起来也乱)。
956
+ { ok: canContinue, text: "· 直接发一句话 = 接着当前任务聊" },
957
+ { ok: canNew, text: "· /new <任务> 开新任务(直接发一句话也行)" },
958
+ { ok: this.#can("sessions"), text: "· /ls 列出会话、/use <编号> 切换会话" },
959
+ { ok: this.#can("summary"), text: "· /summary 重发最近结论" }
960
+ ];
961
+ const usable = rows.filter((r) => r.ok);
962
+ const locked = rows.filter((r) => !r.ok);
963
+ // 「通知版」这个标签说的是**档位画像**,不是"有缺项" —— 判据必须是
964
+ // "他有没有能在微信里**干活**的能力"(开新任务 / 管会话 / 要小结)。
965
+ // ⚠️ 曾经写成 `locked.length ? 通知版 : ...`:后台把 caps 配细之后,
966
+ // 一个能开新任务的付费用户只要缺了 summary 就会被标成"通知版" —— 那是错的画像。
967
+ const workCaps = ["assign.new", "sessions", "summary"];
968
+ const isNoticeOnly = !workCaps.some((c) => this.#can(c));
938
969
  const lines = [
939
- "【DSH 微信通道 · 通知版】",
970
+ `【DSH 微信通道${isNoticeOnly ? " · 通知版" : ""}】`,
940
971
  "现在能用的:",
941
- "· 回数字(如 1)回执最近一条需要你拍板的消息",
942
- "· /stop 中断当前会话正在跑的回合",
943
- "· /status 查看绑定与推送状态",
944
- "· /quiet 暂停推送(回复任意消息恢复)",
945
- "· /unbind 解除微信绑定",
946
- "",
947
- "会员功能(开通后可用):",
948
- "· 直接发一句话 = 给当前任务派活",
949
- "· /new 开新任务、/ls 列出会话、/use <编号> 切换会话、/summary 重发最近结论"
972
+ ...usable.map((r) => r.text)
950
973
  ];
951
- if (this.appUrl) lines.push("", `👉 开通并查看设备列表:${this.appUrl}`);
974
+ if (locked.length) {
975
+ lines.push("", "会员功能(开通后可用):", ...locked.map((r) => r.text));
976
+ if (this.appUrl) lines.push("", `👉 开通并查看设备列表:${this.appUrl}`);
977
+ }
952
978
  return lines.join("\n");
953
979
  }
954
980
 
@@ -1033,9 +1059,21 @@ export class WeChatRuntime {
1033
1059
  await this.reply(from, this.#helpText());
1034
1060
  return;
1035
1061
  }
1036
- // 其余交给上游的通用处理 —— ⚠️ 字段是 `replyText`,不是 text。
1062
+ // 未知指令:**绝不静默**,而且必须用**按档位分层**的帮助。
1063
+ //
1064
+ // ⚠️ 这里以前直接透传上游 `handleCommand()` 的 replyText,而它拼的是**静态** HELP_TEXT ——
1065
+ // 那份清单把 `/new`、`/ls`、`/use`、`/summary` 与"直接发一句话"一并列成"你能用的"。
1066
+ // 免费用户敲错一个字,拿到的就是这张会员清单:照着做 → 再吃一次拒绝,
1067
+ // 于是"打错命令"被理解成"这功能坏了"。业主红线是「话术与权限必须一致、不多承诺」,
1068
+ // 而 `#helpText()` 存在的唯一理由就是按档位如实分层(见它的注释)。
1069
+ // 另外把用户敲的那条**原样回显** —— 他才知道是哪个字打错了。
1070
+ if (cmd) {
1071
+ await this.reply(from, `不认识这条指令:${cmd}\n\n${this.#helpText()}`);
1072
+ return;
1073
+ }
1074
+ // 兜底:理论上到不了(入站非空文本必带 command),但绝不静默吞掉
1037
1075
  const r = handleCommand(cmd, args);
1038
- await this.reply(from, (r && r.replyText) || "可用指令:/new /ls /use /stop /status /summary /help /quiet /unbind");
1076
+ await this.reply(from, (r && r.replyText) || "可用指令:/help");
1039
1077
  }
1040
1078
 
1041
1079
  // ── v2:会话遥控 ────────────────────────────────────────────────────────
@@ -1306,8 +1344,14 @@ export class WeChatRuntime {
1306
1344
 
1307
1345
  async reply(to, text) {
1308
1346
  if (!this.channel.account || !to) return false;
1347
+ // ★ 文本**必须是字符串**。历史上 `#helpText()` 对付费档直接 `return HELP_TEXT` ——
1348
+ // 那是个**数组**,而 `String(数组)` 会按逗号拼接、**一个换行都没有**:
1349
+ // 用户实测到的正是「/help 回一坨,没有换行也没有编号」。数组一律按行拼接,
1350
+ // 让"想给多行却给了数组"退化成**正确**的多行文本,而不是一坨。
1351
+ const body = Array.isArray(text) ? text.join("\n") : String(text == null ? "" : text);
1352
+ if (!body) return false;
1309
1353
  try {
1310
- await this.channel.client.sendMessage({ to, text });
1354
+ await this.channel.client.sendMessage({ to, text: body });
1311
1355
  this.channel.markPush(true);
1312
1356
  return true;
1313
1357
  } catch (e) {
@@ -1433,7 +1477,9 @@ export class WeChatRuntime {
1433
1477
  summary,
1434
1478
  sessionId: sid,
1435
1479
  hanging: !summary
1436
- }, this.#can("assign") ? {} : { continuationHint: this.#continuationHint() });
1480
+ // 判据是 **assign.new**(能不能开新任务),不是粗粒度 assign:
1481
+ // 后台只勾细粒度 assign.new 时,粗根判假 → 会给一个**能派活**的用户塞"接着交代属于会员功能"
1482
+ }, this.#can("assign.new") ? {} : { continuationHint: this.#continuationHint() });
1437
1483
  built = { text: c.text, replyable: false, eventId: "" };
1438
1484
  } else if (
1439
1485
  formatterKind === "approval" &&
@@ -1610,8 +1656,13 @@ export class WeChatRuntime {
1610
1656
  `本月 ${limit} 条消息额度已用 ${used} 条,还剩 ${remain} 条。`,
1611
1657
  `额度按自然月计算,下个月 1 号自动归零(重新给满 ${limit} 条)。`
1612
1658
  ];
1613
- if (!this.#can("assign")) {
1614
- lines.push("", "在微信里直接派活属于会员能力;开通后可继续使用(你能用的能力以 App 内展示为准)。");
1659
+ if (!this.#can("assign.new")) {
1660
+ // 按**实际缺的那一项**说,不笼统说"派活是会员能力":
1661
+ // 还能接着当前会话聊的人被这么说,会以为自己在微信里什么都做不了 ——
1662
+ // 少承诺同样是一种"话术与权限不一致"。
1663
+ lines.push("", this.#can("assign.continue")
1664
+ ? "在微信里开新任务属于会员能力;接着当前任务回复仍然可用(你能用的能力以 App 内展示为准)。"
1665
+ : "在微信里直接派活属于会员能力;开通后可继续使用(你能用的能力以 App 内展示为准)。");
1615
1666
  }
1616
1667
  if (this.appUrl) lines.push("", `👉 打开 App:${this.appUrl}`);
1617
1668
  return lines.join("\n");
package/dsh-setup.mjs CHANGED
@@ -420,6 +420,34 @@ function looksLikeWatcher(pid) {
420
420
  return Boolean(r.ok && /dsh-setup\.mjs/.test(r.stdout));
421
421
  }
422
422
 
423
+ /**
424
+ * 这个 pid 是不是我们的 **bridge 子进程**(不是 watcher、也不是别的 node)。
425
+ *
426
+ * 为什么要单独判:接管时我们要回收"上一任遗留的 bridge",而 pid 可能已被系统回收给无关进程 ——
427
+ * 对无关进程发 SIGKILL 是不可接受的。所以对 `ps` 出来的命令行做**双重**校验:
428
+ * 必须同时出现 bridge 脚本名与 clients/dsh-remote 路径片段。
429
+ */
430
+ function looksLikeBridge(pid) {
431
+ if (!Number.isInteger(pid) || pid <= 0) return false;
432
+ const r = sh(`ps -p ${pid} -o command=`);
433
+ if (!r.ok) return false;
434
+ const cmd = String(r.stdout || "");
435
+ return /dsh-bridge\.mjs/.test(cmd) && /clients[\\/]dsh-remote/.test(cmd);
436
+ }
437
+
438
+ /**
439
+ * 该不该回收这个"上一任留下的 bridge 子进程"(纯函数 —— 便于直接测)。
440
+ *
441
+ * 只有**确认它就是我们的 bridge 脚本**才敢动它;是自己、已死、非整数一律不动。
442
+ * 这条判断代价很高(误杀无关进程 / 漏杀导致两个实例抢同一微信账号的消息),所以抽出来单测。
443
+ */
444
+ function shouldReapLeftoverBridge({ pid, selfPid = 0, alive = false, isBridgeScript = false } = {}) {
445
+ if (!Number.isInteger(pid) || pid <= 0) return false;
446
+ if (pid === selfPid) return false;
447
+ if (!alive) return false;
448
+ return Boolean(isBridgeScript);
449
+ }
450
+
423
451
  /**
424
452
  * 去重决策(纯函数 —— 这条判断的代价很高,必须能被直接测)。
425
453
  *
@@ -1020,6 +1048,37 @@ async function runBridge() {
1020
1048
  return;
1021
1049
  }
1022
1050
  }
1051
+ // ── 回收「上一任留下的 bridge 子进程」────────────────────────────────────
1052
+ //
1053
+ // 🔴 真机 bug(2026-09-23 用户实测):一条微信指令**回两条**。
1054
+ // 成因链:接管时把老 watcher SIGTERM(它只"请"子进程退出、最多等 3 秒就自己 exit),
1055
+ // 而 bridge 此刻常正卡在 35 秒的 getUpdates 长轮询里 —— 来不及退 → 变成孤儿继续跑;
1056
+ // 若老 watcher 是被 SIGKILL 干掉的(等超 5 秒那条路),连"请"都不会发生。
1057
+ // 两个 bridge 各自持有自己的 updatesBuf、各自轮询同一个微信账号 ⇒ 同一条消息被消费两次、
1058
+ // 回两条。所以**接管之后必须显式清掉上一任的子进程**,不能只靠"它应该会自己退"。
1059
+ //
1060
+ // 安全边界:pid 可能已被系统回收给无关进程 —— 必须确认它**真的**是我们的 bridge 脚本才动手
1061
+ //(与 looksLikeWatcher 同一套纪律)。插件半的「脱离进程兜底」也会拉起 bridge,
1062
+ // 但那种情况**没有** watcher 在跑(有 watcher 就不会走兜底),所以这里回收不会误伤正常形态。
1063
+ {
1064
+ const leftover = readPidFile(BRIDGE_PID_FILE);
1065
+ const reap = shouldReapLeftoverBridge({
1066
+ pid: leftover,
1067
+ selfPid: process.pid,
1068
+ alive: leftover ? pidAlive(leftover) : false,
1069
+ isBridgeScript: leftover ? looksLikeBridge(leftover) : false
1070
+ });
1071
+ if (reap) {
1072
+ console.log(`[dsh-remote] 回收上一任遗留的 bridge 子进程(pid=${leftover})——避免两个实例抢同一账号的消息。`);
1073
+ try { process.kill(leftover, "SIGTERM"); } catch { /* 已退出 */ }
1074
+ const dl = Date.now() + 4000;
1075
+ while (Date.now() < dl && pidAlive(leftover)) sleepSync(100);
1076
+ if (pidAlive(leftover) && looksLikeBridge(leftover)) {
1077
+ try { process.kill(leftover, "SIGKILL"); } catch { /* 已退出 */ }
1078
+ }
1079
+ removePidFile(BRIDGE_PID_FILE);
1080
+ }
1081
+ }
1023
1082
  }
1024
1083
  let cfg = loadConfig();
1025
1084
  let warnedNoLogin = false;
@@ -1142,7 +1201,21 @@ async function runBridge() {
1142
1201
  try { bridgeProc.send({ type: "shutdown" }); } catch { /* 通道已断,只能随父进程一起被回收 */ }
1143
1202
  }
1144
1203
  cleanup();
1145
- setTimeout(() => process.exit(0), 3000);
1204
+ // 🔴 但**只"请"是不够的**(2026-09-23 真机 bug:一条微信指令回两条):
1205
+ // bridge 此刻常正卡在 35s 的 getUpdates 长轮询里,3 秒根本退不完;
1206
+ // 老 watcher 一 exit,子进程就被 reparent 成孤儿、继续轮询同一个微信账号 →
1207
+ // 与新 bridge 抢消息(一条被消费两次、回两条)。所以退出前必须**确保它真的死了**。
1208
+ setTimeout(() => {
1209
+ if (bridgeProc && bridgeProc.exitCode === null) {
1210
+ try { bridgeProc.kill("SIGTERM"); } catch { /* 已退出 */ }
1211
+ }
1212
+ }, 1500);
1213
+ setTimeout(() => {
1214
+ if (bridgeProc && bridgeProc.exitCode === null) {
1215
+ try { bridgeProc.kill("SIGKILL"); } catch { /* 已退出 */ }
1216
+ }
1217
+ process.exit(0);
1218
+ }, 3000);
1146
1219
  });
1147
1220
  } catch { /* 该信号在本平台不可注册 */ }
1148
1221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.10-beta.8",
3
+ "version": "0.6.10",
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",
@@ -2480,8 +2480,27 @@ const PLUGIN_ID = "dsh-remote-web";
2480
2480
  /** 更名前 id(≤0.4.9):彻底卸载/清理时一并移除,防旧拷贝残留。 */
2481
2481
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
2482
2482
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
2483
- /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
2484
- const PLUGIN_VERSION = "0.6.10-beta.7";
2483
+ /**
2484
+ * 插件自身发布版本 —— **从 package.json 派生,不再手写**(与 dsh-remote 根包同步递增)。
2485
+ *
2486
+ * 为什么改成派生(2026-09-23):原先这里是硬编码字符串,每次发版要**手改两处**
2487
+ * (package.json + 这里),beta.8 就漏改了 —— 装到用户机器上的插件在**装机上报**里
2488
+ * 仍然自称 beta.7。而 `x-dsh-client` 头、更新通道判定(含 `-` 即 beta)、面板「插件版本」、
2489
+ * 更新检测拿的都是这个值:一处漏改会同时污染装机来源统计与更新判断,
2490
+ * 而且**发出去之后改不回来**(npm 同一版本不可覆盖)。
2491
+ *
2492
+ * 测试本来就会红(它拿 package.json 与上报值对账 —— 这次正是它抓住的),
2493
+ * 但让两份数据**只有一个来源**才是根治。
2494
+ * 读不到时退回 `0.0.0-unknown`:让异常**显式可见**,绝不悄悄冒充某个真实版本。
2495
+ */
2496
+ const PLUGIN_VERSION = (() => {
2497
+ try {
2498
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
2499
+ return String(pkg && pkg.version ? pkg.version : "") || "0.0.0-unknown";
2500
+ } catch {
2501
+ return "0.0.0-unknown";
2502
+ }
2503
+ })();
2485
2504
  const UPDATE_LOG = ".dsh-update.log";
2486
2505
  const UPDATE_MARKER = ".dsh-update-running";
2487
2506
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.10-beta.8",
3
+ "version": "0.6.10",
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",
@@ -135,3 +135,46 @@ test("★ 被监管者(launchd)拉起的实例不得因『已有游离守护』
135
135
  assert.match(rb, /looksLikeWatcher\(other\)/, "★接管前必须确认那个 pid 真的是 watcher(pid 会被回收给无关进程)");
136
136
  assert.match(rb, /dedupDecision\(/, "决策必须走 dedupDecision(保证被判为活代码且可测)");
137
137
  });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // 「一条微信指令回两条」的根治(2026-09-23 用户实测)
141
+ //
142
+ // 成因链:接管时把老 watcher SIGTERM,它只**请**子进程退出、最多等 3 秒就自己 exit;
143
+ // 而 bridge 此刻常正卡在 35 秒的 getUpdates 长轮询里 —— 来不及退 → reparent 成孤儿继续跑。
144
+ // 两个 bridge 各持自己的 updatesBuf、各轮询同一账号 ⇒ **同一条消息被消费两次、用户收到两条**。
145
+ // 真机现场:`ps` 里两个 `dsh-bridge.mjs`,一个 PPID=1(孤儿)、一个挂在 watcher 下。
146
+ // ---------------------------------------------------------------------------
147
+
148
+ test("★ 遗留 bridge 的回收判定:只认「活着 + 确实是我们的 bridge 脚本 + 不是自己」", () => {
149
+ const from = src.indexOf("function shouldReapLeftoverBridge(");
150
+ assert.ok(from > -1, "应能找到 shouldReapLeftoverBridge");
151
+ const body = src.slice(from, src.indexOf("function writeWindowsTask("));
152
+ assert.ok(body.length > 0, "切片边界应正确");
153
+ const reap = new Function(`${body}\nreturn shouldReapLeftoverBridge;`)();
154
+
155
+ assert.equal(reap({ pid: 1538, selfPid: 1529, alive: true, isBridgeScript: true }), true,
156
+ "★活着且确认是我们的 bridge → 必须回收(否则它会和新实例抢同一账号的消息)");
157
+ assert.equal(reap({ pid: 1538, selfPid: 1529, alive: false, isBridgeScript: true }), false, "已经死了不用管");
158
+ assert.equal(reap({ pid: 1529, selfPid: 1529, alive: true, isBridgeScript: true }), false, "是自己不能杀");
159
+ assert.equal(reap({ pid: 999, selfPid: 1529, alive: true, isBridgeScript: false }), false,
160
+ "★pid 可能被系统回收给无关进程 —— 认不出是我们的 bridge 就绝不动它");
161
+ assert.equal(reap({ pid: 0, selfPid: 1529, alive: true, isBridgeScript: true }), false, "非法 pid");
162
+ assert.equal(reap({ pid: -1, selfPid: 1529, alive: true, isBridgeScript: true }), false, "非法 pid");
163
+ assert.equal(reap({}), false, "缺参一律不动");
164
+
165
+ const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
166
+ assert.match(rb, /shouldReapLeftoverBridge\(/, "★接管后必须真的调用回收判定(保证它是活代码)");
167
+ assert.match(rb, /looksLikeBridge\(leftover\)/, "★动手前必须确认那个 pid 真的是 bridge 脚本");
168
+ assert.match(rb, /readPidFile\(BRIDGE_PID_FILE\)/, "回收目标取自 bridge 的 pid 文件");
169
+ });
170
+
171
+ test("★ watcher 退出前必须确保子进程真的死了(只『请』不够)", () => {
172
+ const rb = src.slice(src.indexOf("async function runBridge()"), src.indexOf("// ---------- setup"));
173
+ assert.match(rb, /bridgeProc\.send\(\{ type: "shutdown" \}\)/, "先请它优雅退出(跑完 notifystop)");
174
+ // 关键:退出前兜底 SIGTERM/SIGKILL —— 否则长轮询中的子进程会变成孤儿继续抢消息
175
+ const tail = rb.slice(rb.indexOf('for (const sig of ["SIGINT"'));
176
+ assert.match(tail, /bridgeProc\.kill\("SIGTERM"\)/, "★宽限期后必须补 SIGTERM(bridge 常卡在 35s 长轮询里)");
177
+ assert.match(tail, /bridgeProc\.kill\("SIGKILL"\)/, "★再宽限仍不退必须 SIGKILL(否则退出后留孤儿)");
178
+ assert.match(tail, /bridgeProc\.exitCode === null/, "判活要用 exitCode(已退出就别再杀)");
179
+ assert.match(src, /function looksLikeBridge\(pid\)/, "应有 looksLikeBridge 供回收前校验命令行");
180
+ });