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
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { randomBytes, timingSafeEqual } from 'node:crypto'
30
+ import { Readable, Transform } from 'node:stream'
30
31
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
31
32
  import { createRequire } from 'node:module'
32
33
  import { join } from 'node:path'
@@ -42,6 +43,13 @@ import {
42
43
  filterWorkspaceFrame,
43
44
  } from './relay-filter.js'
44
45
  import { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri } from './session-reference.js'
46
+ import type { ServerPluginEntry, BridgeRegistry } from './plugin-bridge.js'
47
+ import { routeMethodsOf, routeMaxBodyOf } from './plugin-bridge.js'
48
+ import { createPluginTunnel, validatePluginCall, type PluginTunnelRoutes } from './plugin-tunnel.js'
49
+ import type { PluginLoopback } from './plugin-loopback.js'
50
+ import { isLoopbackAuthError } from './plugin-loopback.js'
51
+ import type { NormalizedAdapter, AdapterVerdict } from './plugin-adapter-core.js'
52
+ import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
45
53
 
46
54
  /**
47
55
  * Registration prefix on the host webServer. Deliberately WITHOUT the
@@ -134,6 +142,9 @@ const MAX_INVOKES_PER_DEVICE = 32
134
142
  */
135
143
  const MAX_UPLOADS_PER_DEVICE = 8
136
144
 
145
+ /** Concurrent bridged plugin GETs one device may keep in flight (PB-3). */
146
+ const MAX_PLUGIN_REQUESTS_PER_DEVICE = 8
147
+
137
148
  /** Most recent forwarded waterfall events remembered per `$zr/events`
138
149
  * subscription (T32-fix): the registry is only an ownership record, so this
139
150
  * bounds a pathological burst the same way the job cache does — past the
@@ -224,12 +235,17 @@ export interface RelayGateway {
224
235
  * callback too (T42), awaited per handshake so a just-restarted server never
225
236
  * serves a stale map; a THROWN compute degrades to the empty map here so a
226
237
  * broken fingerprint source can never fail the handshake itself — the
227
- * client's comparison judges every group `unavailable`, never `different`. */
238
+ * client's comparison judges every group `unavailable`, never `different`.
239
+ * `plugins` is the same discipline for the plugin bridge (PB-2): computed
240
+ * FRESH per handshake (the local registry and the row's consent map are both
241
+ * live), a THROWN compute degrades to the empty table — the handshake still
242
+ * succeeds and the client reads every local plugin `not-on-server`. */
228
243
  export interface RelayServerInfo {
229
244
  serverId: string
230
245
  serverName: () => string
231
246
  dshVersion: string
232
247
  fingerprints?: () => Record<string, string> | Promise<Record<string, string>>
248
+ plugins?: () => Record<string, ServerPluginEntry>
233
249
  }
234
250
 
235
251
  export interface RelayHandlerOptions {
@@ -268,6 +284,28 @@ export interface RelayHandlerOptions {
268
284
  * destroyed (the {@link DRAIN_DEADLINE_MS} bound); defaults to 10000.
269
285
  * Tests inject a small value so the deadline path stays cheap to drive. */
270
286
  drainDeadlineMs?: number
287
+ /** The plugin bridge's server half (PB-3). Absent — a wiring that never
288
+ * mounted it, old tests — and `POST relay/v1/plugin` answers 501
289
+ * `unsupported`, exactly like the `/api` passthrough without a dispatcher. */
290
+ pluginBridge?: {
291
+ /** THIS process's bridge declaration registry (the `zenRemote` service's
292
+ * backing table) — the source of truth for "which plugin, which version,
293
+ * which routes". */
294
+ registry: BridgeRegistry
295
+ /** The row's per-plugin whole-machine-data consent, read LIVE per request
296
+ * (a volatile row field — never snapshotted). */
297
+ getConsent: () => Record<string, boolean>
298
+ /** The loopback executor performing the actual plugin GET. */
299
+ loopback: PluginLoopback
300
+ /** The WebSocket byte tunnel's server half (PB-10). Absent — a wiring
301
+ * that predates the tunnels — and the three `plugin-tunnel` routes
302
+ * answer 501 `unsupported` like an unwired bridge. */
303
+ tunnel?: PluginTunnelRoutes
304
+ /** The validated adapters (PB-11), as the wiring registered them.
305
+ * Absent/empty — the pluggable default — and `v1/plugin` serves
306
+ * cooperative declarations only, exactly as before. */
307
+ adapters?: readonly NormalizedAdapter[]
308
+ }
271
309
  }
272
310
 
273
311
  /**
@@ -358,6 +396,18 @@ function responseJson(res: ServerResponse, status: number, body: unknown): void
358
396
  res.end(bytes)
359
397
  }
360
398
 
399
+ /**
400
+ * One plugin-bridge refusal (PB-3): the same JSON envelope as every other
401
+ * relay refusal, plus the `x-zen-remote-error` marker header — the
402
+ * sub-client's passthrough layer reads the CODE off a response whose body
403
+ * it must not have to parse (the success path streams opaque plugin bytes;
404
+ * keeping the refusal marker a header lets one code path classify both).
405
+ */
406
+ function relayPluginError(res: ServerResponse, status: number, code: string): void {
407
+ res.setHeader('x-zen-remote-error', code)
408
+ responseJson(res, status, { ok: false, error: { code } })
409
+ }
410
+
361
411
  /** First (and in practice only) value of one request header. */
362
412
  function headerValue(req: IncomingMessage, name: string): string | undefined {
363
413
  const raw = req.headers[name]
@@ -611,6 +661,16 @@ function drainUpload(req: IncomingMessage, fromBytes: number, deadlineMs: number
611
661
  })
612
662
  }
613
663
 
664
+
665
+ /** The 200 error envelope for a failed gateway call, `details` included when
666
+ * the host error carried a sanitized one (see errorOf). */
667
+ function errorEnvelopeOf(error: unknown): { ok: false; error: Record<string, unknown> } {
668
+ const { code, message, details } = errorOf(error)
669
+ const inner: Record<string, unknown> = message === undefined ? { code } : { code, message }
670
+ if (details !== undefined) inner.details = details
671
+ return { ok: false, error: inner }
672
+ }
673
+
614
674
  /**
615
675
  * What may travel to the client about a failed gateway call. DSH's own errors
616
676
  * always carry a `namespace/name`-shaped string `code` — those pass through
@@ -618,7 +678,7 @@ function drainUpload(req: IncomingMessage, fromBytes: number, deadlineMs: number
618
678
  * error whose `code` (`ENOENT`, …) and message (`… '/Users/x/…'`) quote the
619
679
  * server's filesystem — reports `internal` and NO message at all (T22a-fix).
620
680
  */
621
- function errorOf(error: unknown): { code: string; message?: string } {
681
+ function errorOf(error: unknown): { code: string; message?: string; details?: Record<string, unknown> } {
622
682
  const code =
623
683
  error !== null && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'string'
624
684
  ? (error as { code: string }).code
@@ -629,7 +689,23 @@ function errorOf(error: unknown): { code: string; message?: string } {
629
689
  // error would, so only the code travels.
630
690
  if (code.endsWith('/internal')) return { code }
631
691
  const text = error instanceof Error ? error.message : String(error)
632
- return { code, message: text.slice(0, MAX_MESSAGE_CHARS) }
692
+ // The structured `details` beside code/message (2026-10-10): the host
693
+ // rejects an image-bearing prompt with `session/attachment-invalid` whose
694
+ // `details.reason` (`MODEL_DOES_NOT_SUPPORT_IMAGES`, …) is what the client
695
+ // UI maps onto a user-facing line — dropped here, the UI prints
696
+ // `undefined`. Scalars and clipped strings only, bounded key count;
697
+ // nested objects/arrays stay out so server internals cannot ride this.
698
+ const raw = (error as { details?: unknown }).details
699
+ const details: Record<string, unknown> = {}
700
+ if (isPlainObject(raw)) {
701
+ for (const [key, value] of Object.entries(raw).slice(0, 16)) {
702
+ if (typeof value === 'string') details[key] = value.slice(0, MAX_MESSAGE_CHARS)
703
+ else if (typeof value === 'number' || typeof value === 'boolean' || value === null) details[key] = value
704
+ }
705
+ }
706
+ const out: { code: string; message?: string; details?: Record<string, unknown> } = { code, message: text.slice(0, MAX_MESSAGE_CHARS) }
707
+ if (Object.keys(details).length > 0) out.details = details
708
+ return out
633
709
  }
634
710
 
635
711
  /**
@@ -720,6 +796,17 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
720
796
  /** Uploads currently in flight per device id, for the upload budget (T51-fix). */
721
797
  const uploadsByDevice = new Map<string, number>()
722
798
 
799
+ /**
800
+ * Bridged plugin GETs currently in flight per device id (PB-3): the same
801
+ * per-device budget discipline as the invoke and upload counters above.
802
+ * The cap is 8 — the upload budget's number — because a bridged call is
803
+ * one panel's data fetch, a burst of a handful per session view; the slot
804
+ * stays taken for the WHOLE stream (a slow plugin response pins one slot
805
+ * until its last byte), which is exactly what the budget bounds. Past the
806
+ * cap the request answers 429 `busy` without touching the counter.
807
+ */
808
+ const pluginsByDevice = new Map<string, number>()
809
+
723
810
  /**
724
811
  * The device NAME this process last saw per device id (T59): every relay
725
812
  * request rides the gateway's fresh `x-zen-remote-device-name` marking
@@ -1005,6 +1092,19 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1005
1092
  } catch {
1006
1093
  fingerprints = {}
1007
1094
  }
1095
+ // PB-2: the plugin bridge table, computed FRESH per handshake (the
1096
+ // registry and the row's consent map are both live state), with the
1097
+ // SAME guard: a throwing source answers the empty table — the field
1098
+ // still travels, so the client reads "no plugins on this server"
1099
+ // (not-on-server), never a failed handshake. The field is ALWAYS
1100
+ // present in the reply: only its ABSENCE (an older server) can ever
1101
+ // mean server-too-old on the client.
1102
+ let plugins: Record<string, ServerPluginEntry> = {}
1103
+ try {
1104
+ plugins = serverInfo.plugins?.() ?? {}
1105
+ } catch {
1106
+ plugins = {}
1107
+ }
1008
1108
  responseJson(res, 200, {
1009
1109
  ok: true,
1010
1110
  relayProtocol: RELAY_PROTOCOL,
@@ -1014,6 +1114,9 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1014
1114
  // T42: the server's own interface fingerprints (empty when the wiring
1015
1115
  // had nothing to compute — the client judges group by group).
1016
1116
  fingerprints,
1117
+ // PB-2: the plugin bridge table (empty when the wiring had nothing to
1118
+ // register — the client judges plugin by plugin).
1119
+ plugins,
1017
1120
  // T59: the CALLING device's name, as the gateway's device table holds
1018
1121
  // it at this moment (the header is written fresh per forward, so a
1019
1122
  // just-renamed device reads its new name here). Undefined without the
@@ -1117,8 +1220,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1117
1220
  )) as unknown
1118
1221
  responseJson(res, 200, isPlainObject(envelope) ? envelope : { ok: false, error: { code: 'internal' } })
1119
1222
  } catch (error) {
1120
- const { code, message } = errorOf(error)
1121
- responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } })
1223
+ responseJson(res, 200, errorEnvelopeOf(error))
1122
1224
  } finally {
1123
1225
  res.off('close', onClientGone)
1124
1226
  }
@@ -1266,8 +1368,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1266
1368
  // The gateway's own failures (unknown namespace, absent service) are
1267
1369
  // business answers, not transport errors: they ride a 200 envelope
1268
1370
  // like every other result, with the gateway's string code preserved.
1269
- const { code, message } = errorOf(error)
1270
- responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } })
1371
+ responseJson(res, 200, errorEnvelopeOf(error))
1271
1372
  } finally {
1272
1373
  res.off('close', onClientGone)
1273
1374
  // The invoke is over — answered, refused mid-flight, or its client
@@ -1350,6 +1451,584 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1350
1451
  return
1351
1452
  }
1352
1453
 
1454
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin`) {
1455
+ // The plugin bridge's forwarding route (PB-3, write requests since
1456
+ // PB-9): the sub-client hands over one call against a BRIDGED plugin's
1457
+ // route (coordinates in the x-zr-* headers), and this server re-issues
1458
+ // it against its OWN plugin over loopback HTTP (docs/adr/0001),
1459
+ // streaming the answer back. The METHOD rides `x-zr-method` (PB-9,
1460
+ // default GET); a body rides as the raw byte stream, forwarded
1461
+ // verbatim.
1462
+ const bridge = options.pluginBridge
1463
+ if (bridge === undefined) {
1464
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
1465
+ return
1466
+ }
1467
+ // The method, before anything else — it decides whether a body is
1468
+ // even legal. GET/HEAD stay bodyless by protocol: the coordinates
1469
+ // ride the headers, so anything at all in the body is a shape
1470
+ // violation. One byte settles it; a real body is drained (bounded,
1471
+ // timed — the upload route's discipline) AFTER the answer, like every
1472
+ // oversized read here.
1473
+ const pluginMethod = headerValue(req, 'x-zr-method') ?? 'GET'
1474
+ const bodylessMethod = pluginMethod === 'GET' || pluginMethod === 'HEAD'
1475
+ let bodyBytes = 0
1476
+ if (bodylessMethod) {
1477
+ for await (const chunk of req) {
1478
+ bodyBytes += (chunk as Buffer).length
1479
+ break
1480
+ }
1481
+ if (bodyBytes > 0) {
1482
+ relayPluginError(res, 400, 'bad-request')
1483
+ await drainUpload(req, 0, drainDeadlineMs)
1484
+ return
1485
+ }
1486
+ }
1487
+ // The coordinates, verbatim off the wire — `x-zr-path` is matched
1488
+ // UNDECODED (the module-wide no-decode discipline), the query parsed
1489
+ // exactly once below.
1490
+ const pluginName = headerValue(req, 'x-zr-plugin')
1491
+ const pluginVersion = headerValue(req, 'x-zr-plugin-version')
1492
+ const pluginDigest = headerValue(req, 'x-zr-plugin-digest')
1493
+ const routePath = headerValue(req, 'x-zr-path')
1494
+ const rawQuery = headerValue(req, 'x-zr-query') ?? ''
1495
+ if (
1496
+ pluginName === undefined || pluginName === '' ||
1497
+ pluginVersion === undefined || pluginVersion === '' ||
1498
+ pluginDigest === undefined || pluginDigest === '' ||
1499
+ routePath === undefined || !routePath.startsWith('/')
1500
+ ) {
1501
+ relayPluginError(res, 400, 'bad-request')
1502
+ return
1503
+ }
1504
+ // ---- PB-11: the adapter routes claim the request BEFORE the
1505
+ // cooperative chain. An adapter route matches by equality or one
1506
+ // segment deeper (not the cooperative exact equality), its ids live
1507
+ // wherever ITS locator says (query, path segment, JSON body), and the
1508
+ // loopback receives the coordinates VERBATIM — the sub-client has
1509
+ // already rewritten every virtual id to its real half, so what gets
1510
+ // validated here is exactly what the local plugin will see. A YIELDED
1511
+ // adapter (the plugin registered its own declaration) falls through to
1512
+ // the cooperative chain below, whose digest check refuses the stale
1513
+ // adapter coordinates with `plugin-mismatch`.
1514
+ const adapterHit = matchAdapterRoute(options.pluginBridge?.adapters, routePath)
1515
+ if (adapterHit !== undefined) {
1516
+ const { adapter, route: adapterRoute } = adapterHit
1517
+ if (adapterOwnsRegistryEntry(adapter, bridge.registry.get(adapter.plugin))) {
1518
+ // Coordinates first: the forwarded version/digest must be the
1519
+ // adapter's registered pair.
1520
+ if (pluginVersion !== adapter.declaration.version || pluginDigest !== adapter.digest) {
1521
+ relayPluginError(res, 409, 'plugin-mismatch')
1522
+ return
1523
+ }
1524
+ // Whole-machine data by definition: the live consent wall, the
1525
+ // same knob the cooperative server-scope routes read.
1526
+ let adapterConsent: Record<string, boolean> = {}
1527
+ try {
1528
+ adapterConsent = bridge.getConsent()
1529
+ } catch {
1530
+ adapterConsent = {}
1531
+ }
1532
+ if (adapterConsent[adapter.plugin] !== true) {
1533
+ relayPluginError(res, 403, 'consent-off')
1534
+ return
1535
+ }
1536
+ if (!adapterRoute.methods.includes(pluginMethod)) {
1537
+ relayPluginError(res, 405, 'method-undeclared')
1538
+ return
1539
+ }
1540
+ // The per-device budget — the SAME table the cooperative forwards
1541
+ // share, held for the whole response stream.
1542
+ const adapterDevice = headerValue(req, 'x-zen-remote-device') ?? ''
1543
+ if ((pluginsByDevice.get(adapterDevice) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
1544
+ relayPluginError(res, 429, 'busy')
1545
+ return
1546
+ }
1547
+ pluginsByDevice.set(adapterDevice, (pluginsByDevice.get(adapterDevice) ?? 0) + 1)
1548
+ let adapterGone = false
1549
+ let multiValueRefusal = false
1550
+ let adapterQueryOverride: string | undefined
1551
+ const adapterHangUp = new AbortController()
1552
+ const onAdapterGone = (): void => {
1553
+ adapterGone = true
1554
+ if (!res.writableEnded) adapterHangUp.abort()
1555
+ }
1556
+ res.on('close', onAdapterGone)
1557
+ res.on('error', onAdapterGone)
1558
+ try {
1559
+ // Locate ONCE, over the same routing rule the sub-client used:
1560
+ // the query parses once, the path segments decode off the
1561
+ // rewritten pathname, and a body-locating route buffers its
1562
+ // (bounded) JSON body. A duplicated query id throws — the
1563
+ // cooperative shape violation (PB-12 review).
1564
+ const adapterSearch = new URLSearchParams(rawQuery)
1565
+ let parsedBody: unknown
1566
+ let bodyGiven = false
1567
+ let bufferedBody: Buffer | undefined
1568
+ if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
1569
+ const chunks: Buffer[] = []
1570
+ let size = 0
1571
+ let over = false
1572
+ for await (const chunk of req) {
1573
+ size += (chunk as Buffer).length
1574
+ if (size > adapterRoute.maxBody) {
1575
+ over = true
1576
+ break
1577
+ }
1578
+ chunks.push(Buffer.from(chunk as Uint8Array))
1579
+ }
1580
+ if (over) {
1581
+ relayPluginError(res, 413, 'body-too-large')
1582
+ void drainUpload(req, 0, drainDeadlineMs)
1583
+ return
1584
+ }
1585
+ bufferedBody = Buffer.concat(chunks)
1586
+ if (bufferedBody.length > 0) {
1587
+ try {
1588
+ parsedBody = JSON.parse(bufferedBody.toString('utf8'))
1589
+ bodyGiven = true
1590
+ } catch {
1591
+ relayPluginError(res, 400, 'bad-request')
1592
+ return
1593
+ }
1594
+ }
1595
+ }
1596
+ const hits = (() => {
1597
+ try {
1598
+ return locateAdapterIds(adapterRoute, routePath, adapterSearch, bodyGiven ? parsedBody : undefined)
1599
+ } catch (error) {
1600
+ if (error instanceof AdapterMultiValueQueryError) {
1601
+ multiValueRefusal = true
1602
+ return []
1603
+ }
1604
+ throw error
1605
+ }
1606
+ })()
1607
+ if (multiValueRefusal) {
1608
+ relayPluginError(res, 400, 'bad-query')
1609
+ return
1610
+ }
1611
+ // The sub-client only forwards a classified-remote request, so
1612
+ // an adapter request that locates NOTHING is malformed here.
1613
+ if (hits.length === 0) {
1614
+ relayPluginError(res, 400, 'bad-request')
1615
+ return
1616
+ }
1617
+ // EVERY located id must be a shared session — one unshared id
1618
+ // among several refuses the whole request.
1619
+ for (const hit of hits) {
1620
+ if (!isAccessible(hit.value)) {
1621
+ relayPluginError(res, 403, 'not-shared')
1622
+ return
1623
+ }
1624
+ }
1625
+ // The server hook: refuse, or replace the parsed body (strip a
1626
+ // `cwd`, pin a field…). The loopback coordinates stay verbatim.
1627
+ let loopbackBody: Buffer | undefined
1628
+ if (bodyGiven) loopbackBody = Buffer.from(JSON.stringify(parsedBody), 'utf8')
1629
+ const serverHook = adapter.hooks.server?.request
1630
+ if (serverHook !== undefined) {
1631
+ let verdict: AdapterVerdict
1632
+ try {
1633
+ verdict = serverHook({ method: pluginMethod, pathname: routePath, route: adapterRoute, query: adapterSearch, body: bodyGiven ? parsedBody : undefined })
1634
+ } catch {
1635
+ relayPluginError(res, 403, 'internal')
1636
+ return
1637
+ }
1638
+ if (!verdict.allow) {
1639
+ relayPluginError(res, 403, verdict.reason)
1640
+ return
1641
+ }
1642
+ if (bodyGiven && verdict.body !== undefined) {
1643
+ loopbackBody = Buffer.from(JSON.stringify(verdict.body), 'utf8')
1644
+ }
1645
+ // A hook may also rewrite the query (the file/upload routes'
1646
+ // `cwd` strip rides this) — the loopback then sends the
1647
+ // hook's serialization instead of the verbatim rawQuery.
1648
+ adapterQueryOverride = verdict.query !== undefined ? verdict.query.toString() : undefined
1649
+ }
1650
+ // The loopback body: a body-locating route sends its (possibly
1651
+ // hook-rewritten) buffer; any other body rides the cooperative
1652
+ // counting gate.
1653
+ const adapterAccept = headerValue(req, 'accept')
1654
+ const adapterRange = headerValue(req, 'range')
1655
+ const adapterContentType = headerValue(req, 'content-type')
1656
+ let adapterBodyStream: import('node:stream').Readable | null = null
1657
+ let adapterBodyGateError: Error | undefined
1658
+ if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
1659
+ adapterBodyStream = Readable.from(loopbackBody ?? Buffer.alloc(0))
1660
+ } else if (!bodylessMethod) {
1661
+ let pumped = 0
1662
+ const gate = new Transform({
1663
+ transform(chunk: Buffer, _encoding, callback) {
1664
+ pumped += chunk.length
1665
+ if (pumped > adapterRoute.maxBody) {
1666
+ callback(new Error('body-too-large'))
1667
+ return
1668
+ }
1669
+ callback(null, chunk)
1670
+ },
1671
+ })
1672
+ gate.on('error', (error) => {
1673
+ adapterBodyGateError = error
1674
+ adapterHangUp.abort()
1675
+ })
1676
+ req.pipe(gate)
1677
+ adapterBodyStream = gate
1678
+ }
1679
+ // The loopback: the coordinates travel VERBATIM, on purpose —
1680
+ // the sub-client has already rewritten every located virtual id
1681
+ // to its real half (query re-serialized, path segment
1682
+ // re-encoded, JSON body re-serialized), so what the server
1683
+ // validated (the share check below ran over the SAME locate)
1684
+ // is byte-for-byte what the local plugin will see. Re-locating
1685
+ // here would only risk divergence. The ONE exception is an
1686
+ // explicit hook verdict (`query` above): the better-sidebar
1687
+ // adapter strips the file/upload routes' `cwd` so the backend
1688
+ // resolves it from the session.
1689
+ const outbound = await bridge.loopback.request(
1690
+ pluginMethod,
1691
+ routePath,
1692
+ adapterQueryOverride ?? rawQuery,
1693
+ {
1694
+ ...(adapterAccept !== undefined ? { accept: adapterAccept } : {}),
1695
+ ...(adapterRange !== undefined ? { range: adapterRange } : {}),
1696
+ ...(adapterContentType !== undefined ? { contentType: adapterContentType } : {}),
1697
+ },
1698
+ adapterBodyStream,
1699
+ adapterHangUp.signal,
1700
+ )
1701
+ if (adapterBodyGateError !== undefined && !res.headersSent) {
1702
+ relayPluginError(res, 413, 'body-too-large')
1703
+ void drainUpload(req, 0, drainDeadlineMs)
1704
+ return
1705
+ }
1706
+ // The adapter header allowlist (PB-12): the HTML preview's CSP
1707
+ // sandbox and download dispositions ride through to the phone.
1708
+ // The loopback collected them into `extraHeaders`.
1709
+ res.writeHead(outbound.status, {
1710
+ ...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
1711
+ ...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
1712
+ ...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
1713
+ ...outbound.extraHeaders,
1714
+ })
1715
+ await new Promise<void>((resolve, reject) => {
1716
+ const upstream = outbound.stream
1717
+ upstream.on('data', (chunk: Buffer) => {
1718
+ if (adapterGone || res.destroyed || res.writableEnded) {
1719
+ upstream.destroy()
1720
+ return
1721
+ }
1722
+ if (!res.write(chunk)) {
1723
+ upstream.pause()
1724
+ res.once('drain', () => upstream.resume())
1725
+ }
1726
+ })
1727
+ upstream.on('end', () => resolve())
1728
+ upstream.on('error', reject)
1729
+ })
1730
+ await new Promise<void>((resolve) => {
1731
+ if (res.writableEnded || res.destroyed) {
1732
+ resolve()
1733
+ return
1734
+ }
1735
+ const watchdog = setTimeout(() => {
1736
+ try {
1737
+ res.destroy()
1738
+ } catch {
1739
+ // Already gone.
1740
+ }
1741
+ }, endDrainTimeoutMs)
1742
+ if (typeof watchdog.unref === 'function') watchdog.unref()
1743
+ const settled = (): void => {
1744
+ clearTimeout(watchdog)
1745
+ resolve()
1746
+ }
1747
+ res.once('finish', settled)
1748
+ res.once('close', settled)
1749
+ res.end()
1750
+ })
1751
+ } catch (error) {
1752
+ if (adapterHangUp.signal.aborted) {
1753
+ try {
1754
+ res.destroy()
1755
+ } catch {
1756
+ // Already gone.
1757
+ }
1758
+ } else if (isLoopbackAuthError(error)) {
1759
+ relayPluginError(res, 502, 'loopback-auth')
1760
+ } else if (!res.headersSent) {
1761
+ relayPluginError(res, 502, 'internal')
1762
+ } else {
1763
+ try {
1764
+ res.destroy()
1765
+ } catch {
1766
+ // Already gone.
1767
+ }
1768
+ }
1769
+ } finally {
1770
+ res.off('close', onAdapterGone)
1771
+ res.off('error', onAdapterGone)
1772
+ const remaining = (pluginsByDevice.get(adapterDevice) ?? 1) - 1
1773
+ if (remaining <= 0) pluginsByDevice.delete(adapterDevice)
1774
+ else pluginsByDevice.set(adapterDevice, remaining)
1775
+ }
1776
+ return
1777
+ }
1778
+ // A yielded adapter falls through: the cooperative chain's digest
1779
+ // check refuses these stale coordinates below.
1780
+ }
1781
+ // ---- the validation chain, in order, first failure answers --------
1782
+ // Steps 1/2/3 and 4-6 (registry, version+digest, exact route, the
1783
+ // parse-once query, the share table, the consent wall) are SHARED with
1784
+ // the tunnel open route — see validatePluginCall. This route's own
1785
+ // extras are the method check (3b) and the body gate (3c) below.
1786
+ const validation = validatePluginCall(
1787
+ bridge,
1788
+ { pluginName, pluginVersion, pluginDigest, routePath, rawQuery },
1789
+ isAccessible,
1790
+ )
1791
+ if (!validation.ok) {
1792
+ relayPluginError(res, validation.status, validation.code)
1793
+ return
1794
+ }
1795
+ const route = validation.route
1796
+ // 3b. (PB-9) the METHOD must be one the route declared — the write
1797
+ // surface is exactly as explicit as the read one was in 2.1: a
1798
+ // route that declared no `methods` forwards GET only, and an
1799
+ // undeclared method answers 405 rather than reaching the plugin.
1800
+ if (!routeMethodsOf(route).includes(pluginMethod)) {
1801
+ relayPluginError(res, 405, 'method-undeclared')
1802
+ return
1803
+ }
1804
+ // 3c. (PB-9) the body gate: a declared Content-Length over the route's
1805
+ // `maxBody` refuses BEFORE a byte moves (the T51 pre-check
1806
+ // discipline — the tail is drained after the answer); a chunked
1807
+ // body carries no length header and is counted while it streams,
1808
+ // in the pump below.
1809
+ const maxBody = routeMaxBodyOf(route)
1810
+ const declaredLength = Number(headerValue(req, 'content-length'))
1811
+ if (!bodylessMethod && Number.isFinite(declaredLength) && declaredLength > maxBody) {
1812
+ relayPluginError(res, 413, 'body-too-large')
1813
+ await drainUpload(req, 0, drainDeadlineMs)
1814
+ return
1815
+ }
1816
+ const params = validation.params
1817
+ const normalizedQuery = validation.normalizedQuery
1818
+ // 7. the per-device budget — taken only after every wall above, held
1819
+ // for the whole response stream, released in the finally.
1820
+ const device = headerValue(req, 'x-zen-remote-device') ?? ''
1821
+ if ((pluginsByDevice.get(device) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
1822
+ relayPluginError(res, 429, 'busy')
1823
+ return
1824
+ }
1825
+ pluginsByDevice.set(device, (pluginsByDevice.get(device) ?? 0) + 1)
1826
+ // A sub-client hang-up cancels the loopback request mid-stream, like
1827
+ // the invoke route's wiring; the pump below reads the same flag (and
1828
+ // 'error' rides along, v1/stream's discipline).
1829
+ let clientGone = false
1830
+ // The counting gate's recorded failure (PB-9), read by the catch —
1831
+ // declared here because the gate itself lives inside the try.
1832
+ let bodyGateError: Error | undefined
1833
+ const hangUp = new AbortController()
1834
+ const onClientGone = (): void => {
1835
+ clientGone = true
1836
+ if (!res.writableEnded) hangUp.abort()
1837
+ }
1838
+ res.on('close', onClientGone)
1839
+ res.on('error', onClientGone)
1840
+ try {
1841
+ const accept = headerValue(req, 'accept')
1842
+ const range = headerValue(req, 'range')
1843
+ const contentType = headerValue(req, 'content-type')
1844
+ // The forwarded body (PB-9): the relay request's body streamed
1845
+ // through a counting gate — node's pipe owns the backpressure, and
1846
+ // the route's `maxBody` is enforced ON THE STREAM (a chunked body
1847
+ // carries no length header, so the declared-length pre-check above
1848
+ // could not see it). Past the cap the gate errors, which tears the
1849
+ // loopback request down mid-write and lands as 413 below (headers
1850
+ // not yet sent — the plugin's response cannot have arrived while we
1851
+ // are still sending its request body).
1852
+ let bodyStream: import('node:http').IncomingMessage | import('node:stream').Readable | null = null
1853
+ // The gate's own failure must REACH the loopback request (tearing it
1854
+ // down mid-write) and this route's catch — an unhandled Transform
1855
+ // 'error' would crash the process instead. The dedicated abort
1856
+ // signal does both: the loopback dials hang on it, and the catch
1857
+ // reads the recorded error first.
1858
+ const bodyAbort = new AbortController()
1859
+ const bodySignal = AbortSignal.any([hangUp.signal, bodyAbort.signal])
1860
+ if (!bodylessMethod) {
1861
+ let pumped = 0
1862
+ const gate = new Transform({
1863
+ transform(chunk: Buffer, _encoding, callback) {
1864
+ pumped += chunk.length
1865
+ if (pumped > maxBody) {
1866
+ req.pause()
1867
+ callback(new Error('body-too-large'))
1868
+ return
1869
+ }
1870
+ callback(null, chunk)
1871
+ },
1872
+ })
1873
+ gate.on('error', (error) => {
1874
+ if (bodyGateError === undefined) bodyGateError = error
1875
+ bodyAbort.abort()
1876
+ })
1877
+ req.on('error', (error) => {
1878
+ if (bodyGateError === undefined) bodyGateError = error
1879
+ gate.destroy(error)
1880
+ })
1881
+ gate.on('close', () => { if (!res.writableEnded) req.pause() })
1882
+ bodyStream = gate
1883
+ req.pipe(gate)
1884
+ }
1885
+ const outbound = await bridge.loopback.request(
1886
+ pluginMethod,
1887
+ routePath,
1888
+ normalizedQuery,
1889
+ {
1890
+ ...(accept !== undefined ? { accept } : {}),
1891
+ ...(range !== undefined ? { range } : {}),
1892
+ ...(contentType !== undefined ? { contentType } : {}),
1893
+ },
1894
+ bodyStream,
1895
+ bodySignal,
1896
+ )
1897
+ // Success: status plus the three whitelisted headers travel, the
1898
+ // body streams — and NO x-zen-remote-error (the marker is refusal-
1899
+ // only, so the client can classify with one header read).
1900
+ res.writeHead(outbound.status, {
1901
+ ...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
1902
+ ...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
1903
+ ...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
1904
+ })
1905
+ // The pump: chunk-for-chunk with real backpressure — a full socket
1906
+ // pauses the plugin's response instead of buffering it here, so a
1907
+ // slow phone reads a big file without this process ever holding
1908
+ // more than a socket buffer of it. Every chunk re-checks the
1909
+ // response's liveness first (the v1/stream write discipline): a
1910
+ // client that died mid-stream stops the pump on its next chunk
1911
+ // instead of racing one more write into a dead socket, and a
1912
+ // response 'error' (a write racing a destroyed socket surfaces
1913
+ // there) reacts exactly like the hang-up.
1914
+ let clientGoneSeen = false
1915
+ await new Promise<void>((resolve, reject) => {
1916
+ const upstream = outbound.stream
1917
+ upstream.on('data', (chunk: Buffer) => {
1918
+ if (clientGone || res.destroyed || res.writableEnded) {
1919
+ upstream.destroy()
1920
+ return
1921
+ }
1922
+ if (!res.write(chunk)) {
1923
+ upstream.pause()
1924
+ res.once('drain', () => upstream.resume())
1925
+ }
1926
+ })
1927
+ upstream.on('end', () => resolve())
1928
+ upstream.on('error', reject)
1929
+ })
1930
+ // The v1/stream finish discipline (T43-A): the tail is in the
1931
+ // kernel's hands now, and a client that stopped reading must not
1932
+ // pin this device slot forever — one watchdog, disarmed by `finish`
1933
+ // (the honest drained signal) or `close`, destroys otherwise.
1934
+ await new Promise<void>((resolve) => {
1935
+ if (res.writableEnded || res.destroyed) {
1936
+ resolve()
1937
+ return
1938
+ }
1939
+ const watchdog = setTimeout(() => {
1940
+ try {
1941
+ res.destroy()
1942
+ } catch {
1943
+ // Already gone.
1944
+ }
1945
+ }, endDrainTimeoutMs)
1946
+ if (typeof watchdog.unref === 'function') watchdog.unref()
1947
+ const settled = (): void => {
1948
+ clearTimeout(watchdog)
1949
+ resolve()
1950
+ }
1951
+ res.once('finish', settled)
1952
+ res.once('close', settled)
1953
+ res.end()
1954
+ })
1955
+ } catch (error) {
1956
+ if (!res.headersSent && bodyGateError !== undefined && bodyGateError.message === 'body-too-large') {
1957
+ // The counting gate tripped mid-forward (PB-9): a chunked body
1958
+ // past the route's `maxBody`. The plugin's answer cannot have
1959
+ // started while we were still writing its request body, so the
1960
+ // honest 413 still fits on the wire; the relay body's tail is
1961
+ // drained after the answer, bounded and timed like every
1962
+ // oversized read here.
1963
+ relayPluginError(res, 413, 'body-too-large')
1964
+ void drainUpload(req, 0, drainDeadlineMs)
1965
+ } else if (hangUp.signal.aborted) {
1966
+ // The sub-client is gone mid-stream: its socket is dead, and the
1967
+ // abort already tore the loopback request down.
1968
+ try {
1969
+ res.destroy()
1970
+ } catch {
1971
+ // Already gone.
1972
+ }
1973
+ } else if (isLoopbackAuthError(error)) {
1974
+ // The cookie could not be obtained (no token URL on this host, a
1975
+ // failed exchange) — the relay's own wall, not a business answer.
1976
+ relayPluginError(res, 502, 'loopback-auth')
1977
+ } else if (!res.headersSent) {
1978
+ // Any other loopback failure before the answer started (the web
1979
+ // server moved, the plugin route crashed early) is this server's
1980
+ // fault: the bare internal code, no message.
1981
+ relayPluginError(res, 502, 'internal')
1982
+ } else {
1983
+ // Mid-stream failure with headers already on the wire: the status
1984
+ // cannot be changed, so the response is cut — the client sees a
1985
+ // truncated body, the honest shape of a broken stream.
1986
+ try {
1987
+ res.destroy()
1988
+ } catch {
1989
+ // Already gone.
1990
+ }
1991
+ }
1992
+ } finally {
1993
+ res.off('close', onClientGone)
1994
+ res.off('error', onClientGone)
1995
+ const remaining = (pluginsByDevice.get(device) ?? 1) - 1
1996
+ if (remaining <= 0) pluginsByDevice.delete(device)
1997
+ else pluginsByDevice.set(device, remaining)
1998
+ }
1999
+ return
2000
+ }
2001
+
2002
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/open`) {
2003
+ const tunnel = options.pluginBridge?.tunnel
2004
+ if (tunnel === undefined) {
2005
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
2006
+ return
2007
+ }
2008
+ await tunnel.open(req, res)
2009
+ return
2010
+ }
2011
+
2012
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/send`) {
2013
+ const tunnel = options.pluginBridge?.tunnel
2014
+ if (tunnel === undefined) {
2015
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
2016
+ return
2017
+ }
2018
+ await tunnel.send(req, res)
2019
+ return
2020
+ }
2021
+
2022
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/close`) {
2023
+ const tunnel = options.pluginBridge?.tunnel
2024
+ if (tunnel === undefined) {
2025
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
2026
+ return
2027
+ }
2028
+ await tunnel.close(req, res)
2029
+ return
2030
+ }
2031
+
1353
2032
  if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/upload`) {
1354
2033
  // The binary upload channel (T51): the sub-client's wrapped
1355
2034
  // `/api/session/uploadFileBinary` forwards a remote session's raw
@@ -1927,8 +2606,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1927
2606
  // DOCUMENTED normal end, swallowed whole.
1928
2607
  closeWith(null)
1929
2608
  } else {
1930
- const { code, message } = errorOf(error)
1931
- closeWith(message === undefined ? { type: 'error', error: { code } } : { type: 'error', error: { code, message } })
2609
+ closeWith({ type: 'error', error: errorEnvelopeOf(error).error })
1932
2610
  }
1933
2611
  } finally {
1934
2612
  openStreamKills.delete(killThisStream)