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,12 +26,17 @@
26
26
  * a 404.
27
27
  */
28
28
  import { randomBytes, timingSafeEqual } from 'node:crypto';
29
+ import { Readable, Transform } from 'node:stream';
29
30
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
30
31
  import { createRequire } from 'node:module';
31
32
  import { join } from 'node:path';
32
33
  import { decideHttpRoute, decideInvoke, decideStream, decideUploadQuery, parseEventResultBody } from './relay-access.js';
33
34
  import { createWorkspaceFollowState, filterControlFrame, filterJobListFrame, filterModelCatalogResult, filterSessionListResult, filterWorkspaceFrame, } from './relay-filter.js';
34
35
  import { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri } from './session-reference.js';
36
+ import { routeMethodsOf, routeMaxBodyOf } from './plugin-bridge.js';
37
+ import { validatePluginCall } from './plugin-tunnel.js';
38
+ import { isLoopbackAuthError } from './plugin-loopback.js';
39
+ import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js';
35
40
  /**
36
41
  * Registration prefix on the host webServer. Deliberately WITHOUT the
37
42
  * trailing slash: the webserver matches a prefix route as `pathname === p ||
@@ -112,6 +117,8 @@ const MAX_INVOKES_PER_DEVICE = 32;
112
117
  * in-process dispatch writing to the attachments store — behind this route.
113
118
  */
114
119
  const MAX_UPLOADS_PER_DEVICE = 8;
120
+ /** Concurrent bridged plugin GETs one device may keep in flight (PB-3). */
121
+ const MAX_PLUGIN_REQUESTS_PER_DEVICE = 8;
115
122
  /** Most recent forwarded waterfall events remembered per `$zr/events`
116
123
  * subscription (T32-fix): the registry is only an ownership record, so this
117
124
  * bounds a pathological burst the same way the job cache does — past the
@@ -214,6 +221,17 @@ function responseJson(res, status, body) {
214
221
  res.writeHead(status);
215
222
  res.end(bytes);
216
223
  }
224
+ /**
225
+ * One plugin-bridge refusal (PB-3): the same JSON envelope as every other
226
+ * relay refusal, plus the `x-zen-remote-error` marker header — the
227
+ * sub-client's passthrough layer reads the CODE off a response whose body
228
+ * it must not have to parse (the success path streams opaque plugin bytes;
229
+ * keeping the refusal marker a header lets one code path classify both).
230
+ */
231
+ function relayPluginError(res, status, code) {
232
+ res.setHeader('x-zen-remote-error', code);
233
+ responseJson(res, status, { ok: false, error: { code } });
234
+ }
217
235
  /** First (and in practice only) value of one request header. */
218
236
  function headerValue(req, name) {
219
237
  const raw = req.headers[name];
@@ -395,6 +413,15 @@ function drainUpload(req, fromBytes, deadlineMs) {
395
413
  req.resume();
396
414
  });
397
415
  }
416
+ /** The 200 error envelope for a failed gateway call, `details` included when
417
+ * the host error carried a sanitized one (see errorOf). */
418
+ function errorEnvelopeOf(error) {
419
+ const { code, message, details } = errorOf(error);
420
+ const inner = message === undefined ? { code } : { code, message };
421
+ if (details !== undefined)
422
+ inner.details = details;
423
+ return { ok: false, error: inner };
424
+ }
398
425
  /**
399
426
  * What may travel to the client about a failed gateway call. DSH's own errors
400
427
  * always carry a `namespace/name`-shaped string `code` — those pass through
@@ -414,7 +441,26 @@ function errorOf(error) {
414
441
  if (code.endsWith('/internal'))
415
442
  return { code };
416
443
  const text = error instanceof Error ? error.message : String(error);
417
- return { code, message: text.slice(0, MAX_MESSAGE_CHARS) };
444
+ // The structured `details` beside code/message (2026-10-10): the host
445
+ // rejects an image-bearing prompt with `session/attachment-invalid` whose
446
+ // `details.reason` (`MODEL_DOES_NOT_SUPPORT_IMAGES`, …) is what the client
447
+ // UI maps onto a user-facing line — dropped here, the UI prints
448
+ // `undefined`. Scalars and clipped strings only, bounded key count;
449
+ // nested objects/arrays stay out so server internals cannot ride this.
450
+ const raw = error.details;
451
+ const details = {};
452
+ if (isPlainObject(raw)) {
453
+ for (const [key, value] of Object.entries(raw).slice(0, 16)) {
454
+ if (typeof value === 'string')
455
+ details[key] = value.slice(0, MAX_MESSAGE_CHARS);
456
+ else if (typeof value === 'number' || typeof value === 'boolean' || value === null)
457
+ details[key] = value;
458
+ }
459
+ }
460
+ const out = { code, message: text.slice(0, MAX_MESSAGE_CHARS) };
461
+ if (Object.keys(details).length > 0)
462
+ out.details = details;
463
+ return out;
418
464
  }
419
465
  /**
420
466
  * Build the relay route handler mounted under {@link RELAY_PREFIX}. Exported
@@ -465,6 +511,16 @@ export function createRelayHandler(options) {
465
511
  const invokesByDevice = new Map();
466
512
  /** Uploads currently in flight per device id, for the upload budget (T51-fix). */
467
513
  const uploadsByDevice = new Map();
514
+ /**
515
+ * Bridged plugin GETs currently in flight per device id (PB-3): the same
516
+ * per-device budget discipline as the invoke and upload counters above.
517
+ * The cap is 8 — the upload budget's number — because a bridged call is
518
+ * one panel's data fetch, a burst of a handful per session view; the slot
519
+ * stays taken for the WHOLE stream (a slow plugin response pins one slot
520
+ * until its last byte), which is exactly what the budget bounds. Past the
521
+ * cap the request answers 429 `busy` without touching the counter.
522
+ */
523
+ const pluginsByDevice = new Map();
468
524
  /**
469
525
  * The device NAME this process last saw per device id (T59): every relay
470
526
  * request rides the gateway's fresh `x-zen-remote-device-name` marking
@@ -740,6 +796,20 @@ export function createRelayHandler(options) {
740
796
  catch {
741
797
  fingerprints = {};
742
798
  }
799
+ // PB-2: the plugin bridge table, computed FRESH per handshake (the
800
+ // registry and the row's consent map are both live state), with the
801
+ // SAME guard: a throwing source answers the empty table — the field
802
+ // still travels, so the client reads "no plugins on this server"
803
+ // (not-on-server), never a failed handshake. The field is ALWAYS
804
+ // present in the reply: only its ABSENCE (an older server) can ever
805
+ // mean server-too-old on the client.
806
+ let plugins = {};
807
+ try {
808
+ plugins = serverInfo.plugins?.() ?? {};
809
+ }
810
+ catch {
811
+ plugins = {};
812
+ }
743
813
  responseJson(res, 200, {
744
814
  ok: true,
745
815
  relayProtocol: RELAY_PROTOCOL,
@@ -749,6 +819,9 @@ export function createRelayHandler(options) {
749
819
  // T42: the server's own interface fingerprints (empty when the wiring
750
820
  // had nothing to compute — the client judges group by group).
751
821
  fingerprints,
822
+ // PB-2: the plugin bridge table (empty when the wiring had nothing to
823
+ // register — the client judges plugin by plugin).
824
+ plugins,
752
825
  // T59: the CALLING device's name, as the gateway's device table holds
753
826
  // it at this moment (the header is written fresh per forward, so a
754
827
  // just-renamed device reads its new name here). Undefined without the
@@ -850,8 +923,7 @@ export function createRelayHandler(options) {
850
923
  responseJson(res, 200, isPlainObject(envelope) ? envelope : { ok: false, error: { code: 'internal' } });
851
924
  }
852
925
  catch (error) {
853
- const { code, message } = errorOf(error);
854
- responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } });
926
+ responseJson(res, 200, errorEnvelopeOf(error));
855
927
  }
856
928
  finally {
857
929
  res.off('close', onClientGone);
@@ -999,8 +1071,7 @@ export function createRelayHandler(options) {
999
1071
  // The gateway's own failures (unknown namespace, absent service) are
1000
1072
  // business answers, not transport errors: they ride a 200 envelope
1001
1073
  // like every other result, with the gateway's string code preserved.
1002
- const { code, message } = errorOf(error);
1003
- responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } });
1074
+ responseJson(res, 200, errorEnvelopeOf(error));
1004
1075
  }
1005
1076
  finally {
1006
1077
  res.off('close', onClientGone);
@@ -1086,6 +1157,594 @@ export function createRelayHandler(options) {
1086
1157
  }
1087
1158
  return;
1088
1159
  }
1160
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin`) {
1161
+ // The plugin bridge's forwarding route (PB-3, write requests since
1162
+ // PB-9): the sub-client hands over one call against a BRIDGED plugin's
1163
+ // route (coordinates in the x-zr-* headers), and this server re-issues
1164
+ // it against its OWN plugin over loopback HTTP (docs/adr/0001),
1165
+ // streaming the answer back. The METHOD rides `x-zr-method` (PB-9,
1166
+ // default GET); a body rides as the raw byte stream, forwarded
1167
+ // verbatim.
1168
+ const bridge = options.pluginBridge;
1169
+ if (bridge === undefined) {
1170
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
1171
+ return;
1172
+ }
1173
+ // The method, before anything else — it decides whether a body is
1174
+ // even legal. GET/HEAD stay bodyless by protocol: the coordinates
1175
+ // ride the headers, so anything at all in the body is a shape
1176
+ // violation. One byte settles it; a real body is drained (bounded,
1177
+ // timed — the upload route's discipline) AFTER the answer, like every
1178
+ // oversized read here.
1179
+ const pluginMethod = headerValue(req, 'x-zr-method') ?? 'GET';
1180
+ const bodylessMethod = pluginMethod === 'GET' || pluginMethod === 'HEAD';
1181
+ let bodyBytes = 0;
1182
+ if (bodylessMethod) {
1183
+ for await (const chunk of req) {
1184
+ bodyBytes += chunk.length;
1185
+ break;
1186
+ }
1187
+ if (bodyBytes > 0) {
1188
+ relayPluginError(res, 400, 'bad-request');
1189
+ await drainUpload(req, 0, drainDeadlineMs);
1190
+ return;
1191
+ }
1192
+ }
1193
+ // The coordinates, verbatim off the wire — `x-zr-path` is matched
1194
+ // UNDECODED (the module-wide no-decode discipline), the query parsed
1195
+ // exactly once below.
1196
+ const pluginName = headerValue(req, 'x-zr-plugin');
1197
+ const pluginVersion = headerValue(req, 'x-zr-plugin-version');
1198
+ const pluginDigest = headerValue(req, 'x-zr-plugin-digest');
1199
+ const routePath = headerValue(req, 'x-zr-path');
1200
+ const rawQuery = headerValue(req, 'x-zr-query') ?? '';
1201
+ if (pluginName === undefined || pluginName === '' ||
1202
+ pluginVersion === undefined || pluginVersion === '' ||
1203
+ pluginDigest === undefined || pluginDigest === '' ||
1204
+ routePath === undefined || !routePath.startsWith('/')) {
1205
+ relayPluginError(res, 400, 'bad-request');
1206
+ return;
1207
+ }
1208
+ // ---- PB-11: the adapter routes claim the request BEFORE the
1209
+ // cooperative chain. An adapter route matches by equality or one
1210
+ // segment deeper (not the cooperative exact equality), its ids live
1211
+ // wherever ITS locator says (query, path segment, JSON body), and the
1212
+ // loopback receives the coordinates VERBATIM — the sub-client has
1213
+ // already rewritten every virtual id to its real half, so what gets
1214
+ // validated here is exactly what the local plugin will see. A YIELDED
1215
+ // adapter (the plugin registered its own declaration) falls through to
1216
+ // the cooperative chain below, whose digest check refuses the stale
1217
+ // adapter coordinates with `plugin-mismatch`.
1218
+ const adapterHit = matchAdapterRoute(options.pluginBridge?.adapters, routePath);
1219
+ if (adapterHit !== undefined) {
1220
+ const { adapter, route: adapterRoute } = adapterHit;
1221
+ if (adapterOwnsRegistryEntry(adapter, bridge.registry.get(adapter.plugin))) {
1222
+ // Coordinates first: the forwarded version/digest must be the
1223
+ // adapter's registered pair.
1224
+ if (pluginVersion !== adapter.declaration.version || pluginDigest !== adapter.digest) {
1225
+ relayPluginError(res, 409, 'plugin-mismatch');
1226
+ return;
1227
+ }
1228
+ // Whole-machine data by definition: the live consent wall, the
1229
+ // same knob the cooperative server-scope routes read.
1230
+ let adapterConsent = {};
1231
+ try {
1232
+ adapterConsent = bridge.getConsent();
1233
+ }
1234
+ catch {
1235
+ adapterConsent = {};
1236
+ }
1237
+ if (adapterConsent[adapter.plugin] !== true) {
1238
+ relayPluginError(res, 403, 'consent-off');
1239
+ return;
1240
+ }
1241
+ if (!adapterRoute.methods.includes(pluginMethod)) {
1242
+ relayPluginError(res, 405, 'method-undeclared');
1243
+ return;
1244
+ }
1245
+ // The per-device budget — the SAME table the cooperative forwards
1246
+ // share, held for the whole response stream.
1247
+ const adapterDevice = headerValue(req, 'x-zen-remote-device') ?? '';
1248
+ if ((pluginsByDevice.get(adapterDevice) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
1249
+ relayPluginError(res, 429, 'busy');
1250
+ return;
1251
+ }
1252
+ pluginsByDevice.set(adapterDevice, (pluginsByDevice.get(adapterDevice) ?? 0) + 1);
1253
+ let adapterGone = false;
1254
+ let multiValueRefusal = false;
1255
+ let adapterQueryOverride;
1256
+ const adapterHangUp = new AbortController();
1257
+ const onAdapterGone = () => {
1258
+ adapterGone = true;
1259
+ if (!res.writableEnded)
1260
+ adapterHangUp.abort();
1261
+ };
1262
+ res.on('close', onAdapterGone);
1263
+ res.on('error', onAdapterGone);
1264
+ try {
1265
+ // Locate ONCE, over the same routing rule the sub-client used:
1266
+ // the query parses once, the path segments decode off the
1267
+ // rewritten pathname, and a body-locating route buffers its
1268
+ // (bounded) JSON body. A duplicated query id throws — the
1269
+ // cooperative shape violation (PB-12 review).
1270
+ const adapterSearch = new URLSearchParams(rawQuery);
1271
+ let parsedBody;
1272
+ let bodyGiven = false;
1273
+ let bufferedBody;
1274
+ if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
1275
+ const chunks = [];
1276
+ let size = 0;
1277
+ let over = false;
1278
+ for await (const chunk of req) {
1279
+ size += chunk.length;
1280
+ if (size > adapterRoute.maxBody) {
1281
+ over = true;
1282
+ break;
1283
+ }
1284
+ chunks.push(Buffer.from(chunk));
1285
+ }
1286
+ if (over) {
1287
+ relayPluginError(res, 413, 'body-too-large');
1288
+ void drainUpload(req, 0, drainDeadlineMs);
1289
+ return;
1290
+ }
1291
+ bufferedBody = Buffer.concat(chunks);
1292
+ if (bufferedBody.length > 0) {
1293
+ try {
1294
+ parsedBody = JSON.parse(bufferedBody.toString('utf8'));
1295
+ bodyGiven = true;
1296
+ }
1297
+ catch {
1298
+ relayPluginError(res, 400, 'bad-request');
1299
+ return;
1300
+ }
1301
+ }
1302
+ }
1303
+ const hits = (() => {
1304
+ try {
1305
+ return locateAdapterIds(adapterRoute, routePath, adapterSearch, bodyGiven ? parsedBody : undefined);
1306
+ }
1307
+ catch (error) {
1308
+ if (error instanceof AdapterMultiValueQueryError) {
1309
+ multiValueRefusal = true;
1310
+ return [];
1311
+ }
1312
+ throw error;
1313
+ }
1314
+ })();
1315
+ if (multiValueRefusal) {
1316
+ relayPluginError(res, 400, 'bad-query');
1317
+ return;
1318
+ }
1319
+ // The sub-client only forwards a classified-remote request, so
1320
+ // an adapter request that locates NOTHING is malformed here.
1321
+ if (hits.length === 0) {
1322
+ relayPluginError(res, 400, 'bad-request');
1323
+ return;
1324
+ }
1325
+ // EVERY located id must be a shared session — one unshared id
1326
+ // among several refuses the whole request.
1327
+ for (const hit of hits) {
1328
+ if (!isAccessible(hit.value)) {
1329
+ relayPluginError(res, 403, 'not-shared');
1330
+ return;
1331
+ }
1332
+ }
1333
+ // The server hook: refuse, or replace the parsed body (strip a
1334
+ // `cwd`, pin a field…). The loopback coordinates stay verbatim.
1335
+ let loopbackBody;
1336
+ if (bodyGiven)
1337
+ loopbackBody = Buffer.from(JSON.stringify(parsedBody), 'utf8');
1338
+ const serverHook = adapter.hooks.server?.request;
1339
+ if (serverHook !== undefined) {
1340
+ let verdict;
1341
+ try {
1342
+ verdict = serverHook({ method: pluginMethod, pathname: routePath, route: adapterRoute, query: adapterSearch, body: bodyGiven ? parsedBody : undefined });
1343
+ }
1344
+ catch {
1345
+ relayPluginError(res, 403, 'internal');
1346
+ return;
1347
+ }
1348
+ if (!verdict.allow) {
1349
+ relayPluginError(res, 403, verdict.reason);
1350
+ return;
1351
+ }
1352
+ if (bodyGiven && verdict.body !== undefined) {
1353
+ loopbackBody = Buffer.from(JSON.stringify(verdict.body), 'utf8');
1354
+ }
1355
+ // A hook may also rewrite the query (the file/upload routes'
1356
+ // `cwd` strip rides this) — the loopback then sends the
1357
+ // hook's serialization instead of the verbatim rawQuery.
1358
+ adapterQueryOverride = verdict.query !== undefined ? verdict.query.toString() : undefined;
1359
+ }
1360
+ // The loopback body: a body-locating route sends its (possibly
1361
+ // hook-rewritten) buffer; any other body rides the cooperative
1362
+ // counting gate.
1363
+ const adapterAccept = headerValue(req, 'accept');
1364
+ const adapterRange = headerValue(req, 'range');
1365
+ const adapterContentType = headerValue(req, 'content-type');
1366
+ let adapterBodyStream = null;
1367
+ let adapterBodyGateError;
1368
+ if (!bodylessMethod && adapterRoute.locator.in === 'json-body') {
1369
+ adapterBodyStream = Readable.from(loopbackBody ?? Buffer.alloc(0));
1370
+ }
1371
+ else if (!bodylessMethod) {
1372
+ let pumped = 0;
1373
+ const gate = new Transform({
1374
+ transform(chunk, _encoding, callback) {
1375
+ pumped += chunk.length;
1376
+ if (pumped > adapterRoute.maxBody) {
1377
+ callback(new Error('body-too-large'));
1378
+ return;
1379
+ }
1380
+ callback(null, chunk);
1381
+ },
1382
+ });
1383
+ gate.on('error', (error) => {
1384
+ adapterBodyGateError = error;
1385
+ adapterHangUp.abort();
1386
+ });
1387
+ req.pipe(gate);
1388
+ adapterBodyStream = gate;
1389
+ }
1390
+ // The loopback: the coordinates travel VERBATIM, on purpose —
1391
+ // the sub-client has already rewritten every located virtual id
1392
+ // to its real half (query re-serialized, path segment
1393
+ // re-encoded, JSON body re-serialized), so what the server
1394
+ // validated (the share check below ran over the SAME locate)
1395
+ // is byte-for-byte what the local plugin will see. Re-locating
1396
+ // here would only risk divergence. The ONE exception is an
1397
+ // explicit hook verdict (`query` above): the better-sidebar
1398
+ // adapter strips the file/upload routes' `cwd` so the backend
1399
+ // resolves it from the session.
1400
+ const outbound = await bridge.loopback.request(pluginMethod, routePath, adapterQueryOverride ?? rawQuery, {
1401
+ ...(adapterAccept !== undefined ? { accept: adapterAccept } : {}),
1402
+ ...(adapterRange !== undefined ? { range: adapterRange } : {}),
1403
+ ...(adapterContentType !== undefined ? { contentType: adapterContentType } : {}),
1404
+ }, adapterBodyStream, adapterHangUp.signal);
1405
+ if (adapterBodyGateError !== undefined && !res.headersSent) {
1406
+ relayPluginError(res, 413, 'body-too-large');
1407
+ void drainUpload(req, 0, drainDeadlineMs);
1408
+ return;
1409
+ }
1410
+ // The adapter header allowlist (PB-12): the HTML preview's CSP
1411
+ // sandbox and download dispositions ride through to the phone.
1412
+ // The loopback collected them into `extraHeaders`.
1413
+ res.writeHead(outbound.status, {
1414
+ ...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
1415
+ ...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
1416
+ ...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
1417
+ ...outbound.extraHeaders,
1418
+ });
1419
+ await new Promise((resolve, reject) => {
1420
+ const upstream = outbound.stream;
1421
+ upstream.on('data', (chunk) => {
1422
+ if (adapterGone || res.destroyed || res.writableEnded) {
1423
+ upstream.destroy();
1424
+ return;
1425
+ }
1426
+ if (!res.write(chunk)) {
1427
+ upstream.pause();
1428
+ res.once('drain', () => upstream.resume());
1429
+ }
1430
+ });
1431
+ upstream.on('end', () => resolve());
1432
+ upstream.on('error', reject);
1433
+ });
1434
+ await new Promise((resolve) => {
1435
+ if (res.writableEnded || res.destroyed) {
1436
+ resolve();
1437
+ return;
1438
+ }
1439
+ const watchdog = setTimeout(() => {
1440
+ try {
1441
+ res.destroy();
1442
+ }
1443
+ catch {
1444
+ // Already gone.
1445
+ }
1446
+ }, endDrainTimeoutMs);
1447
+ if (typeof watchdog.unref === 'function')
1448
+ watchdog.unref();
1449
+ const settled = () => {
1450
+ clearTimeout(watchdog);
1451
+ resolve();
1452
+ };
1453
+ res.once('finish', settled);
1454
+ res.once('close', settled);
1455
+ res.end();
1456
+ });
1457
+ }
1458
+ catch (error) {
1459
+ if (adapterHangUp.signal.aborted) {
1460
+ try {
1461
+ res.destroy();
1462
+ }
1463
+ catch {
1464
+ // Already gone.
1465
+ }
1466
+ }
1467
+ else if (isLoopbackAuthError(error)) {
1468
+ relayPluginError(res, 502, 'loopback-auth');
1469
+ }
1470
+ else if (!res.headersSent) {
1471
+ relayPluginError(res, 502, 'internal');
1472
+ }
1473
+ else {
1474
+ try {
1475
+ res.destroy();
1476
+ }
1477
+ catch {
1478
+ // Already gone.
1479
+ }
1480
+ }
1481
+ }
1482
+ finally {
1483
+ res.off('close', onAdapterGone);
1484
+ res.off('error', onAdapterGone);
1485
+ const remaining = (pluginsByDevice.get(adapterDevice) ?? 1) - 1;
1486
+ if (remaining <= 0)
1487
+ pluginsByDevice.delete(adapterDevice);
1488
+ else
1489
+ pluginsByDevice.set(adapterDevice, remaining);
1490
+ }
1491
+ return;
1492
+ }
1493
+ // A yielded adapter falls through: the cooperative chain's digest
1494
+ // check refuses these stale coordinates below.
1495
+ }
1496
+ // ---- the validation chain, in order, first failure answers --------
1497
+ // Steps 1/2/3 and 4-6 (registry, version+digest, exact route, the
1498
+ // parse-once query, the share table, the consent wall) are SHARED with
1499
+ // the tunnel open route — see validatePluginCall. This route's own
1500
+ // extras are the method check (3b) and the body gate (3c) below.
1501
+ const validation = validatePluginCall(bridge, { pluginName, pluginVersion, pluginDigest, routePath, rawQuery }, isAccessible);
1502
+ if (!validation.ok) {
1503
+ relayPluginError(res, validation.status, validation.code);
1504
+ return;
1505
+ }
1506
+ const route = validation.route;
1507
+ // 3b. (PB-9) the METHOD must be one the route declared — the write
1508
+ // surface is exactly as explicit as the read one was in 2.1: a
1509
+ // route that declared no `methods` forwards GET only, and an
1510
+ // undeclared method answers 405 rather than reaching the plugin.
1511
+ if (!routeMethodsOf(route).includes(pluginMethod)) {
1512
+ relayPluginError(res, 405, 'method-undeclared');
1513
+ return;
1514
+ }
1515
+ // 3c. (PB-9) the body gate: a declared Content-Length over the route's
1516
+ // `maxBody` refuses BEFORE a byte moves (the T51 pre-check
1517
+ // discipline — the tail is drained after the answer); a chunked
1518
+ // body carries no length header and is counted while it streams,
1519
+ // in the pump below.
1520
+ const maxBody = routeMaxBodyOf(route);
1521
+ const declaredLength = Number(headerValue(req, 'content-length'));
1522
+ if (!bodylessMethod && Number.isFinite(declaredLength) && declaredLength > maxBody) {
1523
+ relayPluginError(res, 413, 'body-too-large');
1524
+ await drainUpload(req, 0, drainDeadlineMs);
1525
+ return;
1526
+ }
1527
+ const params = validation.params;
1528
+ const normalizedQuery = validation.normalizedQuery;
1529
+ // 7. the per-device budget — taken only after every wall above, held
1530
+ // for the whole response stream, released in the finally.
1531
+ const device = headerValue(req, 'x-zen-remote-device') ?? '';
1532
+ if ((pluginsByDevice.get(device) ?? 0) >= MAX_PLUGIN_REQUESTS_PER_DEVICE) {
1533
+ relayPluginError(res, 429, 'busy');
1534
+ return;
1535
+ }
1536
+ pluginsByDevice.set(device, (pluginsByDevice.get(device) ?? 0) + 1);
1537
+ // A sub-client hang-up cancels the loopback request mid-stream, like
1538
+ // the invoke route's wiring; the pump below reads the same flag (and
1539
+ // 'error' rides along, v1/stream's discipline).
1540
+ let clientGone = false;
1541
+ // The counting gate's recorded failure (PB-9), read by the catch —
1542
+ // declared here because the gate itself lives inside the try.
1543
+ let bodyGateError;
1544
+ const hangUp = new AbortController();
1545
+ const onClientGone = () => {
1546
+ clientGone = true;
1547
+ if (!res.writableEnded)
1548
+ hangUp.abort();
1549
+ };
1550
+ res.on('close', onClientGone);
1551
+ res.on('error', onClientGone);
1552
+ try {
1553
+ const accept = headerValue(req, 'accept');
1554
+ const range = headerValue(req, 'range');
1555
+ const contentType = headerValue(req, 'content-type');
1556
+ // The forwarded body (PB-9): the relay request's body streamed
1557
+ // through a counting gate — node's pipe owns the backpressure, and
1558
+ // the route's `maxBody` is enforced ON THE STREAM (a chunked body
1559
+ // carries no length header, so the declared-length pre-check above
1560
+ // could not see it). Past the cap the gate errors, which tears the
1561
+ // loopback request down mid-write and lands as 413 below (headers
1562
+ // not yet sent — the plugin's response cannot have arrived while we
1563
+ // are still sending its request body).
1564
+ let bodyStream = null;
1565
+ // The gate's own failure must REACH the loopback request (tearing it
1566
+ // down mid-write) and this route's catch — an unhandled Transform
1567
+ // 'error' would crash the process instead. The dedicated abort
1568
+ // signal does both: the loopback dials hang on it, and the catch
1569
+ // reads the recorded error first.
1570
+ const bodyAbort = new AbortController();
1571
+ const bodySignal = AbortSignal.any([hangUp.signal, bodyAbort.signal]);
1572
+ if (!bodylessMethod) {
1573
+ let pumped = 0;
1574
+ const gate = new Transform({
1575
+ transform(chunk, _encoding, callback) {
1576
+ pumped += chunk.length;
1577
+ if (pumped > maxBody) {
1578
+ req.pause();
1579
+ callback(new Error('body-too-large'));
1580
+ return;
1581
+ }
1582
+ callback(null, chunk);
1583
+ },
1584
+ });
1585
+ gate.on('error', (error) => {
1586
+ if (bodyGateError === undefined)
1587
+ bodyGateError = error;
1588
+ bodyAbort.abort();
1589
+ });
1590
+ req.on('error', (error) => {
1591
+ if (bodyGateError === undefined)
1592
+ bodyGateError = error;
1593
+ gate.destroy(error);
1594
+ });
1595
+ gate.on('close', () => { if (!res.writableEnded)
1596
+ req.pause(); });
1597
+ bodyStream = gate;
1598
+ req.pipe(gate);
1599
+ }
1600
+ const outbound = await bridge.loopback.request(pluginMethod, routePath, normalizedQuery, {
1601
+ ...(accept !== undefined ? { accept } : {}),
1602
+ ...(range !== undefined ? { range } : {}),
1603
+ ...(contentType !== undefined ? { contentType } : {}),
1604
+ }, bodyStream, bodySignal);
1605
+ // Success: status plus the three whitelisted headers travel, the
1606
+ // body streams — and NO x-zen-remote-error (the marker is refusal-
1607
+ // only, so the client can classify with one header read).
1608
+ res.writeHead(outbound.status, {
1609
+ ...(outbound.contentType !== undefined ? { 'Content-Type': outbound.contentType } : {}),
1610
+ ...(outbound.contentLength !== undefined ? { 'Content-Length': outbound.contentLength } : {}),
1611
+ ...(outbound.cacheControl !== undefined ? { 'Cache-Control': outbound.cacheControl } : {}),
1612
+ });
1613
+ // The pump: chunk-for-chunk with real backpressure — a full socket
1614
+ // pauses the plugin's response instead of buffering it here, so a
1615
+ // slow phone reads a big file without this process ever holding
1616
+ // more than a socket buffer of it. Every chunk re-checks the
1617
+ // response's liveness first (the v1/stream write discipline): a
1618
+ // client that died mid-stream stops the pump on its next chunk
1619
+ // instead of racing one more write into a dead socket, and a
1620
+ // response 'error' (a write racing a destroyed socket surfaces
1621
+ // there) reacts exactly like the hang-up.
1622
+ let clientGoneSeen = false;
1623
+ await new Promise((resolve, reject) => {
1624
+ const upstream = outbound.stream;
1625
+ upstream.on('data', (chunk) => {
1626
+ if (clientGone || res.destroyed || res.writableEnded) {
1627
+ upstream.destroy();
1628
+ return;
1629
+ }
1630
+ if (!res.write(chunk)) {
1631
+ upstream.pause();
1632
+ res.once('drain', () => upstream.resume());
1633
+ }
1634
+ });
1635
+ upstream.on('end', () => resolve());
1636
+ upstream.on('error', reject);
1637
+ });
1638
+ // The v1/stream finish discipline (T43-A): the tail is in the
1639
+ // kernel's hands now, and a client that stopped reading must not
1640
+ // pin this device slot forever — one watchdog, disarmed by `finish`
1641
+ // (the honest drained signal) or `close`, destroys otherwise.
1642
+ await new Promise((resolve) => {
1643
+ if (res.writableEnded || res.destroyed) {
1644
+ resolve();
1645
+ return;
1646
+ }
1647
+ const watchdog = setTimeout(() => {
1648
+ try {
1649
+ res.destroy();
1650
+ }
1651
+ catch {
1652
+ // Already gone.
1653
+ }
1654
+ }, endDrainTimeoutMs);
1655
+ if (typeof watchdog.unref === 'function')
1656
+ watchdog.unref();
1657
+ const settled = () => {
1658
+ clearTimeout(watchdog);
1659
+ resolve();
1660
+ };
1661
+ res.once('finish', settled);
1662
+ res.once('close', settled);
1663
+ res.end();
1664
+ });
1665
+ }
1666
+ catch (error) {
1667
+ if (!res.headersSent && bodyGateError !== undefined && bodyGateError.message === 'body-too-large') {
1668
+ // The counting gate tripped mid-forward (PB-9): a chunked body
1669
+ // past the route's `maxBody`. The plugin's answer cannot have
1670
+ // started while we were still writing its request body, so the
1671
+ // honest 413 still fits on the wire; the relay body's tail is
1672
+ // drained after the answer, bounded and timed like every
1673
+ // oversized read here.
1674
+ relayPluginError(res, 413, 'body-too-large');
1675
+ void drainUpload(req, 0, drainDeadlineMs);
1676
+ }
1677
+ else if (hangUp.signal.aborted) {
1678
+ // The sub-client is gone mid-stream: its socket is dead, and the
1679
+ // abort already tore the loopback request down.
1680
+ try {
1681
+ res.destroy();
1682
+ }
1683
+ catch {
1684
+ // Already gone.
1685
+ }
1686
+ }
1687
+ else if (isLoopbackAuthError(error)) {
1688
+ // The cookie could not be obtained (no token URL on this host, a
1689
+ // failed exchange) — the relay's own wall, not a business answer.
1690
+ relayPluginError(res, 502, 'loopback-auth');
1691
+ }
1692
+ else if (!res.headersSent) {
1693
+ // Any other loopback failure before the answer started (the web
1694
+ // server moved, the plugin route crashed early) is this server's
1695
+ // fault: the bare internal code, no message.
1696
+ relayPluginError(res, 502, 'internal');
1697
+ }
1698
+ else {
1699
+ // Mid-stream failure with headers already on the wire: the status
1700
+ // cannot be changed, so the response is cut — the client sees a
1701
+ // truncated body, the honest shape of a broken stream.
1702
+ try {
1703
+ res.destroy();
1704
+ }
1705
+ catch {
1706
+ // Already gone.
1707
+ }
1708
+ }
1709
+ }
1710
+ finally {
1711
+ res.off('close', onClientGone);
1712
+ res.off('error', onClientGone);
1713
+ const remaining = (pluginsByDevice.get(device) ?? 1) - 1;
1714
+ if (remaining <= 0)
1715
+ pluginsByDevice.delete(device);
1716
+ else
1717
+ pluginsByDevice.set(device, remaining);
1718
+ }
1719
+ return;
1720
+ }
1721
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/open`) {
1722
+ const tunnel = options.pluginBridge?.tunnel;
1723
+ if (tunnel === undefined) {
1724
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
1725
+ return;
1726
+ }
1727
+ await tunnel.open(req, res);
1728
+ return;
1729
+ }
1730
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/send`) {
1731
+ const tunnel = options.pluginBridge?.tunnel;
1732
+ if (tunnel === undefined) {
1733
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
1734
+ return;
1735
+ }
1736
+ await tunnel.send(req, res);
1737
+ return;
1738
+ }
1739
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/plugin-tunnel/close`) {
1740
+ const tunnel = options.pluginBridge?.tunnel;
1741
+ if (tunnel === undefined) {
1742
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } });
1743
+ return;
1744
+ }
1745
+ await tunnel.close(req, res);
1746
+ return;
1747
+ }
1089
1748
  if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/upload`) {
1090
1749
  // The binary upload channel (T51): the sub-client's wrapped
1091
1750
  // `/api/session/uploadFileBinary` forwards a remote session's raw
@@ -1678,8 +2337,7 @@ export function createRelayHandler(options) {
1678
2337
  closeWith(null);
1679
2338
  }
1680
2339
  else {
1681
- const { code, message } = errorOf(error);
1682
- closeWith(message === undefined ? { type: 'error', error: { code } } : { type: 'error', error: { code, message } });
2340
+ closeWith({ type: 'error', error: errorEnvelopeOf(error).error });
1683
2341
  }
1684
2342
  }
1685
2343
  finally {