dsh-zen-remote 2.0.3 → 2.2.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 (119) 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 +57 -0
  10. package/lib/activity.js +25 -0
  11. package/lib/activity.js.map +1 -1
  12. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  13. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  14. package/lib/adapters/dsh-better-sidebar/methods.js +221 -0
  15. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  16. package/lib/adapters/index.js +17 -0
  17. package/lib/adapters/index.js.map +1 -0
  18. package/lib/adapters/types.js +17 -0
  19. package/lib/adapters/types.js.map +1 -0
  20. package/lib/admin-routes.js +51 -0
  21. package/lib/admin-routes.js.map +1 -1
  22. package/lib/client-routes.js +18 -1
  23. package/lib/client-routes.js.map +1 -1
  24. package/lib/client.js +449 -45
  25. package/lib/config.js +27 -0
  26. package/lib/config.js.map +1 -1
  27. package/lib/http.js +10 -0
  28. package/lib/http.js.map +1 -1
  29. package/lib/index.js +273 -1
  30. package/lib/index.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 +319 -2
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +633 -0
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/activity.d.ts +22 -0
  46. package/lib/types/activity.d.ts.map +1 -1
  47. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  48. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  49. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +58 -0
  50. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  51. package/lib/types/adapters/index.d.ts +17 -0
  52. package/lib/types/adapters/index.d.ts.map +1 -0
  53. package/lib/types/adapters/types.d.ts +42 -0
  54. package/lib/types/adapters/types.d.ts.map +1 -0
  55. package/lib/types/admin-routes.d.ts +16 -0
  56. package/lib/types/admin-routes.d.ts.map +1 -1
  57. package/lib/types/client/effects/remote-session.d.ts +0 -22
  58. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  59. package/lib/types/client/locales.d.ts +16 -0
  60. package/lib/types/client/locales.d.ts.map +1 -1
  61. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  62. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  63. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  64. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  65. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  66. package/lib/types/client-data/remote-status.d.ts +20 -0
  67. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  68. package/lib/types/client-data/settings-form.d.ts +37 -1
  69. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  70. package/lib/types/client-routes.d.ts +13 -0
  71. package/lib/types/client-routes.d.ts.map +1 -1
  72. package/lib/types/config.d.ts +8 -0
  73. package/lib/types/config.d.ts.map +1 -1
  74. package/lib/types/http.d.ts +7 -0
  75. package/lib/types/http.d.ts.map +1 -1
  76. package/lib/types/index.d.ts +4 -0
  77. package/lib/types/index.d.ts.map +1 -1
  78. package/lib/types/plugin-adapter-core.d.ts +263 -0
  79. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  80. package/lib/types/plugin-bridge.d.ts +187 -0
  81. package/lib/types/plugin-bridge.d.ts.map +1 -0
  82. package/lib/types/plugin-loopback.d.ts +104 -0
  83. package/lib/types/plugin-loopback.d.ts.map +1 -0
  84. package/lib/types/plugin-route-intercept.d.ts +114 -0
  85. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  86. package/lib/types/plugin-tunnel.d.ts +96 -0
  87. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  88. package/lib/types/relay-client.d.ts +76 -0
  89. package/lib/types/relay-client.d.ts.map +1 -1
  90. package/lib/types/relay-server.d.ts +32 -1
  91. package/lib/types/relay-server.d.ts.map +1 -1
  92. package/llms.txt +1 -0
  93. package/package.json +2 -2
  94. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  95. package/scripts/check-settings-form.mjs +3 -1
  96. package/src/activity.ts +32 -0
  97. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  98. package/src/adapters/dsh-better-sidebar/methods.ts +244 -0
  99. package/src/adapters/index.ts +19 -0
  100. package/src/adapters/types.ts +44 -0
  101. package/src/admin-routes.ts +64 -0
  102. package/src/client/effects/remote-session.ts +82 -10
  103. package/src/client/locales.ts +36 -0
  104. package/src/client/settings/SettingsSection.tsx +107 -3
  105. package/src/client/styles/remote-session.css.ts +70 -31
  106. package/src/client-data/plugin-bridge.ts +150 -0
  107. package/src/client-data/remote-status.ts +30 -0
  108. package/src/client-data/settings-form.ts +78 -0
  109. package/src/client-routes.ts +29 -1
  110. package/src/config.ts +32 -0
  111. package/src/http.ts +12 -0
  112. package/src/index.ts +279 -1
  113. package/src/plugin-adapter-core.ts +530 -0
  114. package/src/plugin-bridge.ts +435 -0
  115. package/src/plugin-loopback.ts +299 -0
  116. package/src/plugin-route-intercept.ts +1193 -0
  117. package/src/plugin-tunnel.ts +470 -0
  118. package/src/relay-client.ts +394 -2
  119. package/src/relay-server.ts +656 -1
@@ -51,6 +51,11 @@ export interface RemoteStatusView {
51
51
  versionMismatch: boolean
52
52
  serverName: string
53
53
  closed: Readonly<Record<string, RemoteClosedReason>>
54
+ /** The plugins whose bridge verdict is bridged RIGHT NOW (PB-2; sorted
55
+ * server-side). A garbage or missing field degrades to the empty list —
56
+ * nothing is ever considered bridged on a shape the parse does not
57
+ * trust. */
58
+ bridgedPlugins: readonly string[]
54
59
  /** When this view was parsed off a GET body. */
55
60
  asOf: number
56
61
  }
@@ -80,10 +85,35 @@ export function parseRemoteStatusBody(body: unknown, now: number): RemoteStatusV
80
85
  versionMismatch: record.versionMismatch === true,
81
86
  serverName: typeof record.serverName === 'string' ? record.serverName : '',
82
87
  closed,
88
+ bridgedPlugins: Array.isArray(record.bridgedPlugins)
89
+ ? record.bridgedPlugins.filter((name): name is string => typeof name === 'string' && name !== '')
90
+ : [],
83
91
  asOf: now,
84
92
  }
85
93
  }
86
94
 
95
+ // --- the bridge stamp (PB-14) -----------------------------------------------
96
+
97
+ /**
98
+ * Whether ONE plugin's bridge verdict is bridged RIGHT NOW, judged off the
99
+ * `bridgedPlugins` list the remote-status body carries. Tolerant by the
100
+ * same discipline as the parse: a garbage list (an older server that never
101
+ * learned the field, a broken body) reads as "not bridged" — the hiding
102
+ * rules stay in force unless the server positively says otherwise, never
103
+ * the reverse. This is the pure core behind the
104
+ * `html[data-zr-bridge-<plugin>]` stamp the remote-session stylesheet
105
+ * spends.
106
+ */
107
+ export function shouldStampBridgeAttribute(bridgedPlugins: unknown, plugin: string): boolean {
108
+ return Array.isArray(bridgedPlugins) && bridgedPlugins.includes(plugin)
109
+ }
110
+
111
+ /** The plugin the bridge stamp names. */
112
+ export const BRIDGE_STAMP_PLUGIN = 'dsh-better-sidebar'
113
+
114
+ /** The html attribute the stamp writes (`data-zr-bridge-dsh-better-sidebar`). */
115
+ export const BRIDGE_STAMP_ATTRIBUTE = `data-zr-bridge-${BRIDGE_STAMP_PLUGIN}`
116
+
87
117
  // --- the icon's states -------------------------------------------------------
88
118
 
89
119
  /** The locale keys `describeRemoteStatus` needs. */
@@ -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
+ }