dsh-zen-remote 2.3.11 → 2.4.1

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 (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -142,7 +142,7 @@ export interface WizardHandlerOptions {
142
142
  * POST mints the pairing code, GET polls the session. Absent — a wiring
143
143
  * that predates GW-9 — and both answer 501 like the placeholders. */
144
144
  verify?: {
145
- start: (input: { publicUrl: unknown; probeOnly?: boolean }) => Promise<VerificationStartResult>
145
+ start: (input: { publicUrl: unknown; probeOnly?: boolean; allowUnprobed?: boolean }) => Promise<VerificationStartResult>
146
146
  check: () => Promise<VerificationCheckResult>
147
147
  }
148
148
  }
@@ -207,9 +207,13 @@ export function createWizardHandler(options: WizardHandlerOptions): WizardHandle
207
207
  // because a forwarded request presents as same-origin (the gateway
208
208
  // rewrites Origin/Host); the marker is the identifying signal.
209
209
  if (req.headers['x-zen-remote-via'] !== undefined) {
210
+ const isEn = options.getConfig().values.lang === 'en'
210
211
  responseJson(res, 403, {
211
212
  ok: false,
212
- error: { code: 'via-gateway', message: '配置引导只能在服务端本机操作' },
213
+ error: {
214
+ code: 'via-gateway',
215
+ message: isEn ? 'Setup wizard is only available on the server machine' : '配置引导只能在服务端本机操作',
216
+ },
213
217
  })
214
218
  return
215
219
  }
@@ -258,11 +262,14 @@ export function createWizardHandler(options: WizardHandlerOptions): WizardHandle
258
262
  // env / row values SHADOW the file layer the persister writes: a
259
263
  // write there would silently do nothing. Refuse honestly instead.
260
264
  if (resolved.source === 'env' || resolved.source === 'row') {
265
+ const isEn = options.getConfig().values.lang === 'en'
261
266
  responseJson(res, 409, {
262
267
  ok: false,
263
268
  error: {
264
269
  code: 'proxy-list-shadowed',
265
- message: `trustedProxies 目前由${resolved.source === 'env' ? '环境变量' : '插件行配置'}提供,请先清掉那边的值再自动信任`,
270
+ message: isEn
271
+ ? `trustedProxies is currently provided by ${resolved.source === 'env' ? 'environment variables' : 'plugin row config'} — clear that value first`
272
+ : `trustedProxies 目前由${resolved.source === 'env' ? '环境变量' : '插件行配置'}提供,请先清掉那边的值再自动信任`,
266
273
  },
267
274
  })
268
275
  return
@@ -277,9 +284,13 @@ export function createWizardHandler(options: WizardHandlerOptions): WizardHandle
277
284
  // quick succession must not lose either IP.
278
285
  const landed = await trust.write(resolved.value, merged)
279
286
  if (landed === false) {
287
+ const isEn = options.getConfig().values.lang === 'en'
280
288
  responseJson(res, 409, {
281
289
  ok: false,
282
- error: { code: 'trust-list-changed', message: '信任列表刚被其他操作修改,请刷新后重试' },
290
+ error: {
291
+ code: 'trust-list-changed',
292
+ message: isEn ? 'Trusted proxy list was just modified — refresh and retry' : '信任列表刚被其他操作修改,请刷新后重试',
293
+ },
283
294
  })
284
295
  return
285
296
  }
@@ -381,7 +392,14 @@ export function createWizardHandler(options: WizardHandlerOptions): WizardHandle
381
392
  : values.ingressDomain
382
393
  const domain = hostOf(rawDomain)
383
394
  if (domain === undefined) {
384
- responseJson(res, 400, { ok: false, error: { code: 'no-domain', message: '保存一个有效的公开域名后再启动' } })
395
+ const isEn = values.lang === 'en'
396
+ responseJson(res, 400, {
397
+ ok: false,
398
+ error: {
399
+ code: 'no-domain',
400
+ message: isEn ? 'Save a valid public domain before starting' : '保存一个有效的公开域名后再启动',
401
+ },
402
+ })
385
403
  return
386
404
  }
387
405
  const rawPort = typeof body?.httpsPort === 'number' && Number.isSafeInteger(body.httpsPort) && body.httpsPort >= 1 && body.httpsPort <= 65535
@@ -497,6 +515,7 @@ export function createWizardHandler(options: WizardHandlerOptions): WizardHandle
497
515
  responseJson(res, 200, await verify.start({
498
516
  publicUrl: body.publicUrl,
499
517
  ...(body.probeOnly === true ? { probeOnly: true } : {}),
518
+ ...(body.allowUnprobed === true ? { allowUnprobed: true } : {}),
500
519
  }))
501
520
  return
502
521
  }
@@ -72,13 +72,21 @@ export interface WizardForwardedSourceInput {
72
72
  * fields. Token presence is reported as booleans only; port as the gateway
73
73
  * child's ACTUAL listening port (it may differ from the configured one).
74
74
  */
75
+ export interface WizardLanCandidate {
76
+ ip: string
77
+ kind: 'tailscale' | 'lan'
78
+ iface: string
79
+ }
80
+
75
81
  export interface WizardRuntimeInput {
76
82
  cfTokenConfigured?: unknown
77
83
  tunnelTokenConfigured?: unknown
78
84
  caddyRunning?: unknown
79
85
  cloudflaredRunning?: unknown
80
86
  gatewayActualPort?: unknown
87
+ gatewayActualHost?: unknown
81
88
  lanAddress?: unknown
89
+ lanAddresses?: unknown
82
90
  lastHostProbe?: WizardRecordInput | null
83
91
  lastExternalVerification?: WizardRecordInput | null
84
92
  lastForwardedSource?: WizardForwardedSourceInput | null
@@ -109,7 +117,9 @@ export interface WizardStatus {
109
117
  caddyRunning: boolean
110
118
  cloudflaredRunning: boolean
111
119
  gatewayActualPort: number | null
120
+ gatewayActualHost?: string
112
121
  lanAddress?: string
122
+ lanAddresses?: WizardLanCandidate[]
113
123
  lastHostProbe: WizardStatusRecord | null
114
124
  lastExternalVerification: WizardStatusRecord | null
115
125
  lastForwardedSource: WizardStatusForwardedSource | null
@@ -154,6 +164,21 @@ function forwardedSourceOf(input: WizardForwardedSourceInput | null | undefined)
154
164
  return { ip, at }
155
165
  }
156
166
 
167
+ function lanCandidatesOf(value: unknown): WizardLanCandidate[] | undefined {
168
+ if (!Array.isArray(value)) return undefined
169
+ const out: WizardLanCandidate[] = []
170
+ for (const item of value) {
171
+ if (item === null || typeof item !== 'object') continue
172
+ const ip = (item as { ip?: unknown }).ip
173
+ const kind = (item as { kind?: unknown }).kind
174
+ const iface = (item as { iface?: unknown }).iface
175
+ if (typeof ip !== 'string' || ip === '') continue
176
+ if (kind !== 'tailscale' && kind !== 'lan') continue
177
+ out.push({ ip, kind, iface: typeof iface === 'string' ? iface : '' })
178
+ }
179
+ return out.length > 0 ? out : undefined
180
+ }
181
+
157
182
  /**
158
183
  * Build the sanitized wizard status. Both arguments are optional so a
159
184
  * composition can answer before anything is configured; the all-empty
@@ -161,6 +186,7 @@ function forwardedSourceOf(input: WizardForwardedSourceInput | null | undefined)
161
186
  * no tokens configured, no memories).
162
187
  */
163
188
  export function projectWizardStatus(declared: WizardDeclaredInput = {}, runtime: WizardRuntimeInput = {}): WizardStatus {
189
+ const lanAddresses = lanCandidatesOf(runtime.lanAddresses)
164
190
  return {
165
191
  ok: true,
166
192
  mode: modeOf(declared.ingressMode),
@@ -172,7 +198,9 @@ export function projectWizardStatus(declared: WizardDeclaredInput = {}, runtime:
172
198
  caddyRunning: runtime.caddyRunning === true,
173
199
  cloudflaredRunning: runtime.cloudflaredRunning === true,
174
200
  gatewayActualPort: portOf(runtime.gatewayActualPort),
201
+ ...(typeof runtime.gatewayActualHost === 'string' && runtime.gatewayActualHost !== '' ? { gatewayActualHost: runtime.gatewayActualHost } : {}),
175
202
  ...(typeof runtime.lanAddress === 'string' && runtime.lanAddress !== '' ? { lanAddress: runtime.lanAddress } : {}),
203
+ ...(lanAddresses !== undefined ? { lanAddresses } : {}),
176
204
  lastHostProbe: recordOf(runtime.lastHostProbe),
177
205
  lastExternalVerification: recordOf(runtime.lastExternalVerification),
178
206
  lastForwardedSource: forwardedSourceOf(runtime.lastForwardedSource),
@@ -54,7 +54,7 @@ export interface VerificationManagerOptions {
54
54
  }
55
55
 
56
56
  export type VerificationStartResult =
57
- | { ok: true; pairingCode: string; publicUrl: string; expiresAt: number }
57
+ | { ok: true; pairingCode: string; desktopPairingCode?: string; publicUrl: string; expiresAt: number; probeWarning?: unknown }
58
58
  | {
59
59
  ok: false
60
60
  reason: 'no-public-url' | 'bad-public-url' | 'runtime-not-ready' | 'gateway-unreachable' | 'probe-failed' | 'pair-refused'
@@ -75,6 +75,7 @@ export type VerificationCheckResult =
75
75
  /** What the wizard reads off one gateway status answer. */
76
76
  interface GatewayStatusView {
77
77
  deviceIds: string[]
78
+ deviceRoles: Map<string, string>
78
79
  port: number | null
79
80
  lastWebClaim: { id: string; secure: boolean; at: number } | null
80
81
  }
@@ -105,7 +106,7 @@ interface VerificationSession {
105
106
  }
106
107
 
107
108
  export interface VerificationManager {
108
- start(input: { publicUrl: unknown; probeOnly?: boolean }): Promise<VerificationStartResult>
109
+ start(input: { publicUrl: unknown; probeOnly?: boolean; allowUnprobed?: boolean }): Promise<VerificationStartResult>
109
110
  check(): Promise<VerificationCheckResult>
110
111
  /** Whether a verification session is open (the route surfaces it for the
111
112
  * UI's 「待外部验证」 state). */
@@ -140,10 +141,19 @@ export function createVerificationManager(options: VerificationManagerOptions):
140
141
  if (reply === null || reply.status < 200 || reply.status >= 300) return null
141
142
  const body = reply.body as { devices?: unknown; port?: unknown; lastWebClaim?: unknown }
142
143
  if (body === null || typeof body !== 'object') return null
143
- const deviceIds: string[] = Array.isArray(body.devices)
144
- ? body.devices.map((device) => (device !== null && typeof device === 'object' ? (device as { id?: unknown }).id : undefined))
145
- .filter((id): id is string => typeof id === 'string')
146
- : []
144
+ const deviceIds: string[] = []
145
+ const deviceRoles = new Map<string, string>()
146
+ if (Array.isArray(body.devices)) {
147
+ for (const device of body.devices) {
148
+ if (device === null || typeof device !== 'object') continue
149
+ const id = (device as { id?: unknown }).id
150
+ const role = (device as { role?: unknown }).role
151
+ if (typeof id === 'string') {
152
+ deviceIds.push(id)
153
+ if (typeof role === 'string') deviceRoles.set(id, role)
154
+ }
155
+ }
156
+ }
147
157
  const port = typeof body.port === 'number' ? body.port : null
148
158
  const claim = body.lastWebClaim
149
159
  const lastWebClaim = claim !== null && typeof claim === 'object'
@@ -153,11 +163,11 @@ export function createVerificationManager(options: VerificationManagerOptions):
153
163
  at: typeof (claim as { at?: unknown }).at === 'number' ? (claim as { at: number }).at : 0,
154
164
  }
155
165
  : null
156
- return { deviceIds, port, lastWebClaim }
166
+ return { deviceIds, deviceRoles, port, lastWebClaim }
157
167
  }
158
168
 
159
169
  return {
160
- async start({ publicUrl, probeOnly }) {
170
+ async start({ publicUrl, probeOnly, allowUnprobed }) {
161
171
  if (typeof publicUrl !== 'string' || publicUrl.trim() === '') return { ok: false, reason: 'no-public-url' }
162
172
  // Same admission as the probe: https anywhere, plain http only into
163
173
  // the private-network allowances. The URL the phone will open must
@@ -171,7 +181,8 @@ export function createVerificationManager(options: VerificationManagerOptions):
171
181
  if (statusBefore === null) return { ok: false, reason: 'gateway-unreachable' }
172
182
 
173
183
  const outcome = await (options.probe ?? probeHostPath)(normalized.url)
174
- if (outcome.kind !== 'reached' || outcome.root !== 'gateway-pairing-page' || outcome.admin !== 'gateway-admin-403') {
184
+ const probeOk = outcome.kind === 'reached' && outcome.root === 'gateway-pairing-page' && outcome.admin === 'gateway-admin-403'
185
+ if (!probeOk && allowUnprobed !== true) {
175
186
  return { ok: false, reason: 'probe-failed', detail: outcome }
176
187
  }
177
188
 
@@ -180,7 +191,7 @@ export function createVerificationManager(options: VerificationManagerOptions):
180
191
  }
181
192
 
182
193
  const pair = await callGateway('POST', '/lan-gate/pair', { role: 'web' })
183
- const pairBody = pair?.body as { ok?: unknown; code?: unknown; expiresAt?: unknown } | undefined
194
+ const pairBody = pair?.body as { ok?: unknown; code?: unknown; desktopCode?: unknown; expiresAt?: unknown } | undefined
184
195
  if (pair === null || pair.status < 200 || pair.status >= 300 || pairBody?.ok !== true || typeof pairBody.code !== 'string' || typeof pairBody.expiresAt !== 'number') {
185
196
  return { ok: false, reason: 'pair-refused', detail: pair === null ? null : pair.status }
186
197
  }
@@ -192,7 +203,14 @@ export function createVerificationManager(options: VerificationManagerOptions):
192
203
  knownDeviceIds: new Set(statusBefore.deviceIds),
193
204
  fingerprint: verificationFingerprint(options.getValues(), statusBefore.port),
194
205
  }
195
- return { ok: true, pairingCode: session.code, publicUrl: session.publicUrl, expiresAt: session.expiresAt }
206
+ return {
207
+ ok: true,
208
+ pairingCode: session.code,
209
+ ...(typeof pairBody.desktopCode === 'string' && pairBody.desktopCode !== '' ? { desktopPairingCode: pairBody.desktopCode } : {}),
210
+ publicUrl: session.publicUrl,
211
+ expiresAt: session.expiresAt,
212
+ ...(probeOk ? {} : { probeWarning: outcome }),
213
+ }
196
214
  },
197
215
 
198
216
  async check() {
@@ -220,6 +238,10 @@ export function createVerificationManager(options: VerificationManagerOptions):
220
238
  if (isHttps && !claim.secure) return { result: 'cookie-insecure', deviceId }
221
239
  return { result: 'pass', deviceId, at: claim.at }
222
240
  }
241
+ if (status.deviceRoles.get(deviceId) === 'desktop-client') {
242
+ session = null
243
+ return { result: 'pass', deviceId, at: now() }
244
+ }
223
245
  // A claim record that has not surfaced (or names a different,
224
246
  // later claim): keep polling rather than bless an unverified cookie.
225
247
  return { result: 'pending' }
package/AGENTS.md DELETED
@@ -1,185 +0,0 @@
1
- # AGENTS.md — dsh-zen-remote
2
-
3
- 单一 npm 包 `dsh-zen-remote`,DSH(DeepSeek Harness)的 bundle 插件。
4
- 一个包里有两半代码,但**对用户是一个插件**:文档、安装说明、README 都不要
5
- 再按「两个包」叙述(2026-08-17 由 monorepo 的 `packages/gateway` +
6
- `packages/mobile-ui` 合并而来,包名 `dsh-mobile-pwa` 与
7
- `@dsh-external/dsh-mobile-nav` 已作废)。
8
-
9
- ## 目录
10
-
11
- | 路径 | 作用 |
12
- | --- | --- |
13
- | `package.json` | 单包声明:`dsh.bundle.patch` → `cordis.patch.yml`,`dsh.client` → `exports["./client"]` |
14
- | `cordis.patch.yml` | 组合层,**一行** insert(`dsh-zen-remote`),随包自带;网关与推送由主入口按角色加载,不再各占一行 |
15
- | `src/index.ts` → `lib/index.js` | 主入口(插件行 `dsh-zen-remote`):读 `role`,host 就 `ctx.plugin()` 加载网关与推送子插件,并挂 host 路由(附件上传 / 客户端配置 / 分享导出 / 管理 / 子客户端 / 中继) |
16
- | `src/config.ts` | 配置模型:四层回退(env > 插件行 > `lan-gate.config.json` > 默认值)、loader 的 `Config` schema(全 volatile)、`resolveRole` |
17
- | `src/http.ts` | host 路由共用的 JSON 响应封装与同源 POST 门 |
18
- | `src/admin-routes.ts` | 设置页管理后端 `/_dsh/zen-remote/admin/*`:代调网关本机 API(配对 / 设备 / 测试推送)+ 共享开关 |
19
- | `src/client-routes.ts` | 子客户端路由 `/_dsh/zen-remote/client/*`:remote-status(两角色都挂,host 只应答这一条、其余 404)、配对代理、设置页诊断、plain-HTTP 转发 `client/http/*`(改动摘要 / diff)、`debug-report`(脱敏诊断报告,via 墙先于分派,CU-3) |
20
- | `src/client-pairing.ts` | 子客户端配对纯逻辑:服务端地址归一化(http 仅限内网段)、claim/探针结果分类 |
21
- | `src/share-store.ts` | 共享会话表(`~/.dsh/zen-remote-shares.json`,持久化,重启恢复),中继访问控制的事实来源 |
22
- | `src/activity.ts` | 会话活动统计 + 闲置休眠扫描(`idleHours`,运行中/等待中不计时) |
23
- | `src/share-ops.ts` | `agent/created` 的自动共享 / 分叉跟随 / busy 恢复 |
24
- | `src/share-export.ts` | 分享图路由 `GET /_dsh/mobile-nav/share-export`(完整日志折叠人类转写) |
25
- | `src/relay-server.ts` | 服务端中继路由 `/_dsh/zen-remote/relay/*`:ping / handshake / invoke / NDJSON stream / event-result / `v1/http`(改动摘要 / diff 的 plain-HTTP 直通,进程内交宿主 `/api` 共享 handler)/ `v1/upload`(远程会话附件字节转宿主暂存,返回回执)/ unshare |
26
- | `src/relay-access.ts` | 中继的按方法登记访问控制表(服务端那一张;`v1/http` 的按路由登记表也在这里) |
27
- | `src/relay-filter.ts` | 全局流与全局列表的输出过滤(`workspace/follow` / `session/control` / `session/list` / @ 引用候选行) |
28
- | `src/session-reference.ts` | 两端共用的 `dsh-session:` 引用编解码与「可注入文本」扫描规则(prompt content、排队消息 edit、`commands/execute` 全部字符串):服务端照它校验、子客户端照它改写,同一份规则防两端漂移 |
29
- | `src/relay-client.ts` | 子客户端中继客户端:握手、接口指纹比对、退避重连、吊销识别 |
30
- | `src/intercept.ts` | 子客户端本机拦截:包装 `typertGateway`,远程会话调用改走中继、虚拟 id 改写、面板判定(客户端那张字段表也在这里) |
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 主包 |
38
- | `src/fetch-route-intercept.ts` | 子客户端本机拦截的第二半:包装宿主连接服务 `fetchRoutes` 表的上传与导出两项——远程会话的非图片附件经 `relay/v1/upload` 流式转服务端(超限本地拒、失败回宿主自己的 200 失败信封)、虚拟 id 的导出后台 403(形态检测、懒装重试、卸载守卫) |
39
- | `src/virtual-id.ts` | `zr~<serverId>~<id>` 虚拟 id 算术(纯函数、零依赖;client-data 半边许可 import 它做 zr~ 判定,先例 remote-drop.ts / remote-status.ts) |
40
- | `src/log-ring.ts` | 诊断导出用内存日志环(CU-1):上限 300 条、进程内不落盘;index.ts 两分支的 logWarn/logInfo 收口双写(ctx.logger + 环),生命周期事件(row apply/reload、网关 spawn/exit、relay online/offline、fetch-route 安装含 via)push 进环 |
41
- | `src/debug-report.ts` + `src/debug-redact.ts` | 诊断报告装配与脱敏器(CU-2,纯逻辑零 DSH import):装配 getter 注入拼 `_meta/config/status/gateway/logs` 五板块、配置按字段名挑选(秘密构造性缺席);脱敏器 URL 走**哨兵摘出-还原**(userinfo→`<token>@`、host→地址类型、端口与 path 保留、query 长值单独 token 化)、家目录折叠先于 token 趟;`scripts/check-debug-report.mjs` 真值表钉住全部形态(含哨兵伪造) |
42
- | `src/merge-streams.ts` | 三条全局流/列表的本地 + 远程合并状态机(侧边栏远程分组) |
43
- | `src/fingerprint.ts` | DSH 接口指纹:规范化描述符 → 分组 JSON Schema 哈希,供握手比对 |
44
- | `src/restart-fields.ts` / `restart-watcher.ts` | 需重启字段的指纹监测,变化即重载插件行 |
45
- | `src/client/**` → `lib/client.js` | 浏览器半边(同一插件行,经 `dsh.client` 发现):app 外壳、slot、样式、设置区块(`settings/`)、共享与远程部件(`remote-*` / `Remote*`) |
46
- | `src/client-data/current-session.ts` + `src/client/current-session-report.ts` | 当前会话信号的浏览器上报(T62):拦截层在后台进程读不到浏览器 localStorage,由浏览器端解析宿主选择存储、值变化时 POST 到 `client/current-session` 路由;纯函数(解析 + 变化判定)与上报循环分居两文件 |
47
- | `src/client-data/remote-group-expansion.ts` + `src/client/remote-group-expansion.ts` | 服务端分组展开状态的记忆与恢复(T64):宿主 retainAccountKeys 每次加载都清掉 zr~ 分组的展开记录,浏览器端把宿主记录镜像进自己的存储键、宿主清掉后按记录(默认展开)点击行恢复;纯函数(规划 + 解析 + 上限淘汰)与 keeper 循环分居两文件 |
48
- | `lan-gate.mjs` | 网关子插件入口,由主入口按 host 角色加载(不再自己占插件行):spawn 子进程,共享密钥只经它进子进程 |
49
- | `lib/lan-gate-server.cjs` | 网关本体(独立 Node 子进程,Node stdlib + `web-push`):设备角色、标记头与共享密钥、desktop-client 只放行中继前缀 |
50
- | `dsh-push.mjs` | 推送子插件(与网关一起由主入口加载):回合结束推送 + `push_notify` 工具 |
51
- | `pwa/**` | manifest / service worker / 注入脚本 / 手势 / 壳级 CSS / 图标 |
52
- | `test/*.test.cjs` | 网关侧测试(真子进程 + mock 上游)+ 中继端到端(`relay-e2e.test.cjs`)+ 纯逻辑导入测试 |
53
- | `scripts/check-*.mjs` | 界面侧自检(纯 `node:assert`,靠 Node ≥23.6 类型剥离直接 import `.ts`) |
54
- | `scripts/build-client.mjs` | client 打包器(内联相对模块 → `__ModuleLoader__.load({id:"dsh-zen-remote"})`) |
55
- | `docs/**` | 深度文档,见下 |
56
-
57
- ## 命令
58
-
59
- ```sh
60
- pnpm install
61
- pnpm build # tsc host + tsc client + build-client.mjs → lib/(产物入库,改 src/ 必须重跑并提交 lib/)
62
- pnpm verify # 两个 tsconfig 的 --noEmit 类型检查
63
- pnpm test # 网关 node:test 用例 + 三个界面自检脚本 + 文档版本号一致性检查,一条命令全跑
64
- ```
65
-
66
- **版本号**:两份 README(`README.md` 英文为主文档,`README.zh-CN.md` 中文)里的
67
- release 徽章和 profile 依赖示例由 `scripts/sync-doc-version.mjs` 按 `package.json`
68
- 改写,挂在 `version` 生命周期脚本上——`npm version patch` 会把改好的两份 README
69
- 带进同一个发版提交,不用手改
70
- (徽章曾经一路卡在 v1.0.0 到 1.0.2)。`pnpm test` 里的 `--check` 会在漏同步时
71
- 把测试挂掉。文档里的锚点变了就更新那个脚本:找不到标记它直接非零退出,不会
72
- 默默通过。
73
-
74
- **发版**:全部由 tag 驱动,本地只做一步。
75
-
76
- ```sh
77
- npm version patch # 改 package.json + 同步两份 README + 建 commit 和 v* tag
78
- git push --follow-tags # 推 tag 才是真正的触发器
79
- ```
80
-
81
- 推上去之后 `.github/workflows/publish.yml` 依次做:装依赖 → `pnpm build` →
82
- **`git diff --exit-code -- lib`**(入库产物必须与重新构建的结果一致,挡住
83
- 「改了 src 忘了 build 就打 tag」)→ `pnpm verify` → `pnpm test` →
84
- tag 名与 `package.json` 版本一致性 → `pnpm publish`(npm Trusted Publishing,
85
- OIDC 无令牌,provenance 自动生成)→ **建 GitHub Release**。
86
-
87
- Release 那步刻意排在 npm 之后:它宣告的是「这个版本已经发出去了」,npm 失败就
88
- 不该留下一个指向不存在版本的发布页。正文来源见下方 CHANGELOG 段(该版本的那一
89
- 段 + 自上一版本以来的全部提交);这一步可重入(先 `gh release view` 查在不在,
90
- 在就跳过),重跑失败的 workflow 不会因为 Release 已存在而挂掉。checkout 带
91
- `fetch-depth: 0`(提交列表要全历史)。
92
-
93
- CHANGELOG(2026-11-02 起有 `CHANGELOG.md`):只记用户可感知的变化,未发布段落
94
- 攒着待发内容;**发版前**把「未发布」标题改成该版本号 + 日期,随 `npm version
95
- patch` 的 version commit 一起提交(脚本只同步 README 徽章,CHANGELOG 是手动步)。
96
- 此前「提交信息就是 changelog、不另维护文件」的约定作废。GitHub Release 正文
97
- **就是 CHANGELOG 里该版本的那一段**(workflow 用同一条 awk 提取,缺段会在 npm
98
- 发布前被守卫挡下),其下自动附「自上一版本以来的全部提交」(git log 现算,
99
- 不再用 `--generate-notes`——它无法与 --notes-file 共存,且正文来源要可预测)。
100
-
101
- 需要人工配置的只有一处:npmjs.com 的包设置里登记可信发布者
102
- `KyoMio/dsh-zen-remote` + 文件名 `publish.yml`。GitHub Release 那步用的是
103
- workflow 自带的 `GITHUB_TOKEN`,不需要额外密钥——但 job 的 `permissions` 里
104
- `contents` 必须是 `write`(原来是 `read`)。
105
-
106
- ## 深度文档
107
-
108
- | 文件 | 内容 |
109
- | --- | --- |
110
- | [`docs/remote-access.md`](docs/remote-access.md) | 通道半边:反代配置(nginx/Caddy/Lucky)、设备角色与配对、环境变量表、管理 API、远程中继协议、推送、安全边界 |
111
- | [`docs/interface.md`](docs/interface.md) | 界面半边:断点策略、设置区块与远程部件、调试徽章、安全区体系、兼容插件清单 |
112
-
113
-
114
- ## 合仓后仍然成立的硬约束
115
-
116
- - **网关是子进程**:`lan-gate.mjs` 只负责 spawn + 生命周期,永远不要把
117
- `lib/lan-gate-server.cjs` import 进 DSH 进程。
118
- - **中继共享密钥不落地**:主入口每次 apply 现生成 `LAN_GATE_RELAY_SECRET`,
119
- 只经环境变量交给网关子进程(无条件覆盖、缺失即删除),中继路由逐请求
120
- `timingSafeEqual` 校验。不允许由外部环境指定,也不要把它写进日志或状态文件。
121
- - **中继访问控制按方法登记,两张表同步**:新增远程方法必须同时登记
122
- `src/relay-access.ts` 的 `RELAY_METHODS`(服务端,授权看登记的归属字段)
123
- 和 `src/intercept.ts` 的 `CLIENT_METHOD_FIELDS`(客户端,改写判定),
124
- 一致性由 `test/intercept.test.cjs` 的逐方法比对测试钉住。插件桥的授权另走一条:声明注册表 + 路径完全相等 + 会话共享(`src/plugin-route-intercept.ts` / relay 的 `/v1/plugin`),同样不扫描参数;
125
- 禁止「从参数里
126
- 通用扫描会话 id」的写法——DSH 会悄悄丢掉不认识的参数名,扫描会被诱饵
127
- 字段骗过;全局读(`session/list` 等)安全靠输出过滤,不靠输入判定。
128
- - **适配器必须可插拔**:`src/adapters/` 整个目录删掉后其余功能一字不改——
129
- 框架只消费 `src/plugin-adapter-core.ts`(在适配器目录之外),接线只有
130
- `src/index.ts` 一处 import `./adapters/index`,`createRelayHandler` /
131
- `installPluginRouteIntercept` / admin 列表的适配器参数全部缺省空;改
132
- 框架两半时不得新增对适配器目录的 import(`test/adapters-pluggable.test.cjs`
133
- 的静态扫描钉住)。适配器让位规则:插件自己登记的协作声明永远优先,
134
- 运行时逐请求核对注册表摘要。
135
- - **桌面端请求没有 `Origin`/`Sec-Fetch-Site`**:DSH 桌面端主进程把窗口请求
136
- 转发给本机后台前,自己校验过 `Origin: dsh-app://app`,然后删掉
137
- `host` / `origin` / `cookie` / `sec-fetch-site`。所以 `src/http.ts` 的
138
- `sameOriginPost` 对「两个头都缺」的请求是**放行**的(`admit` 顶在前面);
139
- 别把它改成「缺 Origin 即拒绝」,那会把桌面端窗口里的所有变更请求挡掉。
140
- 真正要拒的是可辨认的跨站(`Sec-Fetch-Site: cross-site`、Origin 与 Host 不符)。
141
- - **volatile 字段在使用时现场读取**:`Config` 的每个字段都是 volatile——
142
- loader 交给 `apply()` 的是 `{ get() }` 包装而不是值。任何读行配置的地方
143
- 都要走 `unwrapVolatile`(单字段)或 `resolveConfig`(整组),禁止在
144
- apply 时拍快照存着用;`RESTART_FIELDS` 之外的字段(`serverName`、
145
- `idleHours`、`serverUrl`、`deviceToken`、界面旋钮)改了不重载行,
146
- 不现场读就是永久拿到旧值。
147
- - **CSS 分工没变**:排版类规则在 `src/client/styles/`;`pwa/app.css` 只留壳级
148
- 规则(iOS 输入框防缩放、安全区滚动补偿、代码块横向滚动)。两边抢同一个元素
149
- 是历史事故的根源,加规则前先确认归属。
150
- - **`lib/` 是产物,不手改**:改 `src/` → `pnpm build` → 提交 `lib/`。**删 src 时必须手动 `git rm` 对应 lib 产物**:tsc 只增量写、从不清理已删源文件的旧产物,CI 的 `git diff --exit-code -- lib` 关卡对死产物全盲(2026-10-24 PR #34 吸收剥离踩过,靠对抗审查拦下)。
151
- - **桌面必须 no-op**:≥1024px 逐像素与未安装时一致。
152
- 是从真机事故里攒出来的,改相关代码前先读。
153
- - **首帧壳只能内联注入**:冷启动占位壳由网关 `injectBootShell` 写进第一帧
154
- HTML(`BOOT_SHELL_*`),DSH 一往 `#root` 渲染就同步摘掉。壳的 CSS/标记/脚本
155
- 都是网关里的内联常量(不放进 `pwa/app.css` 或客户端样式),带 30s CSS 渐隐
156
- 兜底;移除脚本有真实 vm 用例(DCL / 晚渲染 / `#root` 缺失 / 保险丝)。不能
157
- 挪进客户端插件——那个 bundle 正是首帧要等的东西;不能引入额外请求;显式
158
- `desktop` 设备不注入、≥1024px 由 CSS 隐藏。`test/gateway.test.cjs` 钉住。
159
- - **长缓存只给内容寻址 URL**:网关只给 `/assets/` 下**哈希形状**(8+ 位且含
160
- 数字或大小写混合,排除 `vendor-bootstrap.css` 这类普通词)、且响应不是 HTML
161
- 的文件补 `immutable`(上游已有缓存头时让上游说话);service worker 只对同一
162
- 判定与 `?rev=<hex 构建修订>`(DSH 按 mtime/ctime/size 生成,且 DSH 自己已
163
- 按 immutable 提供)的插件批次缓存优先;响应类型必须与 URL 声明一致,
164
- HTML/错误页不得存进资源缓存。给无哈希 URL 做长缓存就是重演 v2 的「新 DOM + 旧 CSS」事故。
165
- - **要藏宿主刚渲染出来的东西,别用 `requestAnimationFrame`**:React 在该帧的
166
- rAF 阶段之后才提交 DOM,所以从 mutation 里排的 rAF 落在**下一帧**——中间那
167
- 一帧已经画到屏幕上,用户就看见闪。两个月内三次「晚一帧」的 bug(2026-08-18
168
- 回复要等回合结束才出现、08-22 工具调用行先显后折、08-25 issue #3 流式抖动)
169
- 都是这个根子。两条出路:能用选择器表达的交给样式表(`turn-fold.css.ts` 的
170
- `BORN_FOLDED`,读宿主自己的 `data-chat-flow-kind` / `data-variant`,React
171
- 插入节点的同一次提交里就带着,第一帧就是隐藏的);表达不了的放进
172
- MutationObserver 回调里同步做(`turn-fold.ts` 的 `markWholeRows`)——回调是
173
- 微任务,跑在这一帧渲染更新之前,两个方向都不会晚。rAF 只留给能容忍晚一帧的
174
- 活(全量重扫、插 chip、改文案)。
175
- - **别用 `:has()` 写死宿主的 DOM 层数**:包裹层数不等于组件源码看上去的层数
176
- ——槽位会额外套一层(真实是 `flowItem > seat > AssistantMarkdown root >
177
- body > 块`,比组件自身结构多一层)。深度猜错的选择器**静默失效**:测试全绿、
178
- 控制台干净,只有拿真界面量才看得出来。需要结构判断就用 JS 遍历
179
- (`foldsWholeRow` 那种沿父节点上溯的写法),不假设层数。
180
- - **同机联调必须用局域网 IP 连网关**:回环地址且无转发头的请求被网关判定为「本机直连」,不校验设备令牌——同一台机器上同时跑服务端与子客户端做开发测试时,`serverUrl` 要填局域网 IP 而不是 `127.0.0.1` / `localhost`。只影响这种同机开发测试,日常两台机器(子客户端经内网/公网地址连服务端)的使用不受影响。
181
- - **改了界面就去真界面上量**:`turn-fold` 这类规则用自建的静态页面验证会给出
182
- 假阳性(合成 DOM 猜不对宿主的包裹层)。打开 `127.0.0.1:3080` 读现有会话的
183
- `getComputedStyle` 即可,**不要为了验证去建会话或发消息**(耗真实 token,见
184
- 工作区 AGENTS.md 的硬约束)。注意浏览器标签在后台时 rAF 不触发,chip 会是 0
185
- 个——那是观测假象,不是回归,截图把窗口唤到前台再看。
@@ -1,15 +0,0 @@
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 内部」的一类,需要形态检测。
@@ -1,8 +0,0 @@
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 字节隧道」一节。