dsh-zen-remote 2.0.2 → 2.2.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 (118) hide show
  1. package/AGENTS.md +15 -1
  2. package/README.md +11 -5
  3. package/README.zh-CN.md +11 -5
  4. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
  5. package/docs/adr/0002-relay-emulates-websocket.md +8 -0
  6. package/docs/interface.md +3 -1
  7. package/docs/plugin-bridge.md +140 -0
  8. package/docs/remote-access.en.md +57 -0
  9. package/docs/remote-access.md +63 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  12. package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
  13. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  14. package/lib/adapters/index.js +17 -0
  15. package/lib/adapters/index.js.map +1 -0
  16. package/lib/adapters/types.js +17 -0
  17. package/lib/adapters/types.js.map +1 -0
  18. package/lib/admin-routes.js +51 -0
  19. package/lib/admin-routes.js.map +1 -1
  20. package/lib/client-routes.js +18 -1
  21. package/lib/client-routes.js.map +1 -1
  22. package/lib/client.js +449 -45
  23. package/lib/config.js +27 -0
  24. package/lib/config.js.map +1 -1
  25. package/lib/http.js +10 -0
  26. package/lib/http.js.map +1 -1
  27. package/lib/index.js +246 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/intercept.js +5 -2
  30. package/lib/intercept.js.map +1 -1
  31. package/lib/plugin-adapter-core.js +385 -0
  32. package/lib/plugin-adapter-core.js.map +1 -0
  33. package/lib/plugin-bridge.js +318 -0
  34. package/lib/plugin-bridge.js.map +1 -0
  35. package/lib/plugin-loopback.js +207 -0
  36. package/lib/plugin-loopback.js.map +1 -0
  37. package/lib/plugin-route-intercept.js +1103 -0
  38. package/lib/plugin-route-intercept.js.map +1 -0
  39. package/lib/plugin-tunnel.js +416 -0
  40. package/lib/plugin-tunnel.js.map +1 -0
  41. package/lib/relay-client.js +340 -4
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +665 -7
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  46. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  47. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
  48. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  49. package/lib/types/adapters/index.d.ts +17 -0
  50. package/lib/types/adapters/index.d.ts.map +1 -0
  51. package/lib/types/adapters/types.d.ts +42 -0
  52. package/lib/types/adapters/types.d.ts.map +1 -0
  53. package/lib/types/admin-routes.d.ts +16 -0
  54. package/lib/types/admin-routes.d.ts.map +1 -1
  55. package/lib/types/client/effects/remote-session.d.ts +0 -22
  56. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  57. package/lib/types/client/locales.d.ts +16 -0
  58. package/lib/types/client/locales.d.ts.map +1 -1
  59. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  60. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  61. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  62. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  63. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  64. package/lib/types/client-data/remote-status.d.ts +20 -0
  65. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  66. package/lib/types/client-data/settings-form.d.ts +37 -1
  67. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  68. package/lib/types/client-routes.d.ts +13 -0
  69. package/lib/types/client-routes.d.ts.map +1 -1
  70. package/lib/types/config.d.ts +8 -0
  71. package/lib/types/config.d.ts.map +1 -1
  72. package/lib/types/http.d.ts +7 -0
  73. package/lib/types/http.d.ts.map +1 -1
  74. package/lib/types/index.d.ts +4 -0
  75. package/lib/types/index.d.ts.map +1 -1
  76. package/lib/types/intercept.d.ts.map +1 -1
  77. package/lib/types/plugin-adapter-core.d.ts +263 -0
  78. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  79. package/lib/types/plugin-bridge.d.ts +187 -0
  80. package/lib/types/plugin-bridge.d.ts.map +1 -0
  81. package/lib/types/plugin-loopback.d.ts +104 -0
  82. package/lib/types/plugin-loopback.d.ts.map +1 -0
  83. package/lib/types/plugin-route-intercept.d.ts +114 -0
  84. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  85. package/lib/types/plugin-tunnel.d.ts +96 -0
  86. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  87. package/lib/types/relay-client.d.ts +81 -1
  88. package/lib/types/relay-client.d.ts.map +1 -1
  89. package/lib/types/relay-server.d.ts +32 -1
  90. package/lib/types/relay-server.d.ts.map +1 -1
  91. package/llms.txt +1 -0
  92. package/package.json +2 -2
  93. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  94. package/scripts/check-settings-form.mjs +3 -1
  95. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  96. package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
  97. package/src/adapters/index.ts +19 -0
  98. package/src/adapters/types.ts +44 -0
  99. package/src/admin-routes.ts +64 -0
  100. package/src/client/effects/remote-session.ts +82 -10
  101. package/src/client/locales.ts +36 -0
  102. package/src/client/settings/SettingsSection.tsx +107 -3
  103. package/src/client/styles/remote-session.css.ts +70 -31
  104. package/src/client-data/plugin-bridge.ts +150 -0
  105. package/src/client-data/remote-status.ts +30 -0
  106. package/src/client-data/settings-form.ts +78 -0
  107. package/src/client-routes.ts +29 -1
  108. package/src/config.ts +32 -0
  109. package/src/http.ts +12 -0
  110. package/src/index.ts +252 -0
  111. package/src/intercept.ts +5 -2
  112. package/src/plugin-adapter-core.ts +530 -0
  113. package/src/plugin-bridge.ts +435 -0
  114. package/src/plugin-loopback.ts +299 -0
  115. package/src/plugin-route-intercept.ts +1193 -0
  116. package/src/plugin-tunnel.ts +470 -0
  117. package/src/relay-client.ts +413 -4
  118. package/src/relay-server.ts +687 -9
package/AGENTS.md CHANGED
@@ -29,6 +29,12 @@
29
29
  | `src/relay-client.ts` | 子客户端中继客户端:握手、接口指纹比对、退避重连、吊销识别 |
30
30
  | `src/intercept.ts` | 子客户端本机拦截:包装 `typertGateway`,远程会话调用改走中继、虚拟 id 改写、面板判定(客户端那张字段表也在这里) |
31
31
  | `src/intercept-shape.ts` | 被包装方法的形态检测,不符即拒绝安装远程拦截(本地行为不受影响) |
32
+ | `src/plugin-bridge.ts` | 插件桥纯逻辑(PB-1):桥接声明校验/规范化与 16 位摘要、`zenRemote` 注册表、两端比对 `compareBridges`;不 import DSH、不碰 I/O |
33
+ | `src/plugin-loopback.ts` | 插件桥回环执行器(PB-3):本机回环 GET 插件路由,token 换 cookie(401 重换一次),cookie 只存内存;https 仅对字面 127.0.0.1 放宽证书校验 |
34
+ | `src/plugin-route-intercept.ts` | 子客户端改道(PB-4):包装宿主 webServer 的 `match`,声明命中的虚拟 id 请求改走 `relay.pluginRequest`;未桥接一律统一 503(`x-zen-remote: unbridged`),绝不落本机插件 |
35
+ | `src/plugin-adapter-core.ts` | 适配器框架核心(PB-11):适配器路由/定位器的校验、三种定位(query/路径段/JSON 体)的「远程/本地/混合」判定与双向改写、适配器摘要与让位判定;纯逻辑,框架两半只 import 这里 |
36
+ | `src/adapters/` | 适配器目录(PB-11):`index.ts` 只导出 `ADAPTERS` 数组(本单为空,PB-12 加第一个),`types.ts` 放适配器作者接口,实现各占一个子目录;**除 `src/index.ts` 接线外任何文件不得 import 此目录**,适配器实现只可 import `types.ts` 与纯工具 |
37
+ | `src/client-data/plugin-bridge.ts` | 设置页纯函数(PB-5):桥接状态 → 文案键、整机数据同意表的切换(只动一键);浏览器半边,不 import 主包 |
32
38
  | `src/fetch-route-intercept.ts` | 子客户端本机拦截的第二半:包装宿主连接服务 `fetchRoutes` 表的上传与导出两项——远程会话的非图片附件经 `relay/v1/upload` 流式转服务端(超限本地拒、失败回宿主自己的 200 失败信封)、虚拟 id 的导出后台 403(形态检测、懒装重试、卸载守卫) |
33
39
  | `src/virtual-id.ts` | `zr~<serverId>~<id>` 虚拟 id 算术(纯函数,无状态) |
34
40
  | `src/merge-streams.ts` | 三条全局流/列表的本地 + 远程合并状态机(侧边栏远程分组) |
@@ -105,9 +111,17 @@ workflow 自带的 `GITHUB_TOKEN`,不需要额外密钥——但 job 的 `perm
105
111
  - **中继访问控制按方法登记,两张表同步**:新增远程方法必须同时登记
106
112
  `src/relay-access.ts` 的 `RELAY_METHODS`(服务端,授权看登记的归属字段)
107
113
  和 `src/intercept.ts` 的 `CLIENT_METHOD_FIELDS`(客户端,改写判定),
108
- 一致性由 `test/intercept.test.cjs` 的逐方法比对测试钉住。禁止「从参数里
114
+ 一致性由 `test/intercept.test.cjs` 的逐方法比对测试钉住。插件桥的授权另走一条:声明注册表 + 路径完全相等 + 会话共享(`src/plugin-route-intercept.ts` / relay 的 `/v1/plugin`),同样不扫描参数;
115
+ 禁止「从参数里
109
116
  通用扫描会话 id」的写法——DSH 会悄悄丢掉不认识的参数名,扫描会被诱饵
110
117
  字段骗过;全局读(`session/list` 等)安全靠输出过滤,不靠输入判定。
118
+ - **适配器必须可插拔**:`src/adapters/` 整个目录删掉后其余功能一字不改——
119
+ 框架只消费 `src/plugin-adapter-core.ts`(在适配器目录之外),接线只有
120
+ `src/index.ts` 一处 import `./adapters/index`,`createRelayHandler` /
121
+ `installPluginRouteIntercept` / admin 列表的适配器参数全部缺省空;改
122
+ 框架两半时不得新增对适配器目录的 import(`test/adapters-pluggable.test.cjs`
123
+ 的静态扫描钉住)。适配器让位规则:插件自己登记的协作声明永远优先,
124
+ 运行时逐请求核对注册表摘要。
111
125
  - **桌面端请求没有 `Origin`/`Sec-Fetch-Site`**:DSH 桌面端主进程把窗口请求
112
126
  转发给本机后台前,自己校验过 `Origin: dsh-app://app`,然后删掉
113
127
  `host` / `origin` / `cookie` / `sec-fetch-site`。所以 `src/http.ts` 的
package/README.md CHANGED
@@ -90,7 +90,7 @@ dsh plugin --profile web add dsh-zen-remote
90
90
  // ~/.dsh/profiles/desktop/package.json
91
91
  {
92
92
  "dependencies": {
93
- "dsh-zen-remote": "^2.0.2" // for local development: "link:/path/to/dsh-zen-remote"
93
+ "dsh-zen-remote": "^2.2.0" // for local development: "link:/path/to/dsh-zen-remote"
94
94
  }
95
95
  }
96
96
  ```
@@ -242,6 +242,8 @@ Row fields (matching `src/config.ts`):
242
242
  | `serverName` | computer name | **Device name** (≤ 40 chars), shared by both roles: on a host it is the display name in clients' group titles; on a client it is the name registered when pairing. Syncs both ways with the server automatically |
243
243
  | `idleHours` | `48` | Idle-sleep window in hours for remote-enabled sessions; range (0, 8760] |
244
244
  | `autoShareNewSessions` | off | Auto-enable remote on every session the host creates |
245
+ | `pluginBridgeConsent` | `{}` | The plugin bridge's whole-machine-data consent map: plugin name → boolean. Managed by the settings page's Plugin bridge section, rarely hand-edited; leftover keys for unregistered plugins are never pruned |
246
+
245
247
  | `serverUrl` *(client)* | empty | The server's gateway address, normalized per the rules above |
246
248
  | `deviceToken` *(client)* | empty | The paired device token; stored as a secret field, redacted everywhere |
247
249
  | `turnFoldDesktop` | off | Turn-process folding at every width, not just phone widths |
@@ -305,6 +307,10 @@ The `push_notify` tool. The model should call it when you explicitly asked to be
305
307
  - Title-row connection icon per remote session (online / offline / version differs); a "remote closed" banner with the reason when a session is slept or closed server-side; offline groups go grey with input disabled and reconnect automatically with backoff
306
308
  - First-come-first-served approvals/questions: if the server's own UI answers first, the client syncs to "already handled"
307
309
 
310
+ **Both ends (2.1 additions)**
311
+
312
+ - The **plugin bridge**: another plugin registers one bridge declaration and its panel data in remote sessions is served by the HOST — the sub-client reroutes the declared GET requests to the server, which executes them over loopback and streams the answer back; local data never leaks into a remote session. Bridged requires the plugin name, exact version and declaration to agree on BOTH ends; whole-machine data additionally needs the server owner's per-plugin consent in the settings page. Author guide: [the plugin bridge](docs/plugin-bridge.md)
313
+
308
314
  In depth: [interface](docs/interface.md) · [public access + relay protocol](docs/remote-access.en.md)
309
315
 
310
316
  ---
@@ -335,8 +341,8 @@ What a paired device is trusted with, stated plainly:
335
341
  - Switching to a different server and back to the original one — or a server deleting a workspace and recreating one under the same id — leaves the affected remote groups invisible until the client page is **reloaded** (DSH's sidebar never re-accepts a removed workspace id within one page lifetime).
336
342
  - "Export session" is disabled in a remote session (the menu item is hidden, and the backend refuses virtual session ids); the changes panel's **summary and diff do work** (relayed), while the entries that would pop a dialog on the server machine — "open", "reveal in Finder", "open in app", on the changes panel and deliverable cards alike — are hidden there.
337
343
  - The host truncates @-mention candidates to the **first 50 rows before** share-filtering: on a server with many sessions, a shared one may be missing from the candidate list (the reference check itself is unaffected — references you type out are still verified one by one).
338
- - Third-party plugins' own non-standard endpoints are not forwarded — their panels degrade or hide in remote sessions. A plugin's **tools** run on the server and are unaffected; what breaks is the part of its UI that calls the plugin's own backend with a session id (dsh-better-sidebar is the prime example, handled by the next bullet).
339
- - With [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) installed, its file browser, editor, git, tasks, bottom workbench and similar surfaces are hidden in remote sessions — they read and write the **client machine's own** files; and because it takes over the host's "Files" tab, a remote session has no sidebar file browser for now; file links in chat or deliverables open an empty right column in a remote session.
344
+ - A third-party plugin's own endpoints work in remote sessions only through the [plugin bridge](docs/plugin-bridge.md): cooperative plugins register themselves, third-party plugins are covered by built-in adapters. A plugin with neither has its panels degraded or hidden in remote sessions (its **tools** still run on the server, unaffected).
345
+ - With [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) installed, remote sessions have TWO treatments: exactly **0.25.0** is bridged by zen-remote's built-in adapter — its file / git / task panels read and write the SERVER's data in a remote session (consent required, see the security boundary doc). Any OTHER version keeps the hide-local-surfaces treatment: its surfaces are hidden in remote sessions because they would read and write the **client machine's own** files; and because 0.19+ takes over the host's "Files" tab, an unbridged remote session has no sidebar file browser for now; file links in chat or deliverables open an empty right column in a remote session.
340
346
  - Both ends need zen-remote **2.0.x** (identical relay protocol version). DSH versions may differ; interface differences are annotated "(版本有差异)".
341
347
 
342
348
  ---
@@ -363,11 +369,11 @@ Troubleshooting: runtime logs are in `~/.dsh/logs/web.log` (gateway and push lin
363
369
 
364
370
  ## Third-party plugins with mobile support
365
371
 
366
- The mobile UI has specific adaptations for the plugins below. Every adaptation is anchored on that plugin's own DOM markers: if you don't have it installed the rules simply don't match, and installing a plugin that isn't listed here can't be caught in the crossfire.
372
+ The mobile UI has specific adaptations for the plugins below. Every adaptation is anchored on that plugin's own DOM markers; the plugin bridge is a separate lane — the plugin registers a declaration itself (see [the plugin bridge](docs/plugin-bridge.md)) and none of this table applies: if you don't have it installed the rules simply don't match, and installing a plugin that isn't listed here can't be caught in the crossfire.
367
373
 
368
374
  | Plugin | What the mobile adaptation does | Version tested |
369
375
  | --- | --- | --- |
370
- | [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | 0.19+: all its tabs live in DSH 0.1.5's native right sidebar, which the host takes full screen on a phone (padded clear of the notch / home indicator) — the session header keeps an entry button for it, the left-edge swipe-back closes it, and tapping an @-reference in the file tree closes it. ≤ 0.18 (own right panel, DSH < 0.1.5 only): full-width phone drawer with notch safe area and a centred close pill | 0.19.1 (legacy rules: 0.15.0) |
376
+ | [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | 0.19+: all its tabs live in DSH 0.1.5's native right sidebar, which the host takes full screen on a phone (padded clear of the notch / home indicator) — the session header keeps an entry button for it, the left-edge swipe-back closes it, and tapping an @-reference in the file tree closes it. ≤ 0.18 (own right panel, DSH < 0.1.5 only): full-width phone drawer with notch safe area and a centred close pill. **Plugin bridge**: exactly **0.25.0** is bridged by zen-remote’s built-in adapter — in remote sessions its file / git / task panels read and write the SERVER’s data (whole-machine data, per-plugin consent on the server’s settings page; that equals whole-disk access and git writes on the server, see the [security boundary](docs/remote-access.md)); any other version stays unbridged and keeps the hide-local-surfaces treatment | 0.19.1 (legacy rules: 0.15.0; adapter: 0.25.0) |
371
377
  | [@nanmicoder/dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams) | The AgentTeams activity overlay moves below the session header (its original position covered the header buttons) and hides itself on the session list; a subagent session keeps a tappable parent-session title in its header for jumping back | 0.1.9 |
372
378
  | [@ychris12138/dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats) | Usage and balance entries fold into the home-screen chips row | 0.2.9 |
373
379
  | [@opendsh/dsh-plugin-scheduled-tasks](https://github.com/Ceelog/dsh-plugins) | The scheduled-tasks entry folds into the home-screen chips row | 0.2.3 |
package/README.zh-CN.md CHANGED
@@ -90,7 +90,7 @@ dsh plugin --profile web add dsh-zen-remote
90
90
  // ~/.dsh/profiles/desktop/package.json
91
91
  {
92
92
  "dependencies": {
93
- "dsh-zen-remote": "^2.0.2" // 本地开发换成 "link:/path/to/dsh-zen-remote"
93
+ "dsh-zen-remote": "^2.2.0" // 本地开发换成 "link:/path/to/dsh-zen-remote"
94
94
  }
95
95
  }
96
96
  ```
@@ -242,6 +242,8 @@ cd ~/.dsh/profiles/desktop && pnpm install
242
242
  | `serverName` | 电脑名 | **设备名称**(≤ 40 字),两种角色共用:主服务端是子客户端分组标题里的显示名;子客户端是配对时注册的名字;与服务端双向自动同步 |
243
243
  | `idleHours` | `48` | 远程会话的闲置休眠时长(小时),范围 (0, 8760] |
244
244
  | `autoShareNewSessions` | 关 | 服务端新建的会话自动开启远程 |
245
+ | `pluginBridgeConsent` | `{}` | 插件桥的整机数据同意表:插件名 → 布尔。设置页「插件桥」小节管理,一般不用手改;表里未登记插件的残留键不清理 |
246
+
245
247
  | `serverUrl`(子客户端) | 空 | 服务端网关地址,按上文规则校验 |
246
248
  | `deviceToken`(子客户端) | 空 | 配对拿到的设备令牌;密钥字段,处处打码 |
247
249
  | `turnFoldDesktop` | 关 | 回合过程折叠在任意宽度生效(默认仅手机宽度) |
@@ -305,6 +307,10 @@ cd ~/.dsh/profiles/desktop && pnpm install
305
307
  - 远程会话标题行连接图标(在线 / 离线 / 版本有差异);会话被休眠或服务端关闭时显示「远程已关闭」及原因;服务端离线时分组变灰、输入禁用,后台自动退避重连
306
308
  - 审批/提问先到先得:服务端自己的界面先答了,子客户端自动同步成「已处理」
307
309
 
310
+ **两端(2.1 新增)**
311
+
312
+ - **插件桥**:其他插件登记一份桥接声明后,它的面板数据在远程会话里取自主服务端——子客户端本机把声明命中的 GET 请求改道到服务端,服务端以本机回环执行、流式交回,本机数据绝不混入远程会话。两端插件名、确切版本与声明一致才算已桥接;整机数据另需主服务端主人在设置页逐插件同意。接入指南见[插件桥](docs/plugin-bridge.md)
313
+
308
314
  深度说明:[界面](docs/interface.md) · [公网接入 + 中继协议](docs/remote-access.md)
309
315
 
310
316
  ---
@@ -335,8 +341,8 @@ cd ~/.dsh/profiles/desktop && pnpm install
335
341
  - 换到另一台服务端后再换回原来那台,或服务端删除某工作区后又以同一 id 重建:相关远程分组要**重载页面**才会再出现(DSH 侧边栏对移除过的工作区 id,在页面生命周期内不再接受)。
336
342
  - 远程会话里「导出会话」已禁用(菜单项隐藏,后台对虚拟会话 id 一律拒绝);改动面板的**摘要与 diff 可以看**(经中继转发),但「打开 / 在访达中显示 / 在应用中打开」这类会在服务端机器上弹窗的入口在远程会话里隐藏(交付物卡片同理)。
337
343
  - @ 引用候选由宿主**先截前 50 条再按共享过滤**:服务端会话很多时,已共享会话可能不出现在候选列表里(不影响引用检查本身——直接写出的引用仍会被逐条校验)。
338
- - 第三方插件自己的非标准接口不转发——它们的面板在远程会话里降级或隐藏。插件的**工具**跑在服务端,不受影响;受影响的是插件在界面上带着会话 id 调它自己后台接口的部分(dsh-better-sidebar 就是典型,已按下一条处理)。
339
- - 装了 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 时,远程会话里它的文件浏览、编辑器、git、任务、底部工作台等界面会被隐藏——它们读写的是子客户端**本机**的文件;又因为它接管了宿主的「文件」标签,远程会话里暂时没有侧栏文件浏览;聊天或交付物里的文件链接在远程会话里会打开一个空的右栏。
344
+ - 第三方插件自己的接口要经[插件桥](docs/plugin-bridge.md)才能在远程会话里工作:协作插件自行登记,第三方插件靠内置适配器。都没接的插件,其面板在远程会话里降级或隐藏(插件的**工具**跑在服务端,不受影响)。
345
+ - 装了 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 时,远程会话里它有两档处理:确切版本 **0.25.0** 由内置适配器桥接——面板读写的是主服务端的数据(整机数据,需在主服务端设置页逐插件同意,见[安全边界](docs/remote-access.md));**其他版本维持隐藏**——那些版本的面板读写的是子客户端**本机**的文件,且因为它接管了宿主的「文件」标签,未桥接的远程会话里侧栏文件 tab 显示「未桥接」说明占位;聊天或交付物里的文件链接在远程会话里会打开一个空的右栏。
340
346
  - 两端 zen-remote 需同为 **2.0.x**(中继协议版本一致);DSH 版本可以不同,接口指纹有差异时远程分组标注「(版本有差异)」。
341
347
 
342
348
  ---
@@ -363,12 +369,12 @@ cd ~/.dsh/profiles/desktop && pnpm install
363
369
 
364
370
  ## 已适配的第三方插件
365
371
 
366
- 移动端 UI 对下列插件做了专门适配。所有适配都锚定对应插件自己的 DOM 标记:
372
+ 移动端 UI 对下列插件做了专门适配。所有适配都锚定对应插件自己的 DOM 标记;插件桥另算一类——插件自己登记声明即可(见[插件桥](docs/plugin-bridge.md)),不依赖本表:
367
373
  没装该插件时规则不生效,装了未列出的插件也不会被误伤。
368
374
 
369
375
  | 插件 | 移动端适配内容 | 实测版本 |
370
376
  | --- | --- | --- |
371
- | [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | 0.19+:它的全部 tab 都在 DSH 0.1.5 官方右侧栏里、手机上宿主自己切全屏(避让刘海与底部指示条)——会话页头部留入口按钮,左缘回退手势收它,文件树里点 @ 引用后自动收起。≤ 0.18(自绘右栏,只在 DSH < 0.1.5 上):手机全宽抽屉并避让刘海安全区、底部居中的关闭按钮 | 0.19.1(老版规则 0.15.0) |
377
+ | [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | 0.19+:它的全部 tab 都在 DSH 0.1.5 官方右侧栏里、手机上宿主自己切全屏(避让刘海与底部指示条)——会话页头部留入口按钮,左缘回退手势收它,文件树里点 @ 引用后自动收起。≤ 0.18(自绘右栏,只在 DSH < 0.1.5 上):手机全宽抽屉并避让刘海安全区、底部居中的关闭按钮。**插件桥**:确切版本 **0.25.0** 由 zen-remote 的内置适配器桥接——远程会话里它的文件 / git / 任务面板读写的是主服务端的数据(整机数据,需在主服务端设置页逐插件同意;等于让桌面应用端读写服务端整盘并执行 git 写操作,见[安全边界](docs/remote-access.md));其他版本不桥接,远程会话维持「隐藏本机界面」的原有处理 | 0.19.1(老版规则 0.15.0;适配器 0.25.0) |
372
378
  | [@nanmicoder/dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams) | AgentTeams 活动浮层挪到会话头部下方(原位置压住头部按钮)、会话列表页自动隐藏;子代理会话头部保留可点的父会话标题,一键切回主会话 | 0.1.9 |
373
379
  | [@ychris12138/dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats) | 用量与余额入口收进主屏 chips 行 | 0.2.9 |
374
380
  | [@opendsh/dsh-plugin-scheduled-tasks](https://github.com/Ceelog/dsh-plugins) | 定时任务入口收进主屏 chips 行 | 0.2.3 |
@@ -0,0 +1,15 @@
1
+ # 插件桥转发插件自己的 HTTP 接口,主服务端以本机回环请求执行
2
+
3
+ 远程会话里要显示其他插件的面板时,插件桥不转发界面、也不定义统一的数据模型,而是把插件自己注册在 DSH webServer 上的接口请求,从子客户端改道到主服务端:子客户端在本机后台的路由查找处改道(覆盖 fetch、EventSource、iframe、Worker 等一切到达后台的 HTTP),主服务端校验会话共享后,以本机回环 HTTP 请求自己的插件接口。理由:面板界面两端同版本已经有,插件接口也已经存在,zen-remote 只需决定「请求在哪台机器执行」;回环请求让插件自己的鉴权与 handler 原样生效,不必伪造 node 的 req/res。
4
+
5
+ ## Considered Options
6
+
7
+ - 包装浏览器的 `window.fetch`:漏掉 iframe、Worker、EventSource 发出的请求。
8
+ - 主服务端进程内直接调插件 handler:插件 handler 是 node req/res 签名且自查 Host 与 cookie,伪造 IncomingMessage 比回环脆弱。
9
+ - 定义统一的插件数据模型、让插件把数据推给 zen-remote:要求每个插件重写数据层,第三方插件无法接入。
10
+
11
+ ## Consequences
12
+
13
+ - 协作插件只需登记接口清单,并在网址参数里带会话 id;不需要依赖 zen-remote 的代码。
14
+ - 不配合的第三方插件只能由 zen-remote 内部按确切版本编写可移除的适配器,负责请求与响应里会话 id 的改写。
15
+ - 子客户端的改道依赖宿主 webServer 的内部结构(路由查找方法、路由表),与 typertGateway、fetchRoutes 的包装同属「依赖 DSH 内部」的一类,需要形态检测。
@@ -0,0 +1,8 @@
1
+ # 中继不引入 WebSocket,插件的 WebSocket 用流式响应加普通请求模拟
2
+
3
+ 插件桥要转发 better-sidebar 的两条 WebSocket(「文件变动」与「智能体打开文件」)时,沿用中继的既有约定「单次调用走普通 HTTP 请求、订阅走 NDJSON 流式响应、不自己实现 WebSocket」:子客户端后台在本机接住浏览器的 WebSocket,服务端→浏览器方向走一条中继流式响应,浏览器→服务端的指令逐条走普通请求;主服务端用 Node 自带的 WebSocket 客户端连本机插件。理由:这两条 WebSocket 几乎是单向的,上行只有偶发的小指令,模拟成本低;让 WebSocket 真正穿过网关,要改网关对桌面应用端的放行规则,并给中继新增一种双向通道。
4
+
5
+ ## Consequences
6
+
7
+ - 若将来出现上行频繁或对延迟敏感的插件 WebSocket(如逐键交互),每条上行消息一次请求的模拟会成为瓶颈,届时应重新评估本决定。
8
+ - 2.2 的落地实现(PB-10)与上文「Node 自带的 WebSocket 客户端连本机插件」的设想不同:最终是**字节隧道,帧不解析**。子客户端把浏览器的升级请求头与双向字节原样搬运——服务端→浏览器一条 NDJSON 流式响应,浏览器→服务端按序分块的普通请求;服务端用收到的头向本机插件**手写升级请求**,之后双向字节各自原样穿过,中继与网关全程不解析 WebSocket 帧,帧与关闭语义由两端真正的 WebSocket 端点处理。「每条上行消息一次请求」的瓶颈因此不成立:上行按 64 KiB 块流水,与消息边界无关;协议形状见 `docs/remote-access.md` 的「插件桥的 WebSocket 字节隧道」一节。
package/docs/interface.md CHANGED
@@ -23,6 +23,8 @@ v1.0.0(fork 自 [mexiaosqwq/dsh-web-mobile](https://github.com/mexiaosqwq/dsh-
23
23
  - **经网关打开时只读**:手机或其它浏览器经网关打开设置页可以看,但生成配对码、改角色、吊销这些管理动作被拒绝(带 `x-zen-remote-via` 标记的请求一律拒绝 POST)——设备丢了也没法用它再配出新设备;
24
24
  - 角色判定读的是 configForms 行文档里的已保存角色;行文档答不上来时回退探测 `/_dsh/mobile-nav/client-config`(该路由两角色都注册,报的是合并后的生效角色)。
25
25
 
26
+ - **插件桥(2.1)**:远程共享组之后多一个小节「插件桥」——主服务端区块里逐插件列出已登记的名字与版本:只含会话数据的标注「随会话共享转发」;含整机数据的给开关「允许转发整机数据」(缺省关),旁注「打开后,已配对的桌面应用端可在远程会话里读取该插件的整机数据」。开关走与其它字段同一条 staged 表单通道(提交整张 `pluginBridgeConsent` 表,只动该插件的键),保存后列表自动重拉(数据来自 `GET /_dsh/zen-remote/admin/plugin-bridge`,经网关打开时拒绝)。子客户端区块的诊断组里逐插件一行「名字 · 本机版本 · 状态」,状态文案:已桥接 / 服务端未安装 / 版本不同(服务端 x.y.z)/ 声明不一致 / 服务端主人未允许整机数据 / 中继离线 / 服务端 zen-remote 版本过旧;本机改道未启用时另加一行「本机改道未启用:<原因>」。文案与判定逻辑都是纯函数(`src/client-data/plugin-bridge.ts`)。
27
+
26
28
  ### 主服务端:共享部件
27
29
 
28
30
  - **标题行远程图标**(`conversation.session.header.actions`,order 25,排在官方任务入口之后):灰色为未开启,亮色为已开启,亮色带小圆点表示此刻有桌面应用端正在查看;悬停显示剩余闲置时间;点击开关当前会话的远程,带二次确认。
@@ -364,7 +366,7 @@ composer 最左的回形针打开的是**手机本地**的文件选择器(iOS
364
366
  ## 兼容插件
365
367
 
366
368
  - [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar)(0.19+:它的全部 tab 都在 DSH 0.1.5 官方右侧栏里,手机上宿主自己切全屏,本插件在会话页头部给它留入口位、左缘回退手势收它、文件树里点 @ 引用后自动收起——面板全屏盖住会话页,草稿变化看不见,收起本身就是点击反馈;≤ 0.18 自绘右栏的老版本:全宽抽屉 + 安全区 + 底部关闭 pill,只在 DSH < 0.1.5 上还会走到)——本机实测 **0.19.1**(老版规则实测 0.15.0)
367
- - dsh-better-sidebar 的**远程会话处理**:它的界面(文件浏览、编辑器、git 变动、子代理/任务、底部工作台、任务编辑与任务输出浮窗)都带 `{sessionId, cwd}` 语境调它自己的 `/sidebar/api/*` 接口,远程会话里这些请求落到子客户端本机——文件树会显示本机同名文件、编辑会真的写本机文件。远程会话(`html[data-zr-remote-session="1"]`)里按插件自己的挂载/渲染标记隐藏这些界面,选择器在 `src/client/styles/remote-session.css.ts`,未安装时一条都不命中;桌面门前注入,桌面 App 形态的子客户端同样生效。因为它接管了宿主的「文件」标签,远程会话里暂时没有侧栏文件浏览;聊天或交付物里的文件链接(`dsh-resource://file/**`)会被它的编辑器注册抢先接住,展开一个空的右栏(根治要等 zen-remote 自己的远程文件树)。定位是**隐藏而非代理**:中继不携带也不新增 `/sidebar` 路由;插件组件保持挂载并继续静默发只读请求,但所有写控件都在被隐藏的界面里,不会误写本机
369
+ - dsh-better-sidebar 的**远程会话处理**(PB-12 起分两档):确切版本 **0.25.0** 由 zen-remote 的内置适配器(`src/adapters/dsh-better-sidebar/`)**代理**——它的文件浏览、编辑器、git 变动、任务等界面在远程会话里读写的是主服务端的数据(整机数据,需主服务端主人在设置页同意;`open.external` 远程拒绝、`settings.*` 与 `/sidebar/bundle` 留本机、侧聊转发(子会话继承父会话共享))。**其他版本维持隐藏**:那些版本的界面都带 `{sessionId, cwd}` 语境调它自己的 `/sidebar/api/*` 接口,远程会话里这些请求落到子客户端本机——文件树会显示本机同名文件、编辑会真的写本机文件;远程会话(`html[data-zr-remote-session="1"]`)里按插件自己的挂载/渲染标记隐藏这些界面,选择器在 `src/client/styles/remote-session.css.ts`,未安装时一条都不命中。因为它接管了宿主的「文件」标签,未桥接的远程会话里暂时没有侧栏文件浏览;聊天或交付物里的文件链接(`dsh-resource://file/**`)会被它的编辑器注册抢先接住,展开一个空的右栏(根治要等 zen-remote 自己的远程文件树)。隐藏档的定位仍是**隐藏而非代理**:插件组件保持挂载并继续静默发只读请求,但所有写控件都在被隐藏的界面里,不会误写本机。PB-14 起显示规则按桥接状态生效:已桥接(0.25.0 且主服务端同意)时远程会话里**照常显示**(面板数据经适配器来自主服务端,隐藏规则全部让位);未桥接时维持隐藏,已打开的 tab 空态改为一行说明占位(better-sidebar 未在远程会话桥接——中英双语,随浏览器语言),0.25.0 的空白工作台浮动按钮一并隐藏
368
370
  - [@nanmicoder/dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams)(AgentTeams 活动浮层:手机端挪到会话头部下方、避开安全区,会话列表页隐藏;子代理会话头部保留可点的父会话面包屑用于切回)——本机实测 **0.1.9**
369
371
  - [@ychris12138/dsh-usage-stats](https://github.com/Ychris12138/dsh-usage-stats)(用量与余额,主屏 chips 行可直接打开;旧包名 `dsh-usage-stats` 已废弃,两个名字同时留在 profile 里会因重复的 `usage-stats` 行让 DSH 起不来)——本机实测 **0.2.9**
370
372
  - [@opendsh/dsh-plugin-scheduled-tasks](https://github.com/Ceelog/dsh-plugins)(定时任务,主屏 chips 行自动收割入口)——本机实测 **0.2.3**
@@ -0,0 +1,140 @@
1
+ # 插件桥(给插件作者)
2
+
3
+ 你的插件只要**登记一份桥接声明**,它的面板数据就能在远程会话里照常显示:子客户端把「该插件的某次 GET 请求」改道到主服务端,主服务端用本机回环 HTTP 请求你插件的接口,把响应原样交回浏览器。你不需要依赖 zen-remote 的任何运行时——没装 zen-remote 时,`inject` 永远不触发,你的插件行为与从前完全一致。
4
+
5
+ 什么时候用:你的插件在**会话页**里渲染自己的面板,面板数据来自插件自己注册在 DSH webServer 上的 GET 接口,且数据按会话区分(或明确属于整机数据)。装了 zen-remote 的两台 DSH 之间,远程会话里的面板将取**服务端**的数据,而不是子客户端本机的空数据。
6
+
7
+ > 术语见 `GLOSSARY.md`:已桥接、桥接声明、整机数据。
8
+
9
+ ## 接入
10
+
11
+ 在插件的 `apply` 里向 zen-remote 登记一份声明,dispose 挂进 `effect`(行卸载时撤下):
12
+
13
+ ```ts
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import type { BridgeDeclaration, ZenRemoteService } from 'dsh-zen-remote'
16
+
17
+ export function apply(ctx: Context) {
18
+ ctx.inject(['zenRemote'], (c) => {
19
+ const bridge: ZenRemoteService = c.zenRemote
20
+ const declaration: BridgeDeclaration = {
21
+ plugin: '@scope/my-plugin', // 必须与 package.json 的 name 一致
22
+ version: '0.2.0', // 必须与运行版本一致
23
+ routes: [
24
+ { path: '/my-plugin/session-data', scope: 'session' },
25
+ { path: '/my-plugin/all-tasks', scope: 'server' },
26
+ ],
27
+ }
28
+ const dispose = bridge.bridge(declaration)
29
+ ctx.effect(() => dispose, 'my-plugin: bridge declaration')
30
+ })
31
+ }
32
+ ```
33
+
34
+ - 类型从主包拿:`import type { BridgeDeclaration, ZenRemoteService } from 'dsh-zen-remote'`。
35
+ - 没装 zen-remote:`inject(['zenRemote'])` 不触发,以上代码零影响;装了旧版 zen-remote:同样不触发(服务是 2.1 起提供)。
36
+ - 两端都要登记:zen-remote 判定「已桥接」要求**两端插件名、确切版本、声明摘要**一致(声明摘要 = 对规范化声明做 sha256 取 16 位十六进制;路由顺序不影响摘要,任何字段值影响)。两端不一致时面板拿到 503(见下文),不会落到本机数据。
37
+
38
+ ## 声明字段表
39
+
40
+ `BridgeDeclaration = { plugin, version, routes }`,校验失败登记时直接抛错:
41
+
42
+ | 字段 | 规则 |
43
+ | --- | --- |
44
+ | `plugin` | 非空字符串;npm 包名形状(允许 `@scope/name`,全小写);长度 ≤ 214 |
45
+ | `version` | 非空字符串(原样比较,不做 semver 解析) |
46
+ | `routes` | 非空数组,最多 64 条 |
47
+
48
+ 每条 `BridgeRoute`:
49
+
50
+ | 字段 | 规则 |
51
+ | --- | --- |
52
+ | `path` | 以 `/` 开头;不含 `?`、`#`、`..`、空白;**不得**以 `/_dsh/` 或 `/api` 开头(宿主与 zen-remote 自己的前缀不能被桥接);同一声明内不得重复 |
53
+ | `scope` | 必填,只能是 `'session'`(数据属于单个会话)或 `'server'`(整机数据,需主服务端主人逐插件同意) |
54
+ | `sessionParam` | 可选,缺省 `'sessionId'`;须匹配 `/^[A-Za-z][A-Za-z0-9_]{0,63}$/`——你的接口用别的名字接会话 id 时在这里声明 |
55
+ | `stream` | 可选,缺省 `false`;标记该路由以流式响应回答 |
56
+ | `methods` | 可选,缺省 `['GET']`;大写 HTTP 方法数组,只接受 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`,去重并按字典序排序后进规范化与摘要(2.2 起,写请求) |
57
+ | `maxBody` | 可选,缺省 `1048576`(1 MiB);请求体上限(字节),正整数,最高 64 MiB(2.2 起) |
58
+ | `upgrade` | 可选;置 `true` 标记该路由是 WebSocket 升级端点,远程会话里经字节隧道转发(2.2 起,见下文「WebSocket 路由」)。只能为 `true` 或省略;不能与 `stream` 同用;升级路由只有 GET——显式 `methods` 里出现其他方法登记即报错 |
59
+
60
+ 声明里出现未知字段直接报错(防拼错)。`methods`、`maxBody` 与 `upgrade` 只在**显式给出**时进摘要——2.1 时代(没有这三个字段)的声明,摘要与当年完全一致,升级不破坏已桥接状态。2.1 只匹配 **exact 路径**:声明的 `path` 与请求 pathname 完全相等才算命中,把 `path` 写成前缀不会覆盖子路径。
61
+
62
+ ## 你的插件要守的约定
63
+
64
+ - **请求 query 带当前会话 id**:浏览器侧对 `scope:'session'` 路由的请求必须带 `sessionParam`(缺省 `sessionId`)参数;改道时 zen-remote 靠它找回真实会话 id。没有该参数的请求不算远程请求,会落到本机。
65
+ - **服务端按会话 id 解析上下文,不信客户端路径**:同一份面板代码在两台机器上各有一个实例,你的接口拿到的会话 id 永远是**服务端**的会话;不要假设「请求来自本机浏览器」。
66
+ - **响应不改写**:响应体原样交回浏览器。不要在响应里携带自己的本机地址或让浏览器再拿响应里的会话 id 去发第二个请求——第二个请求是否改道由它自己的参数决定,跨请求拼链路是远程面板最常见的坏法。
67
+ - **侧切换重开流**:`scope:'session'` 的流式响应绑定当前会话;用户把面板切到另一个会话时重新发起请求,不要复用旧流。
68
+
69
+ ## 未桥接时浏览器看到什么
70
+
71
+ 远程会话里、声明命中的路由上,任何导致「无法转发」的情形都回答**同一个 503 形状**,且**绝不落到本机插件**(本机数据不会混进远程会话):
72
+
73
+ ```
74
+ HTTP/1.1 503
75
+ x-zen-remote: unbridged
76
+ content-type: application/json; charset=utf-8
77
+
78
+ {"ok":false,"error":{"code":"zr-plugin-unbridged","reason":"<reason>","plugin":"<插件名>"}}
79
+ ```
80
+
81
+ `reason` 的全部取值:
82
+
83
+ | reason | 含义 |
84
+ | --- | --- |
85
+ | `not-on-server` | 服务端没登记这个插件 |
86
+ | `version-mismatch` | 两端版本不同(升级某端后未重启行) |
87
+ | `declaration-mismatch` | 版本相同但声明摘要不同 |
88
+ | `consent-off` | 声明含 `scope:'server'` 路由,且主服务端主人未同意(设置页「插件桥」里逐插件打开) |
89
+ | `offline` | 中继离线(或传输失败) |
90
+ | `server-too-old` | 服务端 zen-remote 过旧,握手没有插件表 |
91
+ | `server-changed` | 会话 id 指向另一台服务端(切换服务端后页面未刷新) |
92
+ | `bad-query` | 会话参数重复,或虚拟会话 id 解析失败(浏览器侧缺失 / 为空的请求走本地放行,到不了 503) |
93
+ | `method-not-bridged` | 请求方法不在该路由声明的 `methods` 内(未声明即只转发 GET) |
94
+ | `not-shared` / `busy` / `plugin-unknown` / `plugin-mismatch` / `route-undeclared` / `loopback-auth` / `internal` | 服务端中继路由的对应拒绝原样透传(会话未共享 / 该设备并发超限 / 服务端无此登记 / 版本或摘要不符 / 路径未声明 / 服务端回环鉴权失败 / 服务端内部错误) |
95
+
96
+ ## 限制
97
+
98
+ - 2.1 只转发 **GET**;2.2 起路由可声明 `methods` 开放写方法(POST 等),请求体经中继流式转发,受该路由 `maxBody` 约束(缺省 1 MiB)——超限在本地即回答 413,不落本机、不到服务端。
99
+ - 只匹配 **exact 路径**;前缀声明不生效。
100
+
101
+ ## WebSocket 路由(2.2 起)
102
+
103
+ 路由声明 `upgrade: true` 后即成为 WebSocket 升级端点:远程会话里浏览器对该路径的 `new WebSocket(...)` 由子客户端本机接住,经中继的**字节隧道**接到服务端本机插件——升级请求头(`sec-websocket-key` / `sec-websocket-version`,及可选的 `sec-websocket-protocol` / `sec-websocket-extensions`)与双向字节各自**原样搬运,两端都不解析 WebSocket 帧**:101 响应、每个帧、关闭字节都是不透明字节,真正的握手由插件路由在服务端本机完成。三路由的协议形状见 `docs/remote-access.md` 的「插件桥」一节;决策记录见 `docs/adr/0002`。
104
+
105
+ 浏览器侧没有新接口:拿到的仍是原生 `WebSocket`,感知不到隧道。升级无法转发时(未桥接、会话不可达等原因)浏览器看到的就是一次握手失败——子客户端在 socket 上回答与 HTTP 改道相同的 503 `zr-plugin-unbridged` 形状(reason 取值见上表),表现为 `error` / `close` 事件。
106
+
107
+ 限制:
108
+
109
+ - 声明侧:`upgrade` 只能为 `true` 或省略,不能与 `stream` 同用,升级路由只有 GET(见字段表);
110
+ - 上行字节按最大 64 KiB 分块、严格按序转发(服务端按序号把关,乱序即断);下行字节每行最多 48 KiB,走一条 NDJSON 流;
111
+ - 每设备并发隧道上限 8;
112
+ - 隧道随浏览器 socket 断开即关闭;服务端一侧的字节流断掉时浏览器 socket 直接切断——字节隧道不代拟关闭帧,插件自己发的关闭字节(若有)已作为数据先行到达。
113
+
114
+ 相关阅读:主服务端一侧的路由协议见 `docs/remote-access.md` 的「插件桥」小节;设置页的同意开关与状态文案见 `docs/interface.md`。
115
+
116
+ ## 适配器(zen-remote 内部)
117
+
118
+ 协作声明是插件作者对自己的接口登记;**适配器**是 zen-remote 为**不配合的第三方插件**写的内部转发模块,按目标插件的**确切版本**编写,负责把该插件请求与响应里的会话 id 在两端改写(术语见 `GLOSSARY.md` 的「适配器」)。它不是对外接口:插件作者不需要、也无法登记适配器;适配器随 zen-remote 本体分发,整块可移除(`src/adapters/` 目录删掉后,其余功能一字不改照常工作,由 `test/adapters-pluggable.test.cjs` 钉住)。
119
+
120
+ 一个适配器声明三类信息:
121
+
122
+ - **路由与定位**:每条路由给 `path`(exact 或前缀匹配)、可转发的方法与体上限,以及 `locator`——会话 id 在请求里的位置(query 参数 / 前缀后的路径段 / JSON 体顶层字段)。`local: true` 的前缀(如静态资源)永远留本机,不参与改道。
123
+ - **远程判定**:定位到的会话 id 全是 `zr~` 虚拟 id → 远程转发;全不是(或一个都没定位到)→ 本地放行;**混合 → 503 `bad-query`**,绝不落本机。
124
+ - **两个钩子(可选)**:子客户端侧 `client.request`(拒绝或改写 JSON 体,如在转发前拒绝 `open.external`)与 `client.response`(把响应里的真实会话 id 换回虚拟 id,JSON 响应缓冲上限 8 MB,超限原样直通);主服务端侧 `server.request`(回环前拒绝或剥字段,如剥掉 `cwd`)。
125
+
126
+ 登记规则:适配器按宿主 `pluginPackages` 服务探测目标插件的**已装版本**,版本不在适配器声明的版本清单里就不登记;登记进与协作声明同一张注册表(版本 = 已装版本,摘要 = 适配器路由表的规范化哈希),握手同一个 `plugins` 字段,整机数据的同意开关逐插件生效(默认关,设置页条目标注「由 zen-remote 适配」)。若某插件既有适配器、自己又登记了协作声明,**以插件自己的声明为准**(适配器让位,记一条日志)。
127
+
128
+ ## 开发一个适配器
129
+
130
+ 先确认目标插件真的需要适配器:能让作者加几行 `zenRemote.bridge(...)` 就优先走协作声明——适配器只在第三方插件不配合时使用。当前唯一在册的适配器是 **`dsh-better-sidebar@0.25.0`**(`src/adapters/dsh-better-sidebar/`),新适配器照它写。
131
+
132
+ 步骤:
133
+
134
+ 1. **摸清接口面**:读目标插件的构建产物(`~/.dsh/profiles/<profile>/node_modules/<包>/lib/`),列出全部 HTTP 路由与 WebSocket:每个路由的方法、会话 id 在哪个字段(query / 路径段 / JSON 体)、读还是写、响应里有没有会话 id、有没有不带会话字段的调用。每个结论带行号。带会话 id 的请求体会不会另外携带本机路径(如 `cwd`)也要核实——主服务端要按会话自己解析,客户端传来的路径必须剥掉。
135
+ 2. **写适配器**:`src/adapters/<插件名>/` 一个目录,`versions` 只写核对过的确切版本;路由表里**表外方法一律拒绝**(宁可少转发,不可误转发);需要在「无会话字段」的情况下按当前会话拒绝的入口(如 better-sidebar 的 `open.external`)用 `deny` 钩子 + 浏览器上报的当前会话信号。适配器一律按整机数据对待(主人逐插件同意,默认关)。
136
+ 3. **登记**:`src/adapters/index.ts` 的 `ADAPTERS` 加一行,这是整个目录与框架之间的唯一接线。
137
+ 4. **测试**:方法表与目标插件源码集合的对照测试(源码缺失时 skip)、每种定位的改写、响应改写、版本漂移不登记、e2e 至少读写各一条全链路。可插拔的静态扫描与空表回归由 `test/adapters-pluggable.test.cjs` 自动覆盖新适配器。
138
+ 5. **文档**:两份 README 的兼容清单、`docs/interface.md`、安全边界(整机数据时写明等于什么权限)同步更新。
139
+
140
+ 路由匹配:协作声明只支持 **exact 路径**;适配器路由额外支持**前缀匹配**(`match: 'prefix'`,会话 id 在路径段时按段定位)。
@@ -327,8 +327,65 @@ The `/v1/http` route is a second allowlist, keyed by **route name** (not part of
327
327
  - `dsh-session:` references are scanned too: DSH injects the referenced session's content along **messages** (the content text blocks of `session/prompt` / `subagents/prompt`), **queue edits** (`session/updateQueue`'s edit content) and **slash commands** (EVERY string of a `commands/execute` call — a command handler steers its raw input in as the next user message), with no access check of its own at injection time, so a call whose reference names an unshared session is refused server-side with 403; the @-mention candidates (`sessionReferenceResolver/candidates`) likewise only ever list shared sessions' rows;
328
328
  - Terminals are deliberate (a desktop client is a trusted device): what opens is a **server-side** PTY running as the server user, without the agent sandbox or approval restrictions; closing the session's remote access kills its terminal streams with it;
329
329
  - `workspaceFiles/read` and friends are not contained to the session directory (DSH itself does not contain them) — anything the server process can read is readable, under the same trusted-device premise as terminals;
330
+ - The plugin bridge's whole-machine data requires the server owner's per-plugin consent — the toggles in the settings page's Plugin bridge section, everything OFF by default; the loopback carries the operator's cookie, so a bridged plugin sees the same permissions the local browser sees. The bridge's boundary is therefore the DECLARED route set (exact equality) plus the share table — there is **no** parameter-level scanning;
331
+ - **Adapters (better-sidebar)**: beyond cooperative declarations, zen-remote ships a built-in adapter for `dsh-better-sidebar` **0.25.0** (`src/adapters/` — non-cooperative third-party plugins are bridged by it, with prefix/path-segment route matching and session-id rewriting in JSON bodies and path segments). **Consenting to its whole-machine toggle hands the paired desktop client whole-disk file read/write on the server, git write operations (commit, push, revert, …), and model spend through commit-message generation** — the product decision is "both directions forward, no path fencing", and a server owner should treat that toggle as terminal-grade trust in the paired machine; `open.external` (launching processes on the host) refuses for remote sessions, `settings.*` and `/sidebar/bundle` stay on the sub-client's own machine; the two WebSockets (`/sidebar/ws/fs-watch` file-tree watch, `/sidebar/ws/agent-opens` open push) bridge over the byte tunnel (frames pass through untouched — the browser never reads the session id inside a push); side chat (`sidechat.*`) forwards — its child sessions carry `meta.parentSession`, so they inherit the parent's share through the ancestor chain, and the `childId` in `sidechat.start`'s answer is virtualized before it reaches the browser;
330
332
  - Concurrent streams are budgeted per device (32), and so are invokes kept in flight at once — past the per-device cap the invoke answers 429 `too-many-invokes`. Error codes shaped `*/internal` (`gateway/internal` and friends) are returned as the bare code with no message — that kind of message quotes server-side internals. Event answers are matched against the subscription and device they were forwarded to. Approval/question forwarding is **first come, first served**: whichever of the server's own UI and the sub-client answers first wins; the later answer is refused by the gateway and the client syncs to "already handled". The relay **never answers on the client's behalf**: an unshared session's approval/question events are not forwarded at all, and with the server's own UI offline too, the approval simply keeps waiting — the gateway re-delivers still-pending events to the next `$events` subscriber. A wait, never a rejection (an abstention would fail every pending approval of the session at once and break the push → wake → approve flow). Notification-type events (EMIT frames: session-list summaries, account expirations and other server-wide state) of an unshared session are never forwarded.
331
333
 
334
+
335
+ ### Plugin bridge: `POST /v1/plugin`
336
+
337
+ A bridged plugin's panel request, rerouted by the sub-client's local backend (the `match` wrap — see `docs/plugin-bridge.md`), arrives here: coordinates in headers, **no body** (a body answers 400).
338
+
339
+ | Header | Meaning |
340
+ | --- | --- |
341
+ | `x-zr-plugin` / `x-zr-plugin-version` / `x-zr-plugin-digest` | Plugin name, the caller's plugin version, declaration digest |
342
+ | `x-zr-path` | The plugin route path (no query), verbatim, never decoded |
343
+ | `x-zr-query` | The query string (no `?`; the session parameter already carries the REAL id) |
344
+ | `accept`, `range` | The only request headers passed through to the plugin |
345
+
346
+ **Validation chain** (first failure answers, with the `x-zen-remote-error: <code>` header): gateway secret and markers → body must be empty → header shapes → the plugin is in THIS server's registry → version AND digest equal the local declaration → `x-zr-path` matches a declared route EXACTLY (exact-only in 2.1) → the query is parsed once and the route's declared `sessionParam` appears exactly once, non-empty → the session passes the share table (ancestor chain included) → a `scope:'server'` route requires `pluginBridgeConsent[plugin] === true` (read live off the row) → the per-device budget. On success the query is RE-SERIALIZED from the parsed result via `URLSearchParams` — what was checked is exactly what is sent.
347
+
348
+ **Error codes and statuses**:
349
+
350
+ | code | status | condition |
351
+ | --- | --- | --- |
352
+ | `unsupported` | 501 | the bridge is not wired on this server |
353
+ | `bad-request` | 400 | a body was sent; missing or malformed coordinate headers |
354
+ | `plugin-unknown` | 404 | the plugin is not in the local registry |
355
+ | `plugin-mismatch` | 409 | version or digest differs from the local declaration |
356
+ | `route-undeclared` | 404 | the path matches no declared route exactly |
357
+ | `bad-query` | 400 | the session parameter is missing, repeated, or empty |
358
+ | `not-shared` | 403 | the session is not reachable |
359
+ | `consent-off` | 403 | a whole-machine-data route without the owner's consent |
360
+ | `busy` | 429 | the device already holds 8 in-flight bridged requests |
361
+ | `loopback-auth` | 502 | the loopback cookie exchange failed (no token URL, no cookie obtained) |
362
+ | `internal` | 502 | any other loopback failure (headers not sent yet; a mid-stream failure cuts the response) |
363
+
364
+ **Loopback execution**: the server requests its OWN plugin route over loopback HTTP (`127.0.0.1:<webServer.port>`), Host header pinned to the loopback address — the plugin's own authentication and handler run exactly as they would for a local browser. The loopback request carries the OPERATOR's cookie: with none cached, one `GET /?token=<server token>` exchanges for it (no redirects followed; the pair comes off the 3xx `set-cookie`), and a 401 from the plugin route triggers exactly one re-exchange before a retry — a still-401 answer travels verbatim. **The loopback carries the operator's cookie, so the plugin sees the same permissions the local browser sees** — by design: the bridge replaces "the browser opening the panel locally", it does not bypass the plugin's own auth. The cookie lives in memory only — never logged, never persisted. Since 0.2.1 the webServer may serve self-signed https: certificate verification is skipped ONLY when dialing the literal `127.0.0.1`.
365
+
366
+ **Streaming and budget**: the plugin answer's status plus its `content-type`, `content-length` (when present) and `cache-control` travel verbatim; the body is pumped chunk by chunk with real backpressure (a slow client slows the loopback, nothing buffers); the finish follows the relay stream's drain watchdog (default 5 s — a connection that stops reading is destroyed). At most 8 in-flight bridged requests per device (one slot held for the whole stream), 429 `busy` past that; a finish that cannot drain is destroyed after 5 seconds and its slot freed, while a client that stops reading MID-stream simply pins up to that device's 8 slots (everything else flows); the sub-client hanging up aborts the loopback request.
367
+
368
+ **Write requests (2.2+)**: the method arrives in the `x-zr-method` header (default GET); `GET`/`HEAD` with a body still answers 400, and any other method's body IS the plugin request body (raw byte stream). The size gate is the declared `Content-Length` first, a streaming count as the fallback — past the route's `maxBody` the answer is 413 `body-too-large`; a method the route did not declare answers 405 `method-undeclared`; the passthrough whitelist gains `content-type` accordingly.
369
+
370
+ ### The plugin bridge's WebSocket byte tunnel: `POST /v1/plugin-tunnel/{open,send,close}`
371
+
372
+ Routes declared `upgrade: true` travel over these three (the declaration side is in `docs/plugin-bridge.md`). The relay **never parses WebSocket**: the server→browser direction is **one NDJSON streaming response**, the browser→server direction is **ordered chunked POSTs**, and both directions are opaque bytes — the real upgrade handshake (the 101 and every frame) is produced by the plugin route on the server machine and crosses the tunnel untouched (`docs/adr/0002`).
373
+
374
+ | Route | Headers | Body | Success answer |
375
+ | --- | --- | --- | --- |
376
+ | `/v1/plugin-tunnel/open` | The same coordinate headers as `/v1/plugin` | A small JSON: `{"headers":{"sec-websocket-key":…,"sec-websocket-version":…,"sec-websocket-protocol"?,"sec-websocket-extensions"?},"head"?}` — the four ws headers travel verbatim into the upgrade request (each passes a CR/LF check); `head` is the already-arrived early bytes, base64, optional | 200 NDJSON stream (shape below) |
377
+ | `/v1/plugin-tunnel/send` | `x-zr-tunnel` (the tunnel id from the open line), `x-zr-seq` (strictly increasing from 0) | Raw bytes, ≤ 64 KiB | `{"ok":true}` — sent only after the plugin's socket took the bytes (backpressure: a stalled plugin slows THIS send) |
378
+ | `/v1/plugin-tunnel/close` | `x-zr-tunnel` | none | `{"ok":true}`, idempotent (an unknown id still answers ok) |
379
+
380
+ The open route's validation chain is **entirely shared** with `/v1/plugin` (registry → version and digest → exact route → parse-once query → share table → consent wall; same order, same codes), plus one wall of its own: the route must ALSO be declared `upgrade: true` — behind an ordinary route there is no WebSocket endpoint to dial, so 404 `route-undeclared`. On success the relay dials the local web server ITSELF (net/tls, loopback only; self-signed https verification is relaxed only for the literal `127.0.0.1`), hand-writes the upgrade request from the four received headers plus the operator's cookie (no Origin), and pumps the socket's bytes into the NDJSON stream line by line:
381
+
382
+ - The first line `{"type":"open","tunnel":"<32 hex chars>"}` is the client's signal to stop HTTP-ing and start reading; a dial failure does not drop the connection — it becomes an `end` line before the stream closes;
383
+ - `{"type":"data","b64":"…"}` carries upstream bytes (base64), at most 48 KiB of **raw** bytes per line (base64 inflates by 4/3 — the cap is on the raw bytes);
384
+ - `{"type":"ping"}` heartbeat lines (default one per 15 s) keep reverse proxies from cutting an idle stream; the slow-reader discipline matches the relay streams — a client that stops reading pauses the UPSTREAM socket, and this process never buffers for a slow reader;
385
+ - The final line `{"type":"end","reason":"upstream-closed"|"upstream-error"|"client-closed"}` precedes the stream close; close bytes the plugin sent (if any) already travelled as data lines — no WebSocket close frame is synthesized.
386
+
387
+ The open body caps at 1 MiB (413 past it); each device holds at most 8 concurrent tunnels, 429 `busy` past that. A tunnel belongs to the device that opened it (`x-zen-remote-device`): another device's send/close answers 404 `tunnel-unknown`; a send whose `x-zr-seq` is not the expected sequence answers 409 `tunnel-out-of-order` and **closes the tunnel immediately** — a broken ordering contract makes the stream untrustworthy. Beyond these tunnel-specific codes, every other refusal matches the `/v1/plugin` error table (the validation chain is shared).
388
+
332
389
  ---
333
390
 
334
391
  ## Push notes
@@ -123,6 +123,12 @@ server {
123
123
  # 长连接/流式响应建议关掉缓冲,避免响应被攒着不发
124
124
  proxy_buffering off;
125
125
  proxy_read_timeout 3600s;
126
+
127
+ # 必须加:nginx 默认 client_max_body_size 只有 1m,而中继的 JSON
128
+ # invoke(远程会话带图消息,上限 32MiB)和附件二进制上传
129
+ # (relay/v1/upload,上限 100MiB)都远超——缺了这条,文字消息正常、
130
+ # 一发图片就被掐断,子客户端界面报一个没有内容的错误(实测 2026-10-10)。
131
+ client_max_body_size 128m;
126
132
  }
127
133
  }
128
134
  ```
@@ -338,8 +344,65 @@ sudo cloudflared service install # 通了再装成常驻服务
338
344
  - `dsh-session:` 会话引用也会被扫描:DSH 会在**发消息**(`session/prompt` / `subagents/prompt` 的 content 文本块)、**改写排队消息**(`session/updateQueue` 的 edit 内容)与**斜杠命令**(`commands/execute` 参数里的全部字符串——命令处理器会把原始输入拼进下一条用户消息)这三条路径上注入被引用会话的内容,且注入时不再做权限检查,所以引用指向未共享会话的调用在服务端直接 403;@ 引用候选(`sessionReferenceResolver/candidates`)的答案也只放行已共享会话的行;
339
345
  - 终端是有意开放的(桌面应用端是可信设备):开的是**服务端**的 PTY,以服务端用户身份运行、不受智能体沙箱与审批限制;关闭会话的远程时终端流一并终止;
340
346
  - `workspaceFiles/read` 等文件预览接口不限制在会话目录内(DSH 自身就不限制),服务端进程可读的文件都能读——信任前提与终端相同;
347
+ - 插件桥的整机数据需要主服务端主人逐插件同意——设置页「插件桥」里的开关,缺省全部关闭;回环执行带操作者 cookie,插件接口看到的权限与本机浏览器相同,因此桥接的边界就是声明登记的路径(exact 相等)加会话共享表,**不做**参数级扫描;
348
+ - **适配器(better-sidebar)**:协作声明之外,zen-remote 为 `dsh-better-sidebar` **0.25.0** 内置了一个适配器(`src/adapters/`,不配合的第三方插件由它代为桥接,路由按前缀/路径段匹配、会话 id 在 JSON 体与路径段里改写)。**同意它的整机数据开关,等于让已配对的桌面应用端读写主服务端整盘文件、执行 git 写操作(提交、推送、回滚……),并通过提交信息生成消耗主服务端的模型额度**——产品上的决定就是「读写都接、不加路径围栏」,主服务端主人应当把它当成给对方一台本机终端级别的信任来开;`open.external`(在宿主机起进程)对远程会话拒绝,`settings.*` 与 `/sidebar/bundle` 留在子客户端本机;两条 WebSocket(`/sidebar/ws/fs-watch` 文件树监视、`/sidebar/ws/agent-opens` 打开推送)经字节隧道桥接(帧原样透传,浏览器不消费推送里的会话 id);侧聊(`sidechat.*`)转发——侧聊子会话带 `meta.parentSession`,按祖先链自动继承父会话的共享,`sidechat.start` 响应里的 `childId` 会换算成虚拟 id 回给浏览器;
341
349
  - 每台设备并发流数有上限(32)、同时在途的 invoke 数也有上限(32,超出 429 `too-many-invokes`);`*/internal` 形状的错误码(`gateway/internal` 等)只回码不带消息——那类消息引用的是服务端内部细节。事件应答按「转发时的订阅 + 设备」核对归属。审批/提问的转发是**先到先得**:服务端自己的界面和子客户端谁先应答谁生效,后答的一方被网关拒绝、同步成「已处理」。中继**不代答**:未共享会话的审批/提问事件不会被转发,服务端界面也不在线时这个审批就一直等待——网关会把仍未应答的事件转交给下一个连上来的订阅,等,而不是替你拒绝(代答会把整批待审批一次性判死,砸掉「推送 → 唤醒 → 审批」链路)。未共享会话的**通知类**事件(EMIT 帧:会话列表摘要、账号到期之类的服务端全局状态)一律不转发;唯一的例外是已共享会话(含其子会话)的 `api-session/status`——它只描述单个会话的运行/空闲,逐帧按共享表判定,子客户端靠它在主机侧一轮开始或结束时同步运行状态徽标(断线期间的翻转由重连后补拉列表纠正)。
342
350
 
351
+
352
+ ### 插件桥:`POST /v1/plugin`
353
+
354
+ 已桥接插件的面板请求改道(子客户端本机后台的 `match` 包装,见 `docs/plugin-bridge.md`)到达服务端后,走这条路由:坐标在请求头,**无请求体**(有体 400)。
355
+
356
+ | 请求头 | 含义 |
357
+ | --- | --- |
358
+ | `x-zr-plugin` / `x-zr-plugin-version` / `x-zr-plugin-digest` | 插件名、子客户端的插件版本、声明摘要 |
359
+ | `x-zr-path` | 插件路由路径(不含 query),原样不解码 |
360
+ | `x-zr-query` | query 字符串(不带 `?`,会话参数已是真实 id) |
361
+ | `accept`、`range` | 唯二透传给插件的请求头 |
362
+
363
+ **校验次序**(任一失败即返回,响应带头 `x-zen-remote-error: <code>`):网关密钥与标记 → 请求体必须为空 → 坐标头形状 → 插件在本机注册表 → 版本与摘要都等于本机 → `x-zr-path` 与声明路由**完全相等**(2.1 只有 exact 语义)→ query 只解析一次、会话参数(该路由声明的 `sessionParam`)恰好一个且非空 → 会话过共享表(含祖先链)→ `scope:'server'` 路由需 `pluginBridgeConsent[plugin] === true`(现场读行配置)→ 每设备并发预算。通过后用 `URLSearchParams` 把**解析结果重新序列化**成 query 再发出——校验的与发出去的是同一个字符串。
364
+
365
+ **错误码与状态**:
366
+
367
+ | code | 状态 | 条件 |
368
+ | --- | --- | --- |
369
+ | `unsupported` | 501 | 服务端未接线插件桥 |
370
+ | `bad-request` | 400 | 带请求体;坐标头缺失或不成形 |
371
+ | `plugin-unknown` | 404 | 本机注册表无此插件 |
372
+ | `plugin-mismatch` | 409 | 版本或摘要与本机声明不等 |
373
+ | `route-undeclared` | 404 | 路径不精确等于任何声明路由 |
374
+ | `bad-query` | 400 | 会话参数缺失、重复或为空 |
375
+ | `not-shared` | 403 | 会话不可达 |
376
+ | `consent-off` | 403 | 整机数据路由且主人未同意 |
377
+ | `busy` | 429 | 该设备在途桥接请求已达 8 |
378
+ | `loopback-auth` | 502 | 回环 cookie 换取失败(无 token 地址、换不到 cookie) |
379
+ | `internal` | 502 | 回环其他失败(响应头未发出时;已发出则截断) |
380
+
381
+ **回环执行**:服务端以本机回环 HTTP 请求自己的插件路由(`127.0.0.1:<webServer.port>`),Host 头即回环地址——插件自己的鉴权与 handler 原样生效。回环请求带操作者 cookie:没有 cookie 先 `GET /?token=<服务端令牌>` 换一次(不跟随重定向,从 3xx 的 `set-cookie` 取),插件路由回答 401 时重换一次再试,仍 401 原样交回。**回环带操作者 cookie,意味着插件接口看到的权限与本机浏览器完全相同**——这是设计:改道替代的是「浏览器在本机打开面板」,不是绕过插件自己的鉴权。cookie 只存内存,不落日志、不落盘。0.2.1 起 webServer 可能是 https 自签证书:仅当目标是字面 `127.0.0.1` 时不校验证书。
382
+
383
+ **流式与预算**:插件响应的状态码与 `content-type`、`content-length`(若有)、`cache-control` 原样交回,体逐块直通(背压:子客户端读得慢,回环就走得慢);收尾沿用中继流的排水看门狗(默认 5 秒,读不动的连接直接销毁)。每设备在途桥接请求上限 8(整个流期间占一个名额),超出 429 `busy`;收尾读不动的连接 5 秒后销毁并释放名额,中途停读的连接则最多占满该设备的 8 个名额(其余请求照常进出);子客户端断开即中止回环请求。
384
+
385
+ **写请求(2.2 起)**:方法由头 `x-zr-method` 给出(缺省 GET);`GET`/`HEAD` 带体仍拒绝 400,其余方法的请求体 = 插件请求体(原样字节流),体大小按 `content-length` 先判、流式累计兜底,超过该路由声明的 `maxBody` 回 413 `body-too-large`;方法不在路由声明的 `methods` 内回 405 `method-undeclared`;透传头白名单相应加入 `content-type`。
386
+
387
+ ### 插件桥的 WebSocket 字节隧道:`POST /v1/plugin-tunnel/{open,send,close}`
388
+
389
+ 声明 `upgrade: true` 的插件路由走这三条路由(声明侧见 `docs/plugin-bridge.md`)。中继**不解析 WebSocket**:服务端→浏览器方向是**一条 NDJSON 流式响应**,浏览器→服务端方向是**按序分块的普通 POST**,双向都是不透明字节——真正的升级握手(101 与每个帧)由插件路由在服务端本机完成,字节原样穿过隧道(`docs/adr/0002`)。
390
+
391
+ | 路由 | 请求头 | 请求体 | 成功回答 |
392
+ | --- | --- | --- | --- |
393
+ | `/v1/plugin-tunnel/open` | 与 `/v1/plugin` 相同的坐标头 | 小 JSON:`{"headers":{"sec-websocket-key":…,"sec-websocket-version":…,"sec-websocket-protocol"?,"sec-websocket-extensions"?},"head"?}`——四个 ws 头原样进升级请求(逐个做 CR/LF 检查),`head` 是 base64 的已到达早期字节,可省略 | 200 NDJSON 流(形状见下) |
394
+ | `/v1/plugin-tunnel/send` | `x-zr-tunnel`(open 首行给的隧道 id)、`x-zr-seq`(从 0 起严格递增) | 原始字节,≤ 64 KiB | `{"ok":true}`——等插件那条 socket 收下字节才回(背压:插件收不动,这次的 send 就慢) |
395
+ | `/v1/plugin-tunnel/close` | `x-zr-tunnel` | 无 | `{"ok":true}`,幂等(未知 id 也答 ok) |
396
+
397
+ open 的校验链与 `/v1/plugin` **完全共享**(注册表 → 版本与摘要 → exact 路由 → parse-once query → 共享表 → 同意墙,次序与错误码一致),外加一条自己的墙:路由还必须声明 `upgrade: true`——普通路由背后没有可拨的 WebSocket 端点,404 `route-undeclared`。通过后中继**自己拨本机 web server**(net/tls、只限回环;自签 https 仅对字面 `127.0.0.1` 放宽证书校验),用收到的四个头与操作者 cookie 手写升级请求(Origin 不写),把 socket 字节逐行泵进 NDJSON 流:
398
+
399
+ - 首行 `{"type":"open","tunnel":"<32 位十六进制>"}`——子客户端收到它就停止 HTTP、开始读流;拨号失败不丢连接,变成一行 `end` 再关闭;
400
+ - `{"type":"data","b64":"…"}`——上游字节(base64),每行最多 48 KiB **原始**字节(base64 会膨胀 4/3,上限按裸字节算);
401
+ - `{"type":"ping"}` 心跳行(默认 15 秒一条)防反代掐闲置流;慢读纪律与中继流一致——客户端读不动就**暂停上游 socket**,服务端进程不为慢客户端攒字节;
402
+ - 结束行 `{"type":"end","reason":"upstream-closed"|"upstream-error"|"client-closed"}` 后流关闭;插件发的关闭字节(若有)作为 data 行先行到达,不另造 WebSocket 关闭帧。
403
+
404
+ open 的体上限 1 MiB(超出 413);每设备并发隧道上限 8,超出 429 `busy`。隧道归属**开它的设备**(`x-zen-remote-device`):另一台设备的 send/close 一律 404 `tunnel-unknown`;send 的 `x-zr-seq` 不等于期望序号回 409 `tunnel-out-of-order` 并**立即关闭隧道**——顺序契约破了,这条流不再可信。除这些隧道自有错误码外,其余拒绝与 `/v1/plugin` 的错误码表一致(校验链共享)。
405
+
343
406
  ---
344
407
 
345
408
  ## 🔔 推送说明