dsh-zen-remote 1.1.16 → 2.0.0

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 (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. package/src/virtual-id.ts +59 -0
@@ -1,6 +1,6 @@
1
1
  # 公网接入(通道半边详解)
2
2
 
3
- > `dsh-zen-remote` 的通道半边完整文档:反代对接、配对流程、环境变量、管理 API、推送、安全边界。
3
+ > `dsh-zen-remote` 的通道半边完整文档:反代对接、设备角色与配对流程、环境变量、管理 API、远程中继、推送、安全边界。
4
4
  > 安装与快速上手见[根 README](../README.zh-CN.md);界面侧细节见 [interface.md](interface.md)。
5
5
  > 网关基座衍生自 MIT 的 [`dsh-mobile-gate`](https://github.com/Bernardxu123/dsh-mobile-gate)。
6
6
 
@@ -10,16 +10,17 @@
10
10
 
11
11
  | 模块 | 说明 |
12
12
  | --- | --- |
13
- | 🔑 **公网身份** | 网关只监听 `127.0.0.1`,放在你自己的反代后面;新设备用配对码换取长期设备令牌(Cookie `lg_device`),身份跟着令牌走,与来源 IP 完全无关 |
13
+ | 🔑 **公网身份** | 网关只监听 `127.0.0.1`,放在你自己的反代后面;新设备用配对码换取长期设备令牌(Web 应用端存 Cookie `lg_device`,桌面应用端拿 JSON 令牌),身份跟着令牌走,与来源 IP 完全无关 |
14
+ | 👥 **设备角色** | 配对码生成时绑定角色:**Web 应用端**(手机/平板/浏览器,经网关访问全部会话)与**桌面应用端**(另一台 DSH 桌面端、zen-remote 子客户端角色,只能访问中继前缀、只见已共享会话)。拿错通道用码会被明确拒绝且不消耗码 |
15
+ | 📡 **远程中继** | 桌面应用端与主服务端之间走 zen-remote 自己的中继协议(HTTP 单次调用 + NDJSON 流式订阅),服务端按「方法允许清单」逐请求复核会话归属,未共享会话的数据一步都出不来 |
14
16
  | 📱 **真 PWA** | `manifest.json` + service worker:反代带来 HTTPS 后,手机浏览器「添加到主屏」真正生效,全屏独立窗口运行,带图标/启动屏/主题色 |
15
- | 🧩 **可安装** | 主屏图标、`standalone` 显示、`apple-touch-icon`、maskable 图标 |
16
17
  | 🌐 **离线可用** | service worker(v3):只有真正的静态壳(manifest/图标/离线页)缓存优先,其余(DSH 客户端 JS/CSS、API、页面 HTML)一律网络优先——装了新版本手机上立刻吃到,不会像早期版本那样在部署之后还残留旧 CSS |
17
18
  | 👆 **触屏手势** | 捏合缩放字体(可重置);左缘返回手势已让位给界面半边(见下方「分工」),下拉刷新已整体移除(误触发全页重载会把人从对话中间弹回列表) |
18
19
  | 🔔 **卡住了就推送** | 真 Web Push(VAPID 签名 + aes128gcm 加密)。默认只在**真正需要你**的时候推:某个工具在等授权、模型在等你回答问题。「回合结束」默认不推(设 `DSH_PUSH_TURN_END=1` 打开)。通知里默认不带对话内容 |
19
- | 🛎️ **`push_notify` 工具** | 模型可主动调用的推送工具(在 `dsh-push.mjs` 里注册):任务中途要用户拿主意、跑到关键节点、或出错需要人来处理时,模型自己决定推一条到锁屏。纪律写在工具描述里明确要求模型别高频用;宿主侧再兜底限流(同会话 60 秒最多 1 条、全局每小时最多 20 条),超额直接不发送、不报错。同样是 aes128gcm 端到端加密,推送服务器只见密文,暴露面只有你自己的锁屏。`lan-gate.config.json` 里 `pushTool: false`(或 `DSH_PUSH_TOOL=0`)可整体关掉;宿主没装工具注册服务(`ctx.tools`)时自动跳过,不影响插件其余功能 |
20
+ | 🛎️ **`push_notify` 工具** | 模型可主动调用的推送工具(由推送半边注册):任务中途要用户拿主意、跑到关键节点、或出错需要人来处理时,模型自己决定推一条到锁屏。纪律写在工具描述里明确要求模型别高频用;宿主侧再兜底限流(同会话 60 秒最多 1 条、全局每小时最多 20 条),超额直接不发送、不报错。同样是 aes128gcm 端到端加密,推送服务器只见密文,暴露面只有你自己的锁屏。`pushTool` 关掉(或 `DSH_PUSH_TOOL=0`)可整体关闭;宿主没装工具注册服务(`ctx.tools`)时自动跳过,不影响插件其余功能 |
20
21
  | 📐 **触屏布局** | 本仓库只留壳级规则(iOS 输入框防缩放、安全区滚动补偿、代码横向滚动)——排版类规则(44px 触摸目标、弹窗、composer 外观等)已交给界面半边,见下方「分工」——桌面零影响 |
21
22
  | 🔒 **桌面不受影响** | 所有规则都以 `html:not([data-lan-device="desktop"])`(或带同样排除条件的 `@media`)为根——只有显式标成「桌面」才会被排除,其余(包括真机默认的「自动」)都生效 |
22
- | 🛡️ **管理面本机独占** | 生成配对码、管理设备、触发推送——这些接口只认本机直连,经反代进来的请求一律 403 |
23
+ | 🛡️ **管理面本机独占** | 生成配对码、管理设备、触发推送——这些接口只认本机直连,经反代进来的请求一律 403。2.0 起管理操作全部从 DSH「插件」页的设置区块发起(插件后台代为调用),`/lan-gate/admin` 页面只剩一段指向设置页的说明 |
23
24
 
24
25
  ---
25
26
 
@@ -31,17 +32,17 @@
31
32
  ▼
32
33
  网关(独立 Node 子进程 · 默认只监听 127.0.0.1:3088)
33
34
  │
34
- ┌──────────────────────────────┼────────────────────────────────┐
35
- │ │ │
36
- 未配对设备 已配对设备(带 lg_device 令牌 Cookie) 本机直连(无 X-Forwarded-* 头)
37
- → 任意路径都跳配对页 → 反代到 DSH Web UI(127.0.0.1:3080) → 管理页/管理 API/推送触发
38
- 提交配对码换令牌 HTML 注入:manifest + PWA 引导 + /lan-gate/admin /status
39
- 触屏 CSS + randomUUID polyfill /action /pair /pwa/push/send
35
+ ┌──────────────────┬───────────┴──────────────┬─────────────────────────┐
36
+ │ │ │ │
37
+ 未配对设备 Web 应用端(已配对) 桌面应用端(已配对) 本机直连(无 X-Forwarded-* 头)
38
+ → 任意路径都跳配对页 → 反代到 DSH(自动发现的 → 只放行中继前缀 → 管理页(只剩说明)/管理 API
39
+ 提交配对码换令牌 实际端口),带设备标记头 /_dsh/zen-remote/relay/* /lan-gate/status /action /pair
40
+ + Bearer 共享密钥,其余路径 403 (含共享密钥与设备标记头) /pwa/push/send
40
41
  ```
41
42
 
42
- - 网关是独立子进程,与 DSH 主进程隔离:挂掉不影响主服务,插件停止时自动终止。
43
- - DSH 主服务本身仍然只监听 `127.0.0.1`,网关不改它的任何配置,也不碰它 `/api` 的信任栅栏。
44
- - 唯一保留的「按 IP 信任」:回环 socket 且不带任何 `X-Forwarded-*` 头的请求,判定为坐在这台机器前面的本机用户——这是管理面的唯一入口。经反代进来的请求一定带转发头,天然进不去。
43
+ - 网关是独立子进程,与 DSH 主进程隔离:挂掉不影响主服务,插件停止时自动终止;主进程若死于非正常退出(崩溃、`kill -9`),子进程每 5 秒自检一次父进程存活,发现不在就自己退出,不占着端口。
44
+ - DSH 主服务本身仍然只监听 `127.0.0.1`,网关不改它的任何配置,也不碰它 `/api` 的信任栅栏。转发目标端口自动发现(桌面端构建的 Web UI 端口可配置,网关读宿主的实际值)。
45
+ - 唯一保留的「按 IP 信任」:回环 socket 且不带任何 `X-Forwarded-*` 头、且 **Host 去掉端口后是 `127.0.0.1` / `localhost` / `[::1]` 之一**的请求,判定为坐在这台机器前面的本机用户——这是管理面的唯一入口。经反代进来的请求一定带转发头,天然进不去。Host 检查挡的是 DNS 重绑定:攻击者自己的域名解析到 127.0.0.1 时,请求虽然从回环进来,Host 却不是回环名,进不了管理面。**注意这也是已知限制的根源**:同机联调时子客户端必须用局域网 IP 连网关,回环地址会被当成「本机直连」而不校验令牌。
45
46
 
46
47
  ---
47
48
 
@@ -72,11 +73,13 @@
72
73
 
73
74
  ### 1. 安装插件
74
75
 
76
+ 桌面端 App 与 Web 端都可以在左侧「插件」页 →「添加插件」→ 输入 `dsh-zen-remote` → 点「立即启用」;无头机器上的 Web 服务也可以用命令行:
77
+
75
78
  ```bash
76
- dsh plugin add dsh-zen-remote
79
+ dsh plugin --profile web add dsh-zen-remote
77
80
  ```
78
81
 
79
- 装完重启 `dsh web`。手动写法与本地开发装法见[根 README](../README.zh-CN.md#安装);不走 `dsh plugin add` 的静态挂载见 [`cordis.patch.yml.example`](../cordis.patch.yml.example)。
82
+ 装完重启桌面端 App 或 `dsh web`。命令行不接受 `--profile desktop`(桌面端 profile 由 App 独占管理);手动写法与本地开发装法见[根 README](../README.zh-CN.md#安装);不走插件管理的静态挂载见 [`cordis.patch.yml.example`](../cordis.patch.yml.example)。
80
83
 
81
84
  ### 2. 配你自己的反代
82
85
 
@@ -231,14 +234,17 @@ sudo cloudflared service install # 通了再装成常驻服务
231
234
 
232
235
  ### 3. 生成配对码、配对设备
233
236
 
234
- 1. 反代配好后,在**这台机器本机**的浏览器打开 `http://127.0.0.1:3088/lan-gate/admin`。
235
- 2. 点「生成配对码」,得到一个 8 位码,10 分钟内有效,只能用一次。
236
- 3. 手机或另一台电脑,浏览器打开你反代的 HTTPS 域名,会看到配对页,输入配对码(可选填设备名)。
237
- 4. 配对成功后自动进入 DSH Web UI(已注入 PWA),身份保存在长期 Cookie 里,换 Wi-Fi/换 IP 都不会掉线。
238
- 5. 手机上通过浏览器菜单「添加到主屏幕」,就能像原生 App 一样独立打开。
239
- 6. 页面会提示开启「任务完成推送」,同意通知权限后,agent 干完活即使切到别的 App 也能收到系统通知。
237
+ 2.0 起配对码从 DSH「插件」页 → dsh-zen-remote 设置区块生成(旧地址 `/lan-gate/admin` 只剩一段指向设置页的说明,仍只认本机直连):
238
+
239
+ 1. 反代配好后,在**这台机器本机**打开 DSH「插件」页,展开 dsh-zen-remote 的设置区块。
240
+ 2. 选设备角色(Web 应用端 / 桌面应用端),点生成,得到一个 8 位码,10 分钟内有效,只能用一次。**码与角色绑定**:Web 应用端的码拿到桌面端去用会被拒绝,反之亦然;拿错的码不被消耗、也不计入错码锁定。
241
+ 3. Web 应用端:手机或另一台电脑的浏览器打开你反代的 HTTPS 域名,在配对页输入配对码(可选填设备名)。
242
+ 4. 桌面应用端:在另一台 DSH 桌面端的插件设置区块里把角色切成「子客户端」,填服务端地址与配对码。地址必须 https,只有内网/回环/Tailscale 段(`192.168.x`、`10.x`、`172.16–31.x`、`100.64.x`、`127.x`、`localhost`/`*.local`、IPv6 `::1`/`fc00::/7`/`fe80::/10`)才允许 http。
243
+ 5. 配对成功后自动进入 DSH Web UI(已注入 PWA),身份保存在长期 Cookie 里,换 Wi-Fi/换 IP 都不会掉线。
244
+ 6. 手机上通过浏览器菜单「添加到主屏幕」,就能像原生 App 一样独立打开。
245
+ 7. 页面会提示开启「任务完成推送」,同意通知权限后,agent 干完活即使切到别的 App 也能收到系统通知。
240
246
 
241
- 在管理页还可以:把设备类型设为手机/电脑/自动排版、给设备改名、单独吊销某台设备或一键全部吊销。
247
+ 在设置区块还可以:改设备名、改设备角色(`set-role`)、单独/全部吊销设备。吊销立即生效——那台设备当前的连接与推送订阅一并销毁。桌面应用端够不到任何管理接口:网关只把它的请求转进中继前缀,其余路径(包括 DSH 页面和 `/api`)一律 403(`reason: 'relay-only'`)。
242
248
 
243
249
  ---
244
250
 
@@ -253,9 +259,10 @@ sudo cloudflared service install # 通了再装成常驻服务
253
259
  | `LAN_GATE_TRUSTED_PROXIES` | 空 | 逗号分隔的 IP 列表。反代和网关不在同一台机器(回环地址)时,把反代的出口 IP 填进来,网关才会信任它带来的 `X-Forwarded-For`/`X-Forwarded-Proto` |
254
260
  | `LAN_GATE_VAPID_SUBJECT` | `mailto:admin@localhost` | Web Push 的 VAPID 联系人字段。**务必改成真实邮箱或 https 网址**:Apple 会用 `403 BadJwtToken` 拒绝占位符,导致 iOS 设备静默收不到推送(Google/Mozilla 不校验)。填错时启动日志有告警 |
255
261
  | `LAN_GATE_LANG` | `auto` | 网关自己那几个页面(配对页、限流页、管理页)和注入到应用里的推送开关卡片用什么语言。`auto` 按请求的 `Accept-Language` 决定——配对访客唯一会主动交出的语言信号;没有这个头就用中文。`zh`/`en` 写死,不再看浏览器 |
256
- | `DSH_PUSH_LANG` | `zh` | 推送通知本身的文案语言(等授权 / 等回答 / 回合完成)。这里**不做自动探测**:通知是宿主进程生成的,既没有请求头,`launchd` 也不给它 `LANG`(中文用户的机器上 `Intl` 照样报 `en-US`)。要英文就显式设 `en` |
262
+ | `LAN_GATE_RELAY_SECRET` | (自动生成) | 中继共享密钥,2.0 起桌面应用端的请求靠它向 DSH 的中继路由自证「标记头是网关写的」。**由插件在每次加载主入口时现生成**(32 字节随机数经环境变量交给网关子进程,无条件覆盖),**不可由外部指定**——宿主环境里手工设置的值会在子进程启动时被覆盖或删除;插件没带(client 角色、旧式独立加载)时密钥功能关闭,网关对空密钥不加头 |
263
+ | `DSH_PUSH_LANG` | `zh` | 推送通知本身的文案语言(等授权 / 等回答 / 回合完成)。2.0 起它只是对设置里 `lang` 的环境变量覆盖;都不设时中文。这里**不做自动探测**:通知是宿主进程生成的,既没有请求头,`launchd` 也不给它 `LANG`(中文用户的机器上 `Intl` 照样报 `en-US`)。要英文就显式设 `en` |
257
264
 
258
- 除了环境变量,**推荐用配置文件** `~/.dsh/lan-gate.config.json`(网关和推送插件两半共用一份,改完重启 `dsh web` 生效;显式环境变量优先于文件):
265
+ 除了环境变量,**推荐在 DSH「插件」页的设置区块里改配置**(全部字段的字段表见根 README);`~/.dsh/lan-gate.config.json` 作为旧配置仍然生效。每个字段的优先级是 **环境变量 > 插件行设置 > `lan-gate.config.json` > 默认值**,某层的值不合法时该层视为未设置(手写坏 `port: "abc"` 会露出文件层的端口,而不是报错)。文件键名 = 环境变量去掉前缀转小驼峰:
259
266
 
260
267
  ```json
261
268
  {
@@ -266,26 +273,72 @@ sudo cloudflared service install # 通了再装成常驻服务
266
273
  }
267
274
  ```
268
275
 
269
- 字段名 = 环境变量去掉前缀转小驼峰:`port` / `host` / `targetPort` / `rateLimit` / `trustedProxies` / `vapidSubject`,推送半边是 `pushTurnEnd`(回合结束是否推送,默认 `false`)/ `pushEvents` / `pushDebounceMs` / `pushSummary` / `pushTool`(`push_notify` 工具开关,默认 `true`)。语言只有一个键 `lang`,两边共用:网关认 `auto`(跟随浏览器)/`zh`/`en`,推送半边只认 `en`,其余一律中文——所以 `"lang": "auto"` 的意思是「页面跟随浏览器、通知保持中文」。挂载行支持 Cordis config 的 DSH 版本也可以把网关配置写在 insert 行的 `config:` 下(同名小驼峰字段),效果等同。
276
+ 推送半边另有 `pushTurnEnd` / `pushEvents` / `pushDebounceMs` / `pushSummary` / `pushTool`,语言只有一个键 `lang`,两边共用:网关认 `auto`(跟随浏览器)/`zh`/`en`,推送半边只认 `en`,其余一律中文——所以 `"lang": "auto"` 的意思是「页面跟随浏览器、通知保持中文」。还有两个键不在设置面板里:`pushApprovalGraceMs`(等授权推送的等待窗口,也可用环境变量 `DSH_PUSH_APPROVAL_GRACE_MS`)。`role`、`serverName`、`idleHours`、`autoShareNewSessions`、`serverUrl`、`deviceToken` 是 2.0 新字段,走设置页或插件行,不在旧文件里读——`role` 例外,文件层也认(`host`/`client`)。
277
+
278
+ 改完需要重启网关/推送的字段(`role`、`port`、`host`、`targetPort`、`rateLimit`、`trustedProxies`、`vapidSubject`、`lang` 与推送各字段),插件自己检测指纹变化并重载插件行,网关子进程随之重启——不用手动重启 App。
270
279
 
271
- 推送半边(`dsh-push.mjs`)随包自带,装包即挂载,**不需要在 profile 的
272
- `cordis.patch.yml` 里手写任何一行**。从旧的两包结构升级上来的话,profile patch
273
- 里那行手挂的 `网关半边/dsh-push.mjs` 要删掉,否则会重复挂载。
280
+ 2.0 起网关与推送**不再各占一行**:包自带的 `cordis.patch.yml` 只挂 `dsh-zen-remote` 一行,主入口按 `role` 用 `ctx.plugin()` 加载这两个子插件,并把合并后的生效配置传给它们。profile 补丁里残留的旧 `dsh-zen-remote-gateway` / `dsh-zen-remote-push` 行会被 loader 警告并跳过,删掉即可——照老例子再手挂一行网关会拉起第二个网关进程抢端口。
274
281
 
275
282
  ---
276
283
 
277
284
  ## 🔌 管理 API
278
285
 
279
- 以下接口全部**仅限本机直连**:请求的 socket 必须是回环地址、且不带任何 `X-Forwarded-*` 头。只要请求经过反代(一定带转发头),一律返回 403——公网碰不到这些接口。
286
+ 以下接口全部**仅限本机直连**:请求的 socket 必须是回环地址、且不带任何 `X-Forwarded-*` 头、且 Host 是回环名(见上)。在这道判定之外还有两道针对浏览器的门(网页发出的跨站 POST 不需要预检,光靠 socket/Host 判定挡不住):请求带 `Origin` 头一律 403,带请求体但 `Content-Type` 不是 `application/json` 一律 415——插件后台的代调用和脚本 `curl`(带 JSON 头、无 Origin)都不受影响。只要请求经过反代(一定带转发头),一律返回 403——公网碰不到这些接口。2.0 起这些接口的调用方从浏览器管理页换成了插件后台:设置页发起的请求走 DSH 同源路由 `/_dsh/zen-remote/admin/*`,由主服务端进程以「本机直连」的身份代调下面这些网关接口(带 `x-zen-remote-via` 标记的转发请求只许查看、拒绝变更——唯一例外是 `/_dsh/zen-remote/admin/shares` 的开关会话远程:Web 应用端(手机)可以开关,手机会话信息卡片里的「远程访问」开关就走这条)。
280
287
 
281
288
  | 接口 | 方法 | 作用 | 参数 |
282
289
  | --- | --- | --- | --- |
283
- | `/lan-gate/pair` | POST | 生成一个新的一次性配对码(10 分钟有效) | 无 |
284
- | `/lan-gate/status` | GET | 查看运行状态、当前配对码、已配对设备列表 | 无 |
285
- | `/lan-gate/action` | POST | 管理设备 | `action`: `set-kind` / `rename` / `revoke` / `revoke-all`;`id`: 设备 id(`revoke-all` 不需要);`set-kind` 还需要 `kind`(`phone`/`desktop`/`auto`);`rename` 还需要 `name` |
290
+ | `/lan-gate/pair` | POST | 生成一个新的一次性配对码(10 分钟有效)。**码与角色绑定** | `role`(可选):`desktop-client` 生成桌面应用端码;缺省/无法解析时为 Web 应用端码。响应带 `role` |
291
+ | `/lan-gate/status` | GET | 查看运行状态、当前配对码、已配对设备列表(每台带 `role`) | 无 |
292
+ | `/lan-gate/action` | POST | 管理设备 | `action`: `set-role` / `set-kind` / `rename` / `revoke` / `revoke-all`;`id`: 设备 id(`revoke-all` 不需要);`set-role` 还需要 `role`(`web`/`desktop-client`,改成 `desktop-client` 时推送订阅一并删除);`set-kind` 还需要 `kind`(`phone`/`desktop`/`auto`);`rename` 还需要 `name` |
286
293
  | `/pwa/push/send` | POST | 给所有已订阅设备发一条推送 | `title`、`body`(都是纯文字,不含对话内容) |
287
294
 
288
- 配对入口 `/lan-gate/pair/claim`(POST)是唯一对外开放的例外——它就是设备输入配对码换令牌的地方,靠码本身(一次性、10 分钟过期)和失败锁定(连续 5 次错码锁该 IP 15 分钟)来防护,不需要本机身份。
295
+ 配对兑换入口是仅有的对外开放例外,靠码本身(一次性、10 分钟过期)和失败锁定(连续 5 次错码锁该 IP 15 分钟)防护,不需要本机身份,两个通道各认各的角色:
296
+
297
+ - `/lan-gate/pair/claim`(POST)——浏览器配对页用,只收 **Web 应用端**码,成功发 Cookie;
298
+ - `/lan-gate/pair/claim-desktop`(POST)——子客户端配对用,只收**桌面应用端**码,令牌放在 JSON 响应体里(`{ok, id, name, token}`),从不种 Cookie。
299
+
300
+ 角色不匹配的兑换返回 403 `role-mismatch` 和一段本地化的提示文案;错通道的码不消耗、也不计锁定。
301
+
302
+ ---
303
+
304
+ ## 📡 远程中继(桌面应用端的数据通道)
305
+
306
+ 桌面应用端没有 DSH 的登录 Cookie,也不直接转发 DSH 的原生 `/api` 和 WebSocket——它和主服务端之间走 zen-remote 自己的中继协议:**单次调用是普通 HTTP POST,每条流式订阅是一个 HTTP 流式响应**,全部挂在主服务端插件的专用前缀 `/_dsh/zen-remote/relay/` 下,经网关进出。插件不自己实现 WebSocket;客户端中止请求即取消订阅。
307
+
308
+ **三道门,逐请求复核**:
309
+
310
+ 1. **网关设备认证**:请求必须带有效的桌面应用端设备令牌(Bearer),网关只放行它去中继前缀;
311
+ 2. **标记头 + 共享密钥**:网关给每个放行的转发请求盖上 `x-zen-remote-via: gateway`、`x-zen-remote-role`、`x-zen-remote-device` 和 `x-zen-remote-secret`(共享密钥)四个头(客户端伪造的同名头在入口就被剥掉);中继路由逐次校验密钥与三个标记头,任何一项不对一律 401。没有这道密钥,本机其他进程可以直连 `127.0.0.1` 伪造标记头冒充网关;
312
+ 3. **按方法登记的访问控制**:每个允许的方法登记它的会话归属字段,授权只看登记的字段,表里没有的方法一律拒绝——**不是**「从参数里通用扫描会话 id」(那种写法会被诱饵字段骗过:塞一个已共享会话的 id 进不认识的名字里,DSH 的参数校验会悄悄丢掉它)。
313
+
314
+ **路由一览**(前缀 `/_dsh/zen-remote/relay`):
315
+
316
+ | 路由 | 方法 | 作用 |
317
+ | --- | --- | --- |
318
+ | `/ping` | GET | 存活探测,`{ok:true}` |
319
+ | `/v1/handshake` | POST | 握手:交换中继协议版本、服务端 id / 显示名、DSH 版本号、接口指纹表。中继协议版本不一致即拒绝连接;回包带 `serverName`(服务端显示名)与 `deviceName`(本设备在服务端记录里的名字) |
320
+ | `/v1/device/name` | POST | 设备改名:**网关自己处理、不转发给 DSH**——只对已配对的桌面应用端开放,只改调用者自己的设备记录;请求体 `{"name":"…"}`(1–40 字),改名经流心跳(约 15 秒)与握手刷新(约 30 秒)同步到对端 |
321
+ | `/v1/invoke` | POST | 在已共享会话上执行一次 DSH 远程调用(单次,JSON 进出) |
322
+ | `/v1/stream` | POST | 打开一条 DSH 流式订阅。**NDJSON 格式**:响应体每行一条 JSON;`{"type":"ping"}` 心跳行(默认 15 秒一条)防止反代把闲置流掐掉,每条都带当下的 `serverName` / `deviceName`(服务端改名约 15 秒内到达所有客户端);共享关闭/订阅失效时补一行 `{"type":"error","error":{"code":"unshared"}}` 再结束 |
323
+ | `/v1/event-result` | POST | 审批/提问这类「需要应答事件」的应答回传(`{eventId, result}`),服务端按转发记录核对归属后转交 |
324
+ | `/v1/http` | POST | 改动摘要 / diff 面板的 plain-HTTP 长尾:请求体 `{route, query}` 只登记 `changes.summary` / `changes.diff` 两条、只转发 GET(查询串是这次调用的坐标,不是要转发的请求体);查询串按允许清单**规范化重建**——只保留 `sessionId`(恰好一次,过共享表)与 `seq` / `index`(至多一次、十进制非负整数),未知参数丢弃,合成 URL 只由规范化结果拼成(两次解析对控制字符的处理差异曾是越权读取口子)——随后**进程内**交给宿主 `/api` 共享 fetch handler,不走回环 HTTP |
325
+ | `/v1/upload` | POST | 远程会话的**非图片附件**上传:请求体是原始字节流(不是 JSON);查询串按允许清单规范化重建——只保留 `sessionId`(恰好一次、过共享表,未共享 403)与 `name`(至多一次);上限 100 MiB,`Content-Length` 预检加流式计数双保险,超限 413(先回拒绝再排空尾部字节);每设备并发 8(超出 429);随后与 `/v1/http` 一样**进程内**交给宿主 `/api` 共享 handler——上传回执落进的会话与之后 `session/prompt` 解析附件引用的会话是同一个 |
326
+ | `/v1/unshare` | POST | 子客户端主动关闭某会话的远程(只认共享表内的会话) |
327
+
328
+ **允许清单里的方法**(其余一律 `forbidden-method`):会话读写(`session/follow`、`session/page`、`session/prompt`、`session/cancel`、`session/rename`、`session/selectModel`、`session/updateQueue`、`session/attachment`、`session/projections`)、新建与分叉(`session/create`、`session/fork`,结果自动共享)、子智能体(`subagents/prompt`、`subagents/interruptByParent`,按父会话判定)、附件与 @ 引用(`fileUploads/upload`、`fileReferences/list`、`sessionReferenceResolver/candidates`)、面板长尾(`goals/*` 五个、`commands/list|execute`、`agentPresets/select`、`sessionFeedback/record`)、文件树与预览(`workspaceFiles/list|changes|read|readBytes|stat`)、终端(`terminal/*`)、任务(`job/list|follow|kill`)、`skills/list`、消息反馈(`messageFeedback/*`)、`schedule/list`、工作区会话操作(`workspace/pinSession` 等)、以及四个全局读:`session/control` 流、`session/list`、`workspace/follow` 流与 `session/modelCatalog`。前三个不带会话参数,安全靠**输出过滤**:每一帧/每一行结果先按共享表过滤,未共享会话的行到不了客户端;`session/list` 结果行里指向不可访问会话的 `parentSessionId` 字段也会被删掉(行的其余部分照常透传)。`session/modelCatalog` 同样无参数、全局只读——它就是服务端的模型提供方目录,只有分组与模型 id、显示名,不带会话数据;服务端会把结果里的 `failures` 数组清空(宿主的逐组错误文本可能带端点或凭据细节,客户端本来也不展示它)。`sessionReferenceResolver/candidates`(@ 引用候选)的答案带**每一个**服务端会话的标题、目录与现成 mention,同样走行级输出过滤,只放行已共享会话的行。审批/提问事件经 `$zr/events` 订阅转发,逐事件按 `agentId` 判定可达性。
329
+
330
+ `/v1/http` 那条路由是另一张按**路由名**登记的允许清单(不是方法表的一部分),纪律相同:表外路由一律 404,登记的路由只按声明的会话参数判定;子客户端一侧的对应入口是 `GET /_dsh/zen-remote/client/http/<route>`——浏览器 fetch 包装拦下的 `/api/changes.*` 虚拟 id 请求改道到这里,还原原始 id 后经中继转发,上游 Content-Type 只认 JSON,其余一律降级为 `text/plain` + `nosniff`。
331
+
332
+ `/v1/upload` 这条路由的起点在**子客户端本机**:DSH 的非图片附件上传走 Web Worker 里发出的请求,`window.fetch` 包装与 typert 拦截都够不到它,所以插件在子客户端本机后台包装宿主连接服务 `fetchRoutes` 表里的上传路由(`/api/session/uploadFileBinary`)把请求接住——带虚拟会话 id 的上传经中继流式转到服务端(本机 id 的上传原样放行、请求体一个字节不碰),超上限在子客户端本地直接拒,慢链路受客户端侧 300 秒超时约束且上传的超时与传输失败不改连接状态。本地失败也按宿主自己的「200 + 失败信封」格式回给界面,中文错误文案直接落在附件卡片上。导出(`/api/session.export`)是同一次包装的另一项:虚拟 id 的导出请求后台直接 403 `remote-unsupported`(界面里菜单项也已隐藏)。
333
+
334
+ **安全边界**:
335
+
336
+ - 中继执行时用的是 DSH 网关服务的公开调用方法,身份是操作者身份,**不在服务端替换任何 DSH 内部方法**;
337
+ - 子智能体/分叉会话不单独落表,可达性按「祖先链上有已共享会话」判定;
338
+ - `dsh-session:` 会话引用也会被扫描:DSH 会在**发消息**(`session/prompt` / `subagents/prompt` 的 content 文本块)、**改写排队消息**(`session/updateQueue` 的 edit 内容)与**斜杠命令**(`commands/execute` 参数里的全部字符串——命令处理器会把原始输入拼进下一条用户消息)这三条路径上注入被引用会话的内容,且注入时不再做权限检查,所以引用指向未共享会话的调用在服务端直接 403;@ 引用候选(`sessionReferenceResolver/candidates`)的答案也只放行已共享会话的行;
339
+ - 终端是有意开放的(桌面应用端是可信设备):开的是**服务端**的 PTY,以服务端用户身份运行、不受智能体沙箱与审批限制;关闭会话的远程时终端流一并终止;
340
+ - `workspaceFiles/read` 等文件预览接口不限制在会话目录内(DSH 自身就不限制),服务端进程可读的文件都能读——信任前提与终端相同;
341
+ - 每台设备并发流数有上限(32)、同时在途的 invoke 数也有上限(32,超出 429 `too-many-invokes`);`*/internal` 形状的错误码(`gateway/internal` 等)只回码不带消息——那类消息引用的是服务端内部细节。事件应答按「转发时的订阅 + 设备」核对归属。审批/提问的转发是**先到先得**:服务端自己的界面和子客户端谁先应答谁生效,后答的一方被网关拒绝、同步成「已处理」。中继**不代答**:未共享会话的审批/提问事件不会被转发,服务端界面也不在线时这个审批就一直等待——网关会把仍未应答的事件转交给下一个连上来的订阅,等,而不是替你拒绝(代答会把整批待审批一次性判死,砸掉「推送 → 唤醒 → 审批」链路)。未共享会话的**通知类**事件(EMIT 帧:会话列表摘要、账号到期之类的服务端全局状态)一律不转发。
289
342
 
290
343
  ---
291
344
 
@@ -305,14 +358,18 @@ sudo cloudflared service install # 通了再装成常驻服务
305
358
 
306
359
  **防住了什么:**
307
360
  - 配对码暴力破解——码本身 10 分钟一次性,连续 5 次错码会把那个来源 IP 锁 15 分钟。
308
- - 令牌可以随时吊销——手机丢了、借给别人用完了,管理页点一下就失效,立即生效。
309
- - 请求量——按解析出的真实客户端 IP 限流,默认每分钟 120 次,超了就 429。
310
- - 管理面只有本机能碰——生成配对码、管理设备、触发推送,这些接口只认本机直连,经反代来的请求(一定带转发头)一律 403。
361
+ - 码与角色绑定——Web 应用端的码进不了桌面通道,反之亦然;拿错的码不消耗、不计锁定。
362
+ - 令牌可以随时吊销——设备丢了、借给别人用完了,设置页点一下就失效,立即生效(连接、HTTP 长流与推送订阅一并销毁)。
363
+ - 桌面应用端被关进中继前缀——它访问其余任何路径(包括 DSH 页面和 `/api`)一律 403。
364
+ - 中继三道门——设备令牌、网关标记头 + 每次加载现生成的共享密钥(`LAN_GATE_RELAY_SECRET`,不可外部指定)、按方法登记的允许清单 + 输出过滤;未共享会话的数据一步都出不来。
365
+ - 请求量——**未配对**的请求按解析出的真实客户端 IP 限流,默认每分钟 120 次,超了就 429;已配对设备不受这条限制(桌面应用端另有中继自己的并发上限)。
366
+ - 管理面只有本机能碰——生成配对码、管理设备、触发推送,这些接口只认本机直连(回环 socket、无转发头、回环 Host),经反代来的请求(一定带转发头)一律 403;带 `Origin` 的请求与带非 JSON 请求体的请求也一样 403/415,DNS 重绑定与跨站简单请求进不来;带网关标记头的请求即使本机直连也拒绝变更动作(开关会话远程除外,见上)。
311
367
 
312
368
  **没防住什么,需要你自己注意:**
313
369
  - 反代配置错了——比如不小心把 `127.0.0.1:3088/lan-gate/admin` 也挂到公网域名下,或者 `X-Forwarded-Proto` 设错导致网关判断错客户端协议,这些是配置问题,网关本身防不住。
314
- - 令牌被别人拿到——这是单用户工具,令牌等于访问权限,没有更细的权限分级;谁拿到令牌谁就能用,怀疑泄露就去管理页吊销重配。
315
- - DSH 自身的能力边界——网关只负责把 HTTPS 流量安全地转发给 DSH,不会也不能给 DSH 本身加它没有的安全措施(比如 `/api` 自己的信任栅栏是 DSH 那边的事)。
370
+ - 令牌被别人拿到——这是单用户工具,令牌等于访问权限,没有更细的权限分级;谁拿到令牌谁就能用,怀疑泄露就去设置页吊销重配。
371
+ - **桌面应用端是可信设备**——它的终端以服务端用户身份运行、不受智能体沙箱与审批限制,文件预览不限制在会话目录内,还能在服务端任意工作区新建会话。「只有已共享会话可见」约束的是会话数据,不是这台机器;把配对码给不值得信任的计算机等于把机器交给它。
372
+ - DSH 自身的能力边界——网关只负责把流量安全地转发给 DSH,不会也不能给 DSH 本身加它没有的安全措施(比如 `/api` 自己的信任栅栏是 DSH 那边的事)。
316
373
  - 状态文件 `~/.dsh/lan-gate-state.json` 明文存着 VAPID 私钥和所有设备的令牌——这个文件本身就等于全部访问权限,注意宿主机上它的文件权限,别把 `~/.dsh` 目录同步进公共网盘或备份到不受信的地方。
317
374
 
318
375
  ---
@@ -335,7 +392,7 @@ sudo cloudflared service install # 通了再装成常驻服务
335
392
  旧版是按来源 IP 审批的,这套逻辑在新的令牌模型下没有意义。网关用新版本第一次启动时,会检测到旧的状态文件并直接把它改名归档成 `lan-gate-state.json.v1.bak`(不做数据迁移)。所有设备都需要重新走一遍配对流程。
336
393
 
337
394
  **配对码提示过期或不对怎么办?**
338
- 配对码 10 分钟有效、用一次就失效,过期或用过了要回到本机管理页重新点「生成配对码」。如果连续输错 5 次,那个来源 IP 会被锁 15 分钟,等一等或者换个网络再试。
395
+ 配对码 10 分钟有效、用一次就失效,过期或用过了要回到服务端的插件设置页(DSH「插件」页的 dsh-zen-remote 区块)重新生成。如果连续输错 5 次,那个来源 IP 会被锁 15 分钟,等一等或者换个网络再试。
339
396
 
340
397
  **推送收不到怎么排查?**
341
398
  按顺序查:手机是不是用 HTTPS 域名访问的(HTTP 下浏览器根本不会注册 Service Worker,推送无从谈起)?浏览器/PWA 有没有被系统或用户拒绝通知权限?可以到本机管理页对应的状态接口(`/lan-gate/status`)看这台设备名字后面有没有 🔔 标记,确认订阅到底成功没有。
@@ -354,8 +411,8 @@ npm test # 起 mock 上游,跑 gateway/auth/push 三组测试:反代与注
354
411
 
355
412
  | 路径 | 作用 |
356
413
  | --- | --- |
357
- | `lan-gate.mjs` | Cordis 插件入口:spawn 网关子进程 + 生命周期管理 |
358
- | `dsh-push.mjs` | (可选)agent 完成推送宿主插件,调网关本机 `/pwa/push/send`;同时注册模型工具 `push_notify` |
414
+ | `lan-gate.mjs` | 网关子插件入口,由主入口按 host 角色加载:spawn 网关子进程 + 生命周期管理 |
415
+ | `dsh-push.mjs` | 推送子插件(与网关一起由主入口加载):监听 DSH 事件总线调网关本机 `/pwa/push/send`,并注册模型工具 `push_notify` |
359
416
  | `lib/lan-gate-server.cjs` | 网关本体:单文件 CommonJS(Node stdlib + `web-push` 一个运行时依赖),HTTP/WebSocket 反代 + 配对/令牌 + 限流 + PWA 注入 + Web Push |
360
417
  | `pwa/manifest.json` | PWA 安装清单 |
361
418
  | `pwa/sw.js` | service worker(离线缓存 + 推送通知) |
package/dsh-push.mjs CHANGED
@@ -32,34 +32,21 @@ export const name = 'dsh-zen-remote-push'
32
32
  export const inject = []
33
33
 
34
34
  import { defineTool } from '@deepseek-ai/dsh-tools'
35
-
36
- // Shares the gateway's optional config file <DSH_HOME>/lan-gate.config.json
37
- // (keys: port, lang, pushEvents, pushDebounceMs, pushSummary, pushTurnEnd, pushTool).
38
- // Explicit env wins.
39
- import { readFileSync } from 'node:fs'
40
- import { join } from 'node:path'
41
- import { homedir } from 'node:os'
42
- function fileConfig() {
43
- try {
44
- const raw = JSON.parse(readFileSync(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'lan-gate.config.json'), 'utf8'))
45
- return raw !== null && typeof raw === 'object' ? raw : {}
46
- } catch { return {} }
47
- }
48
- const truthy = (v) => v === true || v === 1 || v === '1'
49
- const FILE = fileConfig()
50
- const GATEWAY_PORT = Number(process.env.LAN_GATE_PORT ?? FILE.port ?? 3088)
51
- const EVENTS = String(process.env.DSH_PUSH_EVENTS ?? FILE.pushEvents ?? 'agent/turn-stopping').split(',').map((s) => s.trim()).filter(Boolean)
52
- const DEBOUNCE_MS = Number(process.env.DSH_PUSH_DEBOUNCE_MS ?? FILE.pushDebounceMs ?? 15000)
53
- const INCLUDE_SUMMARY = process.env.DSH_PUSH_SUMMARY !== undefined ? process.env.DSH_PUSH_SUMMARY === '1' : truthy(FILE.pushSummary)
54
- const TURN_END_ENABLED = process.env.DSH_PUSH_TURN_END !== undefined ? process.env.DSH_PUSH_TURN_END === '1' : truthy(FILE.pushTurnEnd)
55
- const PUSH_TOOL_ENABLED = process.env.DSH_PUSH_TOOL !== undefined ? process.env.DSH_PUSH_TOOL !== '0' : FILE.pushTool !== false
35
+ // Config arrives as apply(ctx, config)'s already-resolved values — every
36
+ // field merged from env > plugin row > lan-gate.config.json > defaults
37
+ // (src/config.ts). Standalone loads (old single-row setups, tests) resolve
38
+ // the same way on the spot. No module-top-level config constants: everything
39
+ // below is computed per apply() call and passed down as arguments.
40
+ import { readFileConfig, resolveConfig } from './lib/config.js'
56
41
 
57
42
  // Notification language. The gateway resolves the same `lang` key to the
58
43
  // requesting browser's Accept-Language when it is "auto"; a notification has
59
44
  // no such reader signal, and the host process has no usable locale either
60
45
  // (launchd starts it with no LANG — Intl reports en-US on a Chinese machine),
61
- // so anything other than "en" here means Chinese.
62
- const LANG = String(process.env.DSH_PUSH_LANG ?? FILE.lang ?? 'zh') === 'en' ? 'en' : 'zh'
46
+ // so anything other than "en" here means Chinese. DSH_PUSH_LANG keeps
47
+ // precedence over the resolved `lang` field. Resolved at call time (not at
48
+ // import) so policy tests can flip the variable per call.
49
+ const resolveLang = (lang) => String(process.env.DSH_PUSH_LANG ?? lang ?? 'zh') === 'en' ? 'en' : 'zh'
63
50
  const COPY = {
64
51
  zh: {
65
52
  lastTool: '最后执行了 {tool}',
@@ -82,7 +69,6 @@ const COPY = {
82
69
  turnEndBody: 'The agent finished the current turn'
83
70
  }
84
71
  }
85
- const T = COPY[LANG]
86
72
 
87
73
  // The model-facing tool of @deepseek-ai/dsh-tool-ask-user. Its `tool/call`
88
74
  // session event is appended BEFORE dispatch (dsh-agent-loop appendToolCall),
@@ -105,16 +91,19 @@ const ASK_USER_TOOL = 'ask_user_question'
105
91
  // 5000ms covers a machine answerer with margin while still reaching a phone
106
92
  // promptly when a human really is needed. Raise it if your answerer is slower
107
93
  // than that; the cost of a longer window is only that genuine prompts notify
108
- // later.
109
- const APPROVAL_PENDING_MS = Number(
110
- process.env.DSH_PUSH_APPROVAL_GRACE_MS ?? FILE.pushApprovalGraceMs ?? 5000
111
- )
94
+ // later. Not part of the T12 config table: DSH_PUSH_APPROVAL_GRACE_MS and the
95
+ // legacy `pushApprovalGraceMs` file key keep working as before, read per
96
+ // apply() so tests can shrink the window.
97
+ function approvalPendingMs(fileConfig) {
98
+ return Number(process.env.DSH_PUSH_APPROVAL_GRACE_MS ?? fileConfig.pushApprovalGraceMs ?? 5000)
99
+ }
112
100
 
113
101
  // Low-level sender shared by every leg: POSTs to the gateway's local
114
102
  // /pwa/push/send, which does the actual VAPID + aes128gcm encrypted delivery
115
103
  // to every subscribed device and replies { ok, sent, failed }.
116
- async function sendPush(title, body) {
117
- const res = await fetch(`http://127.0.0.1:${GATEWAY_PORT}/pwa/push/send`, {
104
+ // gatewayPort comes from the resolved config's `port` (default 3088).
105
+ async function sendPush(title, body, gatewayPort) {
106
+ const res = await fetch(`http://127.0.0.1:${gatewayPort}/pwa/push/send`, {
118
107
  method: 'POST',
119
108
  headers: { 'Content-Type': 'application/json' },
120
109
  body: JSON.stringify({ title, body: body || '' })
@@ -153,7 +142,10 @@ export function assistantText(message) {
153
142
  // Summary of the closing turn, from the session's append-only event log:
154
143
  // the LAST assistant message that produced real text, or — when the whole
155
144
  // turn was tool work with no prose — the last tool name. Never thinking text.
156
- export function turnSummary(events, turn) {
145
+ // lang is the resolved language ('en' | 'zh'); omitted (tests, ad-hoc calls)
146
+ // it falls back to DSH_PUSH_LANG, then Chinese.
147
+ export function turnSummary(events, turn, lang) {
148
+ const copy = COPY[resolveLang(lang)]
157
149
  if (!Array.isArray(events)) return ''
158
150
  let lastTool = ''
159
151
  for (let i = events.length - 1; i >= 0; i--) {
@@ -168,7 +160,7 @@ export function turnSummary(events, turn) {
168
160
  lastTool = ev.data.name
169
161
  }
170
162
  }
171
- return lastTool ? T.lastTool.replace('{tool}', lastTool) : ''
163
+ return lastTool ? copy.lastTool.replace('{tool}', lastTool) : ''
172
164
  }
173
165
 
174
166
  // First question of an ask_user_question call, from the raw (unparsed)
@@ -193,8 +185,7 @@ export function sessionEvents(session) {
193
185
  if (session && typeof session.snapshotEvents === 'function') {
194
186
  // ponytail: snapshotEvents() with no argument copies the WHOLE event log
195
187
  // on every call. That is acceptable here — this branch only runs when
196
- // INCLUDE_SUMMARY is on (dsh-push.mjs:53, off by default) and at most once
197
- // per turn end. Slicing would need the turn's start seq, and the
188
+ // includeSummary is on (off by default) and at most once per turn end. Slicing would need the turn's start seq, and the
198
189
  // agent/turn-stopping payload carries none: its emitter (0.1.2
199
190
  // dsh-agent-loop) sends only `{ turn, signal }` plus the injected
200
191
  // `agent` — a turn NUMBER, not an event-log seq, and the two are not the
@@ -217,10 +208,13 @@ const skip = (reason) => ({ shouldNotify: false, title: '', body: '', reason })
217
208
  * summary already-extracted turn summary ('turn-end')
218
209
  * toolName tool awaiting approval ('approval')
219
210
  * question pending question text ('question')
220
- * @param {object} cfg { turnEndEnabled, debounceMs, includeSummary }
211
+ * @param {object} cfg { turnEndEnabled, debounceMs, includeSummary, lang }
212
+ * lang is the resolved notification language ('en' | 'zh'); when absent
213
+ * (policy tests build their own cfg) DSH_PUSH_LANG decides, else Chinese.
221
214
  * @returns {{shouldNotify: boolean, title: string, body: string, reason: string}}
222
215
  */
223
216
  export function decideNotification(input, cfg) {
217
+ const copy = COPY[resolveLang(cfg.lang)]
224
218
  const debounced = (input.now - input.lastSent) < cfg.debounceMs
225
219
 
226
220
  switch (input.kind) {
@@ -229,15 +223,15 @@ export function decideNotification(input, cfg) {
229
223
  case 'approval':
230
224
  return {
231
225
  shouldNotify: true,
232
- title: T.approvalTitle,
233
- body: input.toolName ? T.approvalBody.replace('{tool}', input.toolName) : T.approvalBodyPlain,
226
+ title: copy.approvalTitle,
227
+ body: input.toolName ? copy.approvalBody.replace('{tool}', input.toolName) : copy.approvalBodyPlain,
234
228
  reason: 'approval-pending'
235
229
  }
236
230
  case 'question':
237
231
  return {
238
232
  shouldNotify: true,
239
- title: T.questionTitle,
240
- body: (cfg.includeSummary && input.question) || T.questionBody,
233
+ title: copy.questionTitle,
234
+ body: (cfg.includeSummary && input.question) || copy.questionBody,
241
235
  reason: 'question-pending'
242
236
  }
243
237
 
@@ -246,7 +240,7 @@ export function decideNotification(input, cfg) {
246
240
  if (!cfg.turnEndEnabled) return skip('turn-end-disabled')
247
241
  if ((input.delegationDepth ?? 0) !== 0) return skip('subagent')
248
242
  if (debounced) return skip('debounced')
249
- return { shouldNotify: true, title: T.turnEndTitle, body: input.summary || T.turnEndBody, reason: 'turn-end' }
243
+ return { shouldNotify: true, title: copy.turnEndTitle, body: input.summary || copy.turnEndBody, reason: 'turn-end' }
250
244
 
251
245
  default:
252
246
  return skip('unknown-kind')
@@ -298,9 +292,10 @@ const PUSH_TOOL_GLOBAL_MAX = 20
298
292
 
299
293
  // Registers the push_notify model tool against ctx.tools, if present and not
300
294
  // disabled. Throttle state lives in this closure (fresh per apply() call, so
301
- // tests get isolated state without needing a fresh module import).
302
- function registerPushTool(ctx, gate) {
303
- if (!PUSH_TOOL_ENABLED) {
295
+ // tests get isolated state without needing a fresh module import). Both the
296
+ // enabled flag and the gateway port arrive from apply()'s resolved config.
297
+ function registerPushTool(ctx, gate, pushToolEnabled, gatewayPort) {
298
+ if (!pushToolEnabled) {
304
299
  console.log('[dsh-zen-remote-push] push_notify disabled (DSH_PUSH_TOOL=0 / pushTool:false)')
305
300
  return
306
301
  }
@@ -310,7 +305,7 @@ function registerPushTool(ctx, gate) {
310
305
  // registered. inject() defers the callback until the service exists (the
311
306
  // same pattern vision-toolkit uses for webServer) and still degrades
312
307
  // gracefully: hosts without a tools service simply never fire it.
313
- ctx.inject(['tools'], (toolsCtx) => registerPushToolWith(toolsCtx, toolsCtx.tools, gate))
308
+ ctx.inject(['tools'], (toolsCtx) => registerPushToolWith(toolsCtx, toolsCtx.tools, gate, gatewayPort))
314
309
 
315
310
  // Standing reinforcement of the same guidance. Order 150 is the documented
316
311
  // tool-guidance band (@deepseek-ai/dsh-system-prompt PromptSection.order).
@@ -325,7 +320,7 @@ function registerPushTool(ctx, gate) {
325
320
  })
326
321
  }
327
322
 
328
- function registerPushToolWith(ctx, tools, gate) {
323
+ function registerPushToolWith(ctx, tools, gate, gatewayPort) {
329
324
  if (!tools) {
330
325
  console.log('[dsh-zen-remote-push] "tools" service not present — push_notify not registered')
331
326
  return
@@ -396,7 +391,7 @@ function registerPushToolWith(ctx, tools, gate) {
396
391
  // why it dropped a call.
397
392
  gate.arm(now)
398
393
  try {
399
- const result = await sendPush(args.title, args.body)
394
+ const result = await sendPush(args.title, args.body, gatewayPort)
400
395
  return { delivered: (result && typeof result.sent === 'number') ? result.sent : 0 }
401
396
  } catch (e) {
402
397
  console.warn(`[dsh-zen-remote-push] push_notify send failed: ${String(e && e.message || e)}`)
@@ -409,8 +404,26 @@ function registerPushToolWith(ctx, tools, gate) {
409
404
 
410
405
  // ---------------------------------------------------------------------------
411
406
 
412
- export function apply(ctx) {
413
- const cfg = { turnEndEnabled: TURN_END_ENABLED, debounceMs: DEBOUNCE_MS, includeSummary: INCLUDE_SUMMARY }
407
+ export function apply(ctx, config) {
408
+ // The input is ALWAYS re-resolved, whatever it is: the main entry passes
409
+ // its already-resolved effective values (idempotent — legal values stay
410
+ // put), while an old-style standalone load passing `{}` or a partial row
411
+ // still gets env and lan-gate.config.json their say instead of being
412
+ // masked by whatever the object happened to contain. Precedence:
413
+ // env > row > lan-gate.config.json > defaults (src/config.ts).
414
+ const values = resolveConfig(config ?? {}, readFileConfig(), process.env).values
415
+ const gatewayPort = Number(values.port ?? 3088)
416
+ const events = String(values.pushEvents ?? 'agent/turn-stopping').split(',').map((s) => s.trim()).filter(Boolean)
417
+ const lang = resolveLang(values.lang)
418
+ // Legacy knob, deliberately outside the T12 config table: honored from
419
+ // env/file exactly as before so existing files keep working.
420
+ const graceMs = approvalPendingMs(readFileConfig())
421
+ const cfg = {
422
+ turnEndEnabled: values.pushTurnEnd === true,
423
+ debounceMs: Number(values.pushDebounceMs ?? 15000),
424
+ includeSummary: values.pushSummary === true,
425
+ lang
426
+ }
414
427
 
415
428
  // The one piece of mutable state: when the last push went out. Wrapped so
416
429
  // every leg runs the same pure decision and shares the same debounce clock.
@@ -426,7 +439,7 @@ export function apply(ctx) {
426
439
  const fire = (input) => {
427
440
  const decision = gate.decide(input)
428
441
  // Best-effort; never raise into the host.
429
- if (decision.shouldNotify) sendPush(decision.title, decision.body).catch(() => {})
442
+ if (decision.shouldNotify) sendPush(decision.title, decision.body, gatewayPort).catch(() => {})
430
443
  return decision
431
444
  }
432
445
 
@@ -444,10 +457,10 @@ export function apply(ctx) {
444
457
  fire({
445
458
  kind: 'turn-end',
446
459
  delegationDepth: header && header.delegationDepth,
447
- summary: INCLUDE_SUMMARY ? turnSummary(sessionEvents(session), payload && payload.turn) : ''
460
+ summary: cfg.includeSummary ? turnSummary(sessionEvents(session), payload && payload.turn, lang) : ''
448
461
  })
449
462
  }
450
- for (const event of EVENTS) {
463
+ for (const event of events) {
451
464
  try {
452
465
  ctx.on(event, onTurnEnd)
453
466
  } catch (e) {
@@ -469,7 +482,7 @@ export function apply(ctx) {
469
482
  const timer = setTimeout(() => {
470
483
  armed.delete(id)
471
484
  fire({ kind: 'approval', toolName })
472
- }, APPROVAL_PENDING_MS)
485
+ }, graceMs)
473
486
  if (typeof timer.unref === 'function') timer.unref()
474
487
  armed.set(id, timer)
475
488
  } else if (event.type === 'approval/decided') {
@@ -483,7 +496,7 @@ export function apply(ctx) {
483
496
  console.warn(`[dsh-zen-remote-push] cannot listen on "session/event": ${String(e && e.message || e)}`)
484
497
  }
485
498
 
486
- console.log(`[dsh-zen-remote-push] approval/question notifications on (approval grace ${APPROVAL_PENDING_MS}ms); turn-end (${EVENTS.join(', ')}) ${TURN_END_ENABLED ? 'on' : 'off — set DSH_PUSH_TURN_END=1 to enable'}`)
499
+ console.log(`[dsh-zen-remote-push] approval/question notifications on (approval grace ${graceMs}ms); turn-end (${events.join(', ')}) ${cfg.turnEndEnabled ? 'on' : 'off — set DSH_PUSH_TURN_END=1 to enable'}`)
487
500
 
488
- registerPushTool(ctx, gate)
501
+ registerPushTool(ctx, gate, values.pushTool !== false, gatewayPort)
489
502
  }