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
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { RESTART_FIELDS } from '../restart-fields.ts'
29
+ import { derivePluginBridgeStatuses, type PluginBridgeStatusView } from './plugin-bridge.ts'
29
30
 
30
31
  // --- wire shapes -----------------------------------------------------------
31
32
 
@@ -88,6 +89,8 @@ export const ADMIN_STATUS_ROUTE = '/_dsh/zen-remote/admin/status'
88
89
  export const ADMIN_PAIR_ROUTE = '/_dsh/zen-remote/admin/pair'
89
90
  export const ADMIN_ACTION_ROUTE = '/_dsh/zen-remote/admin/action'
90
91
  export const ADMIN_PUSH_TEST_ROUTE = '/_dsh/zen-remote/admin/push-test'
92
+ /** PB-5: the plugin bridge's registered-plugin listing (GET, server-local only). */
93
+ export const ADMIN_PLUGIN_BRIDGE_ROUTE = '/_dsh/zen-remote/admin/plugin-bridge'
91
94
 
92
95
  /** Same-origin client routes the sub-client block talks to (host half: T16). */
93
96
  export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
@@ -153,6 +156,13 @@ export interface ClientStatusBody {
153
156
  * string[], unavailable: string[], incompatibleCalls: { time: number,
154
157
  * endpoint: string, code: string }[] }` (presence-gated). */
155
158
  compat?: unknown
159
+ /** PB-2: the per-plugin bridge verdicts (`BridgeStatus[]` verbatim,
160
+ * presence-gated — older servers answer without it). */
161
+ pluginBridge?: unknown
162
+ /** PB-4: the plugin-route interception's diagnostics (presence-gated —
163
+ * a host role or a refused install may still answer it; a server without
164
+ * the field never does). */
165
+ pluginRouteIntercept?: unknown
156
166
  }
157
167
 
158
168
  /** One remote-call failure line in the diagnostics lists. `time` is epoch
@@ -200,6 +210,13 @@ export interface ClientConnectionView {
200
210
  deviceName: string
201
211
  intercept: ClientInterceptView | undefined
202
212
  compat: ClientCompatView | undefined
213
+ /** PB-5: the bridge verdicts as the 诊断区 renders them, input order kept
214
+ * (undefined when the body carried none — the group stays hidden). */
215
+ pluginBridge: PluginBridgeStatusView[] | undefined
216
+ /** PB-5: the route interception's install state (`installed: false`
217
+ * renders the 本机改道未启用 line with the shape reasons; undefined when
218
+ * the body carried none). */
219
+ pluginRouteIntercept: { installed: boolean; reasons: string[] } | undefined
203
220
  }
204
221
 
205
222
  const CLIENT_STATES: readonly ClientConnectionView['state'][] = ['unpaired', 'connected', 'revoked', 'unreachable', 'unexpected', 'invalid-url', 'incompatible']
@@ -258,6 +275,18 @@ function deriveInterceptView(value: unknown): ClientInterceptView | undefined {
258
275
  }
259
276
  }
260
277
 
278
+ /** PB-4 route-interception block, presence-gated the same way: only the
279
+ * install flag and the shape reasons (the 本机改道未启用 line) — the
280
+ * counters belong to the status surface, not this copy. */
281
+ function derivePluginRouteInterceptView(value: unknown): { installed: boolean; reasons: string[] } | undefined {
282
+ if (value === undefined || value === null) return undefined
283
+ const record = asRecord(value)
284
+ return {
285
+ installed: record.installed === true,
286
+ reasons: stringListOf(record.reasons, 20),
287
+ }
288
+ }
289
+
261
290
  /** T42 compat block, only when the body carried the field at all. The wire
262
291
  * names are the status route's (`different` / `incompatibleCalls`); the view
263
292
  * keeps the render-side names the block was written against. */
@@ -302,6 +331,8 @@ export function deriveClientStatusView(body: ClientStatusBody): ClientConnection
302
331
  deviceName: typeof safe.deviceName === 'string' ? safe.deviceName : '',
303
332
  intercept: deriveInterceptView(safe.intercept),
304
333
  compat: deriveCompatView(safe.compat),
334
+ pluginBridge: derivePluginBridgeStatuses(safe.pluginBridge),
335
+ pluginRouteIntercept: derivePluginRouteInterceptView(safe.pluginRouteIntercept),
305
336
  }
306
337
  }
307
338
 
@@ -641,6 +672,47 @@ export function boolField(field: string): SettingsFieldSpec {
641
672
  }
642
673
  }
643
674
 
675
+ /**
676
+ * `pluginBridgeConsent` (PB-5): a name → boolean map staged as its canonical
677
+ * JSON draft. Nothing renders it as text — the 插件桥 toggles stage the NEXT
678
+ * map wholesale (`togglePluginConsent` builds it, one key per click) — but
679
+ * riding the shared spec table is what puts the field on the SAME save path
680
+ * as `autoShareNewSessions`: one staged revision, one save button, one
681
+ * atomic mutation. `format` keeps the value's own key order so a staged
682
+ * draft equal to the displayed value reads as "no change"; an illegal stored
683
+ * map formats as `{}` (the toggle then writes from scratch, and the junk
684
+ * stays refused by the row validator, never laundered through).
685
+ */
686
+ export function consentField(field: string): SettingsFieldSpec {
687
+ const format = (value: unknown): string => {
688
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return '{}'
689
+ const out: Record<string, boolean> = {}
690
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
691
+ if (typeof entry === 'boolean') out[key] = entry
692
+ }
693
+ return JSON.stringify(out)
694
+ }
695
+ return {
696
+ field,
697
+ format,
698
+ parse: (text) => {
699
+ let parsed: unknown
700
+ try {
701
+ parsed = JSON.parse(text)
702
+ } catch {
703
+ return undefined
704
+ }
705
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
706
+ const out: Record<string, boolean> = {}
707
+ for (const [key, entry] of Object.entries(parsed as Record<string, unknown>)) {
708
+ if (typeof entry !== 'boolean') return undefined
709
+ out[key] = entry
710
+ }
711
+ return { kind: 'set', value: out }
712
+ },
713
+ }
714
+ }
715
+
644
716
  /** Names of the row fields the settings page edits, in group order. */
645
717
  export type SettingsFieldName =
646
718
  | 'role'
@@ -657,6 +729,7 @@ export type SettingsFieldName =
657
729
  | 'serverName'
658
730
  | 'idleHours'
659
731
  | 'autoShareNewSessions'
732
+ | 'pluginBridgeConsent'
660
733
 
661
734
  /** Longest legal `serverName`, mirroring src/config.ts's cap. */
662
735
  export const SERVER_NAME_MAX = 40
@@ -680,6 +753,7 @@ export const SETTINGS_FIELDS: readonly SettingsFieldSpec[] = [
680
753
  nameField('serverName', SERVER_NAME_MAX),
681
754
  hoursField('idleHours', IDLE_HOURS_MAX),
682
755
  boolField('autoShareNewSessions'),
756
+ consentField('pluginBridgeConsent'),
683
757
  ]
684
758
 
685
759
  // --- the status -> view mapping ---------------------------------------------
@@ -894,6 +968,10 @@ export interface ZenRemoteFormState extends SettingsFormShellState {
894
968
  serverName: SettingsFieldState
895
969
  idleHours: SettingsFieldState
896
970
  autoShareNewSessions: SettingsFieldState
971
+ /** The consent map's staged state (PB-5). No text control renders it — the
972
+ * 插件桥 toggles stage the next map wholesale — but its `invalid` flag
973
+ * (a malformed draft) still gates the save like any field's. */
974
+ pluginBridgeConsent: SettingsFieldState
897
975
  deviceToken: SettingsSecretFieldState
898
976
  /** A restart-required field ({@link RESTART_FIELDS}) has a staged change a
899
977
  * save would actually write: the save will move the row and the host
@@ -31,7 +31,9 @@ import type { ClaimOutcome, ProbeState } from './client-pairing.js'
31
31
  import { unwrapVolatile } from './config.js'
32
32
  import type { FetchRouteInterceptDiagnostics } from './fetch-route-intercept.js'
33
33
  import type { CurrentSessionRead, InterceptDiagnostics } from './intercept.js'
34
+ import type { PluginRouteInterceptDiagnostics } from './plugin-route-intercept.js'
34
35
  import { responseJson, sameOriginPost } from './http.js'
36
+ import type { BridgeStatus } from './plugin-bridge.js'
35
37
  import { RELAY_HTTP_ROUTES } from './relay-access.js'
36
38
  import { relayCredentialsDigest, RelayError } from './relay-client.js'
37
39
  import type { RelayClient, RelayHttpResult } from './relay-client.js'
@@ -118,6 +120,17 @@ export interface ClientHandlerOptions {
118
120
  * its refusal counters. Undefined when this composition never attempted
119
121
  * one (no connection service, or no fileUploads service to inject). */
120
122
  getFetchRouteIntercept?: () => FetchRouteInterceptDiagnostics | undefined
123
+ /** The plugin-route interception's diagnostics (PB-4), read live per
124
+ * request: the local webServer `match` wrap that forwards bridged-plugin
125
+ * requests for remote sessions. Undefined when this composition never
126
+ * attempted one (a host role, or no webServer). */
127
+ getPluginRouteIntercept?: () => PluginRouteInterceptDiagnostics | undefined
128
+ /** The plugin bridge's live verdict (PB-2), computed PER CALL off the local
129
+ * registry, the last handshake's `plugins` table and the connection state —
130
+ * never a stored conclusion. Wired only by the CLIENT role's mount; absent
131
+ * (the host mount, old tests) both routes answer the empty shape: status's
132
+ * `pluginBridge: []` and remote-status's `bridgedPlugins: []`. */
133
+ getPluginBridgeStatus?: () => BridgeStatus[]
121
134
  /** Serve ONLY the remote-status route (T41a-fix2, the host role's mount):
122
135
  * every other route under the prefix answers the same 404 an unknown path
123
136
  * would. The claim / status / reconnect / unshare routes exist for a
@@ -456,6 +469,7 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
456
469
  // that.
457
470
  const intercept = options.getIntercept?.()
458
471
  const fetchRouteIntercept = options.getFetchRouteIntercept?.()
472
+ const pluginRouteIntercept = options.getPluginRouteIntercept?.()
459
473
  const compatRelay = options.getRelayClient?.()
460
474
  const compat =
461
475
  compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
@@ -467,8 +481,13 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
467
481
  }
468
482
  : undefined
469
483
  const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
470
- let next = intercept === undefined ? body : { ...body, intercept }
484
+ // PB-2: the per-plugin bridge verdict rides EVERY status answer —
485
+ // the settings surface explains a not-bridged plugin whatever the
486
+ // connection state (unpaired reads all-offline, the honest verdict).
487
+ let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
488
+ if (intercept !== undefined) next = { ...next, intercept }
471
489
  if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
490
+ if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
472
491
  return compat === undefined ? next : { ...next, compat }
473
492
  }
474
493
  // The ROW decides its own two shapes first, per request (both fields
@@ -571,6 +590,11 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
571
590
  // T34: the T34 client parts' data source — no token, no server
572
591
  // address, nothing but the serving state, the compat verdict's
573
592
  // difference flag, the display name and the closed-session map.
593
+ // PB-2 adds `bridgedPlugins`: the names whose bridge verdict is
594
+ // bridged RIGHT NOW, sorted (the list arrives plugin-sorted, but the
595
+ // sort is pinned here so the field's shape never depends on the
596
+ // registry's internal order). The host mount — no verdict source —
597
+ // answers [].
574
598
  const relay = options.getRelayClient?.()
575
599
  const intercept = options.getIntercept?.()
576
600
  responseJson(res, 200, {
@@ -578,6 +602,10 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
578
602
  versionMismatch: (relay?.compat?.different?.length ?? 0) > 0,
579
603
  serverName: relay?.handshakeInfo?.serverName ?? '',
580
604
  closed: Object.fromEntries((intercept?.closedSessions ?? []).map((record) => [record.sessionId, record.reason])),
605
+ bridgedPlugins: (options.getPluginBridgeStatus?.() ?? [])
606
+ .filter((status) => status.bridged)
607
+ .map((status) => status.plugin)
608
+ .sort(),
581
609
  })
582
610
  return
583
611
  }
package/src/config.ts CHANGED
@@ -77,6 +77,12 @@ export interface ZenRemoteConfig {
77
77
  idleHours: number
78
78
  /** Auto-share every newly created session to the paired phone (T16). */
79
79
  autoShareNewSessions: boolean
80
+ /** Per-plugin consent for bridging whole-machine data (plugin bridge,
81
+ * PB-1): plugin name → whether the server owner lets that plugin's
82
+ * `scope:'server'` routes be forwarded inside remote sessions. Absent or
83
+ * false means NO — default `{}` keeps every plugin unconsented. Volatile:
84
+ * read at compare time, deliberately NOT in RESTART_FIELDS. */
85
+ pluginBridgeConsent: Record<string, boolean>
80
86
  }
81
87
 
82
88
  function defaultServerName(): string {
@@ -101,6 +107,7 @@ export const DEFAULTS: ZenRemoteConfig = {
101
107
  serverName: defaultServerName(),
102
108
  idleHours: 48,
103
109
  autoShareNewSessions: false,
110
+ pluginBridgeConsent: {},
104
111
  }
105
112
 
106
113
  // --- per-field validators -------------------------------------------------
@@ -176,6 +183,22 @@ function serverNameOf(value: unknown): string | undefined {
176
183
  return name === undefined ? undefined : name.slice(0, SERVER_NAME_MAX)
177
184
  }
178
185
 
186
+ /** `pluginBridgeConsent`: a plain object mapping plugin names to STRICT
187
+ * booleans. Anything else — null, an array, a non-boolean value (`"yes"`,
188
+ * `1`; unlike the scalar booleans there is no historic `1`/`"1"` convention
189
+ * to honor, this map only ever came from the settings surface or a
190
+ * hand-edited row/file) — makes the layer transparent, because a half-valid
191
+ * consent map must never read as "consented". Object keys are strings by
192
+ * construction in both JSON and YAML; the per-key string check stays as the
193
+ * cheap belt over the iteration. */
194
+ function consentMap(value: unknown): Record<string, boolean> | undefined {
195
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
196
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
197
+ if (typeof key !== 'string' || typeof entry !== 'boolean') return undefined
198
+ }
199
+ return value as Record<string, boolean>
200
+ }
201
+
179
202
  /** Env validator applied to `Number(env[name])`, so `LAN_GATE_PORT='abc'`
180
203
  * lands in the same "unset" bucket as a bad row value. */
181
204
  function envNumber(
@@ -263,6 +286,10 @@ const FIELDS: { [K in keyof ZenRemoteConfig]: FieldResolver<ZenRemoteConfig[K]>
263
286
  serverName: { row: serverNameOf, file: serverNameOf },
264
287
  idleHours: { row: inRange(0, 8760), file: inRange(0, 8760) },
265
288
  autoShareNewSessions: { row: boolLike, file: boolLike },
289
+ // No env layer on purpose (PB-1): a per-plugin consent map has no historic
290
+ // flat variable to stay compatible with, and a consent decision is a
291
+ // settings-page action, not something set through the environment.
292
+ pluginBridgeConsent: { row: consentMap, file: consentMap },
266
293
  }
267
294
 
268
295
  /**
@@ -346,6 +373,7 @@ export function resolveConfig(
346
373
  serverName: resolveField('serverName', safeRow, safeFile, env, sources),
347
374
  idleHours: resolveField('idleHours', safeRow, safeFile, env, sources),
348
375
  autoShareNewSessions: resolveField('autoShareNewSessions', safeRow, safeFile, env, sources),
376
+ pluginBridgeConsent: resolveField('pluginBridgeConsent', safeRow, safeFile, env, sources),
349
377
  }
350
378
  return { values, sources }
351
379
  }
@@ -432,6 +460,10 @@ export const Config = z.object({
432
460
  serverName: z.any().volatile(),
433
461
  idleHours: z.any().volatile(),
434
462
  autoShareNewSessions: z.any().volatile(),
463
+ // Plugin-bridge consent (PB-1): a name → boolean map, live-read at compare
464
+ // time — a consent flip must apply without a row restart, so it is
465
+ // deliberately NOT in RESTART_FIELDS.
466
+ pluginBridgeConsent: z.any().volatile(),
435
467
  // Client-half knobs (T16): where the phone finds the gateway, and the
436
468
  // pairing token it presents. `role('secret')` keeps the token out of every
437
469
  // settings wire surface, like the verifier plugin's API key.
package/src/http.ts CHANGED
@@ -66,3 +66,15 @@ export function sameOriginPost(req: IncomingMessage): boolean {
66
66
  return false
67
67
  }
68
68
  }
69
+
70
+
71
+ /**
72
+ * One plugin-bridge refusal (PB-3): the standard JSON envelope plus the
73
+ * `x-zen-remote-error` marker header — the sub-client's passthrough layer
74
+ * classifies a refusal off the header alone, because the success path
75
+ * streams opaque plugin bytes and must never have to parse a body.
76
+ */
77
+ export function pluginBridgeError(res: ServerResponse, status: number, code: string): void {
78
+ res.setHeader('x-zen-remote-error', code)
79
+ responseJson(res, status, { ok: false, error: { code } })
80
+ }
package/src/index.ts CHANGED
@@ -59,8 +59,22 @@ import { checkGatewayShape } from './intercept-shape.js'
59
59
  import type { GatewayShapeCheck } from './intercept-shape.js'
60
60
  import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
61
61
  import type { FetchRouteInterceptDiagnostics, FetchRouteInterceptHandle, FetchRouteShapeCheck } from './fetch-route-intercept.js'
62
+ import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js'
63
+ import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebServerShapeCheck } from './plugin-route-intercept.js'
64
+ import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js'
65
+ import type { NormalizedAdapter } from './plugin-adapter-core.js'
66
+ import { ADAPTERS } from './adapters/index.js'
62
67
  import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
63
68
  import type { RelayGateway, RelayHandler } from './relay-server.js'
69
+ import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
70
+ import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
71
+ import { createPluginLoopback } from './plugin-loopback.js'
72
+ import { createPluginTunnel } from './plugin-tunnel.js'
73
+
74
+ // The plugin bridge's public types (PB-1), re-exported so another plugin's
75
+ // author can `import type { … } from 'dsh-zen-remote'` without reaching into
76
+ // lib/types. Type-only: no runtime code, no Context augmentation.
77
+ export type { BridgeDeclaration, ZenRemoteService } from './plugin-bridge.js'
64
78
 
65
79
  // The loader's config schema (settings form) and the role normalizer live in
66
80
  // src/config.ts next to the resolution they describe; re-exported so the
@@ -126,6 +140,22 @@ export function getRelayClient(): RelayClient | undefined {
126
140
  return clientRelayClient
127
141
  }
128
142
 
143
+ /**
144
+ * The plugin bridge's declaration registry (PB-1), created inside apply() on
145
+ * BOTH roles and kept on the module so later tasks (the handshake's plugins
146
+ * table, the client-side rewrite) can reach it without reaching into a
147
+ * context. Undefined in compositions that never ran this module's apply, and
148
+ * — identity-guarded below — after the row that owned a registry was
149
+ * unloaded or reloaded, so a reloaded row never leaves a stale table where
150
+ * the getter can hand it out (the T23a-fix discipline).
151
+ */
152
+ let bridgeRegistry: BridgeRegistry | undefined
153
+
154
+ /** The live bridge registry, if a plugin row is currently applied. */
155
+ export function getBridgeRegistry(): BridgeRegistry | undefined {
156
+ return bridgeRegistry
157
+ }
158
+
129
159
  /** Host half config. Every field below arrives through the loader as a
130
160
  * volatile live reference — a `{ get() }` wrapper over the stored value, not
131
161
  * the value itself (src/config.ts explains why every field is volatile) — so
@@ -461,6 +491,76 @@ export async function handleUpload(
461
491
  */
462
492
  export function apply(ctx: Context, config: MobileNavConfig = {}): void {
463
493
  const effective = resolveConfig(config, readFileConfig(), process.env)
494
+ // The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
495
+ // before any role branch: a plugin that wants its panel data forwarded in
496
+ // remote sessions declares that against THIS process whichever role it
497
+ // runs as, and the declaration registry is the one piece of PB-1 both the
498
+ // server half (handshake plugins table) and the client half (route
499
+ // rewrite) will read. ctx.provide (cordis 4) registers the service owned
500
+ // by THIS row's fiber: visible to `inject(['zenRemote'])` dependents once
501
+ // the row is active, withdrawn when the row unloads — so a composition
502
+ // without zen-remote never fires those injects and a row reload swaps the
503
+ // service wholesale. The registry goes into the module slot BEFORE the
504
+ // provide so this apply's consumers never see a window without it; the
505
+ // effect's identity guard then clears the slot exactly when THIS row
506
+ // unloads (a reload installs the new registry before the old disposer
507
+ // runs, same shape as clientRelayClient). provide() throwing is not worth
508
+ // failing the row over — the bridge stays off, everything else keeps
509
+ // working — so it degrades to a warning like the session feeds do.
510
+ const registry = createBridgeRegistry({
511
+ warn: (text) => { ctx.logger?.warn('dsh-zen-remote plugin bridge: %s', text) },
512
+ })
513
+ bridgeRegistry = registry
514
+ try {
515
+ const service: ZenRemoteService = {
516
+ bridge: (declaration: BridgeDeclaration) => registry.register(declaration),
517
+ }
518
+ ctx.provide('zenRemote', service)
519
+ } catch (error) {
520
+ ctx.logger?.warn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error))
521
+ }
522
+ ctx.effect(() => () => {
523
+ if (bridgeRegistry === registry) bridgeRegistry = undefined
524
+ }, 'dsh-zen-remote: plugin bridge registry disposal')
525
+ // The adapters (PB-11): each one registers ONLY against its target's
526
+ // EXACT installed version, probed off the host's `pluginPackages` service
527
+ // (`packageOf(name, ctx.baseUrl)` — verified on 0.2.0-rc.2 to answer
528
+ // `{name, version, dir, …}` or undefined; any other shape, a missing
529
+ // service or a probe failure reads as "not installed" and the adapter
530
+ // stays out). A plugin that registers its OWN cooperative declaration
531
+ // always wins: the adapter yields at registration time (logged), and the
532
+ // runtime re-checks the registry digest on every request. Both roles run
533
+ // this loop — the registry entries drive the handshake table, the bridge
534
+ // verdicts and the settings listing on either side, while the returned
535
+ // array feeds the two request halves.
536
+ const adapters: NormalizedAdapter[] = []
537
+ {
538
+ const adapterWarn = (text: string): void => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text) }
539
+ for (const adapter of ADAPTERS) {
540
+ try {
541
+ const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
542
+ const packages = holder.get?.('pluginPackages') as { packageOf?: (specifier: string, parentURL: string) => unknown } | undefined
543
+ const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown } | undefined
544
+ const installedVersion = found !== null && typeof found === 'object' && typeof (found as { version?: unknown }).version === 'string'
545
+ ? (found as { version: string }).version
546
+ : undefined
547
+ if (installedVersion === undefined || installedVersion === '') {
548
+ adapterWarn(`${adapter.plugin}: target package (or its version) not found; not registered`)
549
+ continue
550
+ }
551
+ const normalized = normalizeAdapter(adapter, installedVersion)
552
+ if (registry.get(normalized.plugin) !== undefined) {
553
+ adapterWarn(`${normalized.plugin}: the plugin registers its own bridge declaration; the adapter yields`)
554
+ continue
555
+ }
556
+ registry.registerNormalized(normalized.declaration)
557
+ adapters.push(normalized)
558
+ ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
559
+ } catch (error) {
560
+ adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
561
+ }
562
+ }
563
+ }
464
564
  if (effective.values.role === 'host') {
465
565
  // A row that switched from client to host must not leave a stale relay
466
566
  // client where getRelayClient() can find it (T23a-fix).
@@ -597,6 +697,16 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
597
697
  // composition lacks the service.
598
698
  viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
599
699
  sessionQuery: () => webCtx.reflect.get('sessionQuery') as SessionQueryLike | undefined,
700
+ // The plugin bridge listing (PB-5): the SAME registry this apply
701
+ // built and the SAME live consent reader the relay's plugins table
702
+ // uses — one source of truth per question. PB-11: the listing
703
+ // marks the plugins an adapter stands in for (live — a plugin
704
+ // that registered its own declaration stops being marked).
705
+ pluginBridge: {
706
+ registry,
707
+ getConsent: () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent,
708
+ adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
709
+ },
600
710
  }),
601
711
  }), 'dsh-zen-remote: admin routes')
602
712
  })
@@ -645,6 +755,32 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
645
755
  // ~7 ms per computation against a full registry; a failed compute
646
756
  // lands as the empty map inside the relay handler's own guard.
647
757
  relayCtx.effect(() => {
758
+ // The plugin bridge's loopback facts (PB-3), both read PER REQUEST
759
+ // through the reflection layer: the web server may have restarted on
760
+ // another port or protocol, and the connection service's
761
+ // authenticatedUrl — the token-exchange entry the lan-gate wiring
762
+ // also reads (0.1.1 hosts lack the method entirely) — lives on a
763
+ // service deliberately NOT in this inject's declaration list (same
764
+ // reason as getApiFetch above: adding it would keep the whole relay
765
+ // prefix off compositions that lack it).
766
+ const getLoopbackTarget = () => {
767
+ const webServer = relayCtx.reflect.get('webServer') as { protocol?: unknown; port?: unknown } | undefined
768
+ const port = typeof webServer?.port === 'number' && webServer.port > 0 ? webServer.port : undefined
769
+ if (port === undefined) throw new Error('the web server exposes no loopback port')
770
+ return { protocol: webServer?.protocol === 'https:' ? ('https:' as const) : ('http:' as const), port }
771
+ }
772
+ const getTokenUrl = () => {
773
+ const connection = relayCtx.reflect.get('connection') as
774
+ | { authenticatedUrl?: (base: string) => string | undefined }
775
+ | undefined
776
+ if (typeof connection?.authenticatedUrl !== 'function') return undefined
777
+ const { protocol, port } = getLoopbackTarget()
778
+ return connection.authenticatedUrl(`${protocol}//127.0.0.1:${port}`)
779
+ }
780
+ // One loopback executor for BOTH plugin halves (the HTTP forward and
781
+ // the PB-10 tunnel): a single cookie cache behind two consumers.
782
+ const pluginLoopback = createPluginLoopback({ getTarget: getLoopbackTarget, getTokenUrl })
783
+ const pluginConsent = () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent
648
784
  const handler = createRelayHandler({
649
785
  secret: relaySecret,
650
786
  store,
@@ -662,6 +798,43 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
662
798
  serverName: () => resolveConfig(config, readFileConfig(), process.env).values.serverName,
663
799
  dshVersion: resolveDshVersion(),
664
800
  fingerprints: () => computeFingerprints(relayCtx),
801
+ // PB-2: the plugin bridge table, rebuilt FRESH at every handshake
802
+ // from the PB-1 registry and the row's consent map — both live:
803
+ // the registry through this apply's own closure, the consent
804
+ // through resolveConfig on the SAME row object (a volatile field,
805
+ // read at use time per the hard constraint, never snapshotted).
806
+ plugins: () => {
807
+ const consent = resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent
808
+ const table: Record<string, ServerPluginEntry> = {}
809
+ for (const declaration of registry.list()) {
810
+ table[declaration.plugin] = {
811
+ version: declaration.version,
812
+ digest: declaration.digest,
813
+ serverScopeConsent: consent[declaration.plugin] === true,
814
+ }
815
+ }
816
+ return table
817
+ },
818
+ },
819
+ // PB-3: the forwarding half of the plugin bridge — the registry
820
+ // this apply built (the zenRemote service's table), the consent
821
+ // map read LIVE per request (a volatile row field), and the
822
+ // loopback executor over the wiring's two reflection getters.
823
+ // PB-10: the WebSocket byte tunnel's server half shares the SAME
824
+ // loopback (one cookie cache) and the same live consent; its
825
+ // reachability judgment rides the store directly (the handler's
826
+ // own isAccessible closure is not visible out here).
827
+ pluginBridge: {
828
+ registry,
829
+ getConsent: pluginConsent,
830
+ loopback: pluginLoopback,
831
+ adapters,
832
+ tunnel: createPluginTunnel({
833
+ bridge: { registry, getConsent: pluginConsent, loopback: pluginLoopback },
834
+ getTarget: getLoopbackTarget,
835
+ isAccessible: (sessionId) => store.isAccessible(sessionId, parentIndex.parentOf),
836
+ heartbeatMs: 15_000,
837
+ }),
665
838
  },
666
839
  })
667
840
  const unregister = relayCtx.webServer.register({
@@ -735,6 +908,15 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
735
908
  computeOwnFingerprints: () => computeFingerprints(ctx),
736
909
  })
737
910
  clientRelayClient = relayClient
911
+ // The plugin bridge's live verdict (PB-2), the CLIENT role's job — a host
912
+ // has no remote sessions of its own to bridge anything into. Computed PER
913
+ // CALL (never a stored conclusion): the local registry is this apply's
914
+ // own closure, the server's table is the LAST handshake's `plugins`
915
+ // (undefined until one succeeded — an old server reads server-too-old),
916
+ // and `online` is the client's CURRENT state — so a registry change, a
917
+ // fresh handshake or a link flip shows up at the very next read.
918
+ const pluginBridgeStatus = (): BridgeStatus[] =>
919
+ compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online')
738
920
  // A stopped or reloaded row must not leave a stale instance where the
739
921
  // module getter can hand it out (T23a-fix), and its reconnect machinery
740
922
  // (T43) must not outlive the row either: stop() cancels the armed retry
@@ -931,6 +1113,74 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
931
1113
  if (fetchRouteHandle === handle) fetchRouteHandle = undefined
932
1114
  }, 'dsh-zen-remote: fetch-route intercept')
933
1115
  })
1116
+ // The plugin bridge's route interception (PB-4): the local webServer's
1117
+ // `match` wrap forwarding bridged-plugin requests for REMOTE sessions
1118
+ // through the relay. Injected on `webServer` alone — the only service
1119
+ // it reads — and the raw instance comes off the reflection layer's
1120
+ // symbols lookup, the same face the typert and fetch-route wraps use.
1121
+ // The diagnostics flow to the client status route like theirs, refused
1122
+ // installs included.
1123
+ let pluginRouteHandle: PluginRouteInterceptHandle | undefined
1124
+ let pluginRouteRefused: WebServerShapeCheck | undefined
1125
+ const pluginRouteDiagnostics = (): PluginRouteInterceptDiagnostics | undefined => {
1126
+ if (pluginRouteHandle !== undefined) return pluginRouteHandle.diagnostics()
1127
+ if (pluginRouteRefused !== undefined) {
1128
+ return {
1129
+ installed: false,
1130
+ shape: pluginRouteRefused,
1131
+ forwarded: 0,
1132
+ refused: 0,
1133
+ refusalsByReason: {},
1134
+ recentFailures: [],
1135
+ adapterForwarded: 0,
1136
+ adapterResponseRewrites: 0,
1137
+ adapterResponsePassthroughs: 0,
1138
+ tunnelsCurrent: 0,
1139
+ tunnelsOpened: 0,
1140
+ tunnelsClosed: 0,
1141
+ }
1142
+ }
1143
+ return undefined
1144
+ }
1145
+ ctx.inject(['webServer'], (wsCtx) => {
1146
+ const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
1147
+ const proxy = (wsCtx as Context & { webServer?: unknown }).webServer
1148
+ const raw = proxy !== null && typeof proxy === 'object'
1149
+ ? (proxy as unknown as Record<PropertyKey, unknown>)[symbols.original]
1150
+ : undefined
1151
+ if (raw === null || typeof raw !== 'object') {
1152
+ pluginRouteRefused = { ok: false, reasons: ['original: webServer[symbols.original] is not an object'] }
1153
+ logWarn('dsh-zen-remote plugin-route intercept not installed: %s', pluginRouteRefused.reasons[0])
1154
+ return
1155
+ }
1156
+ // Shape first, like every wrap: a failed check leaves the webServer
1157
+ // exactly as it was — local routing untouched, remote bridging off.
1158
+ const shape = checkWebServerShape(raw)
1159
+ if (!shape.ok) {
1160
+ pluginRouteRefused = shape
1161
+ logWarn('dsh-zen-remote plugin-route intercept not installed: %s', shape.reasons.join('; '))
1162
+ return
1163
+ }
1164
+ const handle = installPluginRouteIntercept({
1165
+ webServer: raw,
1166
+ registry,
1167
+ relay: relayClient,
1168
+ getServerId: () => relayClient.handshakeInfo?.serverId,
1169
+ getStatus: pluginBridgeStatus,
1170
+ adapters,
1171
+ // PB-12: the browser's currently open session — a VIRTUAL id means
1172
+ // a remote panel is the request's origin, the signal the adapter
1173
+ // routes' deny hooks read (better-sidebar's open.external carries
1174
+ // no session field of its own).
1175
+ getCurrentSession: () => (reportedCurrentSession.kind === 'open' ? reportedCurrentSession.sessionId : undefined),
1176
+ log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1177
+ })
1178
+ pluginRouteHandle = handle
1179
+ wsCtx.effect(() => () => {
1180
+ handle.uninstall()
1181
+ if (pluginRouteHandle === handle) pluginRouteHandle = undefined
1182
+ }, 'dsh-zen-remote: plugin route intercept')
1183
+ })
934
1184
  ctx.inject(['webServer', 'connection'], (clientCtx) => {
935
1185
  clientCtx.effect(() => clientCtx.webServer.register({
936
1186
  kind: 'prefix',
@@ -941,6 +1191,8 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
941
1191
  getRelayClient: () => relayClient,
942
1192
  getIntercept: interceptDiagnostics,
943
1193
  getFetchRouteIntercept: fetchRouteDiagnostics,
1194
+ getPluginRouteIntercept: pluginRouteDiagnostics,
1195
+ getPluginBridgeStatus: pluginBridgeStatus,
944
1196
  onCurrentSession,
945
1197
  }),
946
1198
  }), 'dsh-zen-remote: client routes')
package/src/intercept.ts CHANGED
@@ -2644,7 +2644,10 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2644
2644
  return { ok: true, value: undefined }
2645
2645
  }
2646
2646
  recordFailure(endpoint, code)
2647
- return { ok: false, error: { code, message: messageOf(error), details: {} } }
2647
+ // The host's sanitized `details` rides along when present — its
2648
+ // `reason` codes (MODEL_DOES_NOT_SUPPORT_IMAGES, …) are what the UI
2649
+ // maps onto a user-facing line; without them it prints `undefined`.
2650
+ return { ok: false, error: { code, message: messageOf(error), details: error instanceof RelayError && isPlainObject(error.details) ? error.details : {} } }
2648
2651
  }
2649
2652
  })()
2650
2653
  }
@@ -2703,7 +2706,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2703
2706
  // A call on a closed session reads like the banner, not like the
2704
2707
  // relay's bare code (T73).
2705
2708
  const message = code === 'not-shared' ? CLOSED_MESSAGE : messageOf(error)
2706
- return { ok: false, error: { code, message, details: {} } }
2709
+ return { ok: false, error: { code, message, details: error instanceof RelayError && isPlainObject(error.details) ? error.details : {} } }
2707
2710
  }
2708
2711
  }
2709
2712