dsh-zen-remote 2.0.3 → 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 (114) 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/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/plugin-adapter-core.js +385 -0
  30. package/lib/plugin-adapter-core.js.map +1 -0
  31. package/lib/plugin-bridge.js +318 -0
  32. package/lib/plugin-bridge.js.map +1 -0
  33. package/lib/plugin-loopback.js +207 -0
  34. package/lib/plugin-loopback.js.map +1 -0
  35. package/lib/plugin-route-intercept.js +1103 -0
  36. package/lib/plugin-route-intercept.js.map +1 -0
  37. package/lib/plugin-tunnel.js +416 -0
  38. package/lib/plugin-tunnel.js.map +1 -0
  39. package/lib/relay-client.js +319 -2
  40. package/lib/relay-client.js.map +1 -1
  41. package/lib/relay-server.js +633 -0
  42. package/lib/relay-server.js.map +1 -1
  43. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  44. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  45. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
  46. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  47. package/lib/types/adapters/index.d.ts +17 -0
  48. package/lib/types/adapters/index.d.ts.map +1 -0
  49. package/lib/types/adapters/types.d.ts +42 -0
  50. package/lib/types/adapters/types.d.ts.map +1 -0
  51. package/lib/types/admin-routes.d.ts +16 -0
  52. package/lib/types/admin-routes.d.ts.map +1 -1
  53. package/lib/types/client/effects/remote-session.d.ts +0 -22
  54. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  55. package/lib/types/client/locales.d.ts +16 -0
  56. package/lib/types/client/locales.d.ts.map +1 -1
  57. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  58. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  59. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  60. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  61. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  62. package/lib/types/client-data/remote-status.d.ts +20 -0
  63. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  64. package/lib/types/client-data/settings-form.d.ts +37 -1
  65. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  66. package/lib/types/client-routes.d.ts +13 -0
  67. package/lib/types/client-routes.d.ts.map +1 -1
  68. package/lib/types/config.d.ts +8 -0
  69. package/lib/types/config.d.ts.map +1 -1
  70. package/lib/types/http.d.ts +7 -0
  71. package/lib/types/http.d.ts.map +1 -1
  72. package/lib/types/index.d.ts +4 -0
  73. package/lib/types/index.d.ts.map +1 -1
  74. package/lib/types/plugin-adapter-core.d.ts +263 -0
  75. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  76. package/lib/types/plugin-bridge.d.ts +187 -0
  77. package/lib/types/plugin-bridge.d.ts.map +1 -0
  78. package/lib/types/plugin-loopback.d.ts +104 -0
  79. package/lib/types/plugin-loopback.d.ts.map +1 -0
  80. package/lib/types/plugin-route-intercept.d.ts +114 -0
  81. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  82. package/lib/types/plugin-tunnel.d.ts +96 -0
  83. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  84. package/lib/types/relay-client.d.ts +76 -0
  85. package/lib/types/relay-client.d.ts.map +1 -1
  86. package/lib/types/relay-server.d.ts +32 -1
  87. package/lib/types/relay-server.d.ts.map +1 -1
  88. package/llms.txt +1 -0
  89. package/package.json +2 -2
  90. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  91. package/scripts/check-settings-form.mjs +3 -1
  92. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  93. package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
  94. package/src/adapters/index.ts +19 -0
  95. package/src/adapters/types.ts +44 -0
  96. package/src/admin-routes.ts +64 -0
  97. package/src/client/effects/remote-session.ts +82 -10
  98. package/src/client/locales.ts +36 -0
  99. package/src/client/settings/SettingsSection.tsx +107 -3
  100. package/src/client/styles/remote-session.css.ts +70 -31
  101. package/src/client-data/plugin-bridge.ts +150 -0
  102. package/src/client-data/remote-status.ts +30 -0
  103. package/src/client-data/settings-form.ts +78 -0
  104. package/src/client-routes.ts +29 -1
  105. package/src/config.ts +32 -0
  106. package/src/http.ts +12 -0
  107. package/src/index.ts +252 -0
  108. package/src/plugin-adapter-core.ts +530 -0
  109. package/src/plugin-bridge.ts +435 -0
  110. package/src/plugin-loopback.ts +299 -0
  111. package/src/plugin-route-intercept.ts +1193 -0
  112. package/src/plugin-tunnel.ts +470 -0
  113. package/src/relay-client.ts +394 -2
  114. package/src/relay-server.ts +656 -1
@@ -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];
@@ -493,6 +511,16 @@ export function createRelayHandler(options) {
493
511
  const invokesByDevice = new Map();
494
512
  /** Uploads currently in flight per device id, for the upload budget (T51-fix). */
495
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();
496
524
  /**
497
525
  * The device NAME this process last saw per device id (T59): every relay
498
526
  * request rides the gateway's fresh `x-zen-remote-device-name` marking
@@ -768,6 +796,20 @@ export function createRelayHandler(options) {
768
796
  catch {
769
797
  fingerprints = {};
770
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
+ }
771
813
  responseJson(res, 200, {
772
814
  ok: true,
773
815
  relayProtocol: RELAY_PROTOCOL,
@@ -777,6 +819,9 @@ export function createRelayHandler(options) {
777
819
  // T42: the server's own interface fingerprints (empty when the wiring
778
820
  // had nothing to compute — the client judges group by group).
779
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,
780
825
  // T59: the CALLING device's name, as the gateway's device table holds
781
826
  // it at this moment (the header is written fresh per forward, so a
782
827
  // just-renamed device reads its new name here). Undefined without the
@@ -1112,6 +1157,594 @@ export function createRelayHandler(options) {
1112
1157
  }
1113
1158
  return;
1114
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
+ }
1115
1748
  if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/upload`) {
1116
1749
  // The binary upload channel (T51): the sub-client's wrapped
1117
1750
  // `/api/session/uploadFileBinary` forwards a remote session's raw