dsh-zen-remote 2.3.3 → 2.3.5

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 (76) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/plugin-bridge.md +14 -7
  4. package/docs/remote-access.en.md +8 -8
  5. package/docs/remote-access.md +10 -10
  6. package/lib/adapters/dsh-better-sidebar/index.js +21 -8
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
  9. package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
  10. package/lib/client.js +102 -30
  11. package/lib/index.js +103 -10
  12. package/lib/index.js.map +1 -1
  13. package/lib/intercept.js +23 -1
  14. package/lib/intercept.js.map +1 -1
  15. package/lib/merge-streams.js +29 -2
  16. package/lib/merge-streams.js.map +1 -1
  17. package/lib/plugin-adapter-core.js +69 -13
  18. package/lib/plugin-adapter-core.js.map +1 -1
  19. package/lib/plugin-bridge.js +179 -12
  20. package/lib/plugin-bridge.js.map +1 -1
  21. package/lib/plugin-route-intercept.js +631 -79
  22. package/lib/plugin-route-intercept.js.map +1 -1
  23. package/lib/plugin-tunnel.js +84 -12
  24. package/lib/plugin-tunnel.js.map +1 -1
  25. package/lib/relay-server.js +65 -47
  26. package/lib/relay-server.js.map +1 -1
  27. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  28. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
  29. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
  30. package/lib/types/adapters/types.d.ts +14 -3
  31. package/lib/types/adapters/types.d.ts.map +1 -1
  32. package/lib/types/client/effects/turn-fold.d.ts +42 -0
  33. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  34. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
  35. package/lib/types/index.d.ts +2 -2
  36. package/lib/types/index.d.ts.map +1 -1
  37. package/lib/types/intercept.d.ts +19 -0
  38. package/lib/types/intercept.d.ts.map +1 -1
  39. package/lib/types/merge-streams.d.ts +12 -0
  40. package/lib/types/merge-streams.d.ts.map +1 -1
  41. package/lib/types/plugin-adapter-core.d.ts +24 -11
  42. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  43. package/lib/types/plugin-bridge.d.ts +93 -5
  44. package/lib/types/plugin-bridge.d.ts.map +1 -1
  45. package/lib/types/plugin-route-intercept.d.ts +52 -3
  46. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  47. package/lib/types/plugin-tunnel.d.ts +27 -3
  48. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  49. package/lib/types/relay-server.d.ts +2 -25
  50. package/lib/types/relay-server.d.ts.map +1 -1
  51. package/lib/types/upload-limits.d.ts +67 -0
  52. package/lib/types/upload-limits.d.ts.map +1 -0
  53. package/lib/types/wizard/proxy-snippets.d.ts +4 -3
  54. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  55. package/lib/upload-limits.js +67 -0
  56. package/lib/upload-limits.js.map +1 -0
  57. package/lib/wizard/proxy-snippets.js +5 -4
  58. package/lib/wizard/proxy-snippets.js.map +1 -1
  59. package/package.json +38 -38
  60. package/scripts/check-upload-limits.mjs +167 -0
  61. package/src/adapters/dsh-better-sidebar/index.ts +21 -9
  62. package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
  63. package/src/adapters/types.ts +14 -3
  64. package/src/client/effects/turn-fold.ts +97 -26
  65. package/src/client/styles/turn-fold.css.ts +9 -0
  66. package/src/client-data/wizard.ts +1 -1
  67. package/src/index.ts +99 -12
  68. package/src/intercept.ts +47 -1
  69. package/src/merge-streams.ts +41 -1
  70. package/src/plugin-adapter-core.ts +69 -14
  71. package/src/plugin-bridge.ts +228 -14
  72. package/src/plugin-route-intercept.ts +677 -82
  73. package/src/plugin-tunnel.ts +95 -13
  74. package/src/relay-server.ts +60 -48
  75. package/src/upload-limits.ts +69 -0
  76. package/src/wizard/proxy-snippets.ts +5 -4
@@ -9,9 +9,44 @@
9
9
  * cannot be served locally (the session lives on the server): the surrogate
10
10
  * handler forwards it through `relay.pluginRequest` (PB-3's
11
11
  * `relay/v1/plugin`) with the real id restored, and streams the answer
12
- * back. Everything else — undeclared routes, local sessions — flows to the
12
+ * back (PB-21: where the id lives is the route's own declaration — the 2.1
13
+ * query parameter, or a declaration-v2 locator in a prefix route's path
14
+ * segment or the JSON body; the v2 shapes ride the SAME adapter-core
15
+ * locate → classify → rewrite functions the adapters run, and a
16
+ * `prefix: true` route forwards its full matched, rewritten path).
17
+ * Everything else — undeclared routes, local sessions — flows to the
13
18
  * original route untouched.
14
19
  *
20
+ * PB-22 gives the two remaining declaration-v2 flags their client meaning:
21
+ * a `deny: true` route answers the uniform 503 with reason `route-denied`
22
+ * whenever a REMOTE context touches it — the request locating a virtual id,
23
+ * or the browser's CURRENT session being one (T62's signal) — while a local
24
+ * session passes through exactly as before; a `rewriteIds: true` route's
25
+ * JSON answer is buffered (the 8 MB adapter cap) and deep-scanned, every
26
+ * string EXACTLY equal to one of the request's own real session ids
27
+ * rewritten to its virtual form (`zr~` carries the real id inside, so the
28
+ * request's own hits ARE the read-only mapping — no table is consulted or
29
+ * written), unknown strings passing through untouched.
30
+ *
31
+ * PB-25a adds the position form: `rewriteIds: { fields }` declares the dot
32
+ * paths where the response's NEWLY-CREATED session ids sit (a creation
33
+ * route's `childId` was never in the request, so no value scan can catch
34
+ * it). On top of the value scan, each declared path's non-empty, non-virtual
35
+ * string gains the virtual shell of THIS request's server id; missing paths
36
+ * are no-ops and nothing is ever guessed beyond the declared positions.
37
+ *
38
+ * PB-25b widens the value scan's lookup from the request-local mapping to
39
+ * the request-local mapping UNION the KNOWN-real-session inventory: the
40
+ * merged global streams report every real id the sub-client currently holds
41
+ * (the sidebar's `zr~` rows — subagents.live's object keys, workflows.list
42
+ * rows: ids in the browser's view but not in THIS request), and a string
43
+ * exactly equal to one of those is rewritten too, in the same request-sourced
44
+ * virtual shell. The inventory is READ-ONLY consumption — the scan never
45
+ * touches merge state — and it is read live per answer: a stream refresh
46
+ * replaces its share, and the next answer sees the fresh union (ids that
47
+ * appear between two refreshes wait for the next refresh — the documented
48
+ * ceiling). Without the option the scan is exactly the PB-22 behavior.
49
+ *
15
50
  * The un-bridged refusals answer a UNIFORM 503 shape (header
16
51
  * `x-zen-remote: unbridged`, JSON body naming the reason) — and, decisive
17
52
  * for the security posture, they answer WITHOUT the local plugin handler
@@ -24,13 +59,178 @@
24
59
  * and ours goes inert instead (the same posture as intercept.ts and
25
60
  * fetch-route-intercept.ts).
26
61
  */
27
- import { routeMethodsOf, routeMaxBodyOf, routeUpgradesOf } from './plugin-bridge.js';
62
+ import { routeDeniesRemoteOf, routeLocatorOf, routeMaxBodyOf, routeMethodsOf, routePrefixOf, routeRewriteFieldsOf, routeRewritesIdsOf, routeUpgradesOf } from './plugin-bridge.js';
28
63
  import { PassThrough, Readable, Transform } from 'node:stream';
29
64
  import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js';
30
65
  import { adapterPathMatches, classifyAdapterRequest, rewriteAdapterRequest, adapterOwnsRegistryEntry, MAX_ADAPTER_RESPONSE_BUFFER_BYTES, ADAPTER_RESPONSE_HEADER_ALLOWLIST, AdapterMultiValueQueryError } from './plugin-adapter-core.js';
31
66
  function isRecord(value) {
32
67
  return typeof value === 'object' && value !== null && !Array.isArray(value);
33
68
  }
69
+ /** PB-22: the rewriteIds deep scan — every string in the parsed JSON that is
70
+ * EXACTLY a key of `known` becomes its virtual form; everything else passes
71
+ * through untouched. The mapping's membership is the ONLY rewrite trigger —
72
+ * no shape guessing (a session-id-LOOKING string the request never carried
73
+ * is not ours to rewrite), and object KEYS are left alone: the scan walks
74
+ * object values, array items and bare strings, exactly the positions a
75
+ * response can hand an id back to the browser in. Containers are rebuilt
76
+ * unconditionally, which keeps the rule obvious at the cost of allocations
77
+ * bounded by the 8 MB buffer cap.
78
+ *
79
+ * PB-25b adds the second lookup: a string exactly equal to a member of
80
+ * `knownIds` (the merged streams' known-real-session inventory, `undefined`
81
+ * when the wiring passed none — the scan then stays exactly the request-local
82
+ * behavior) is rewritten into THIS request's virtual shell, same source as
83
+ * the position rewrite's serverId. Already-virtual strings are never
84
+ * re-wrapped, and the set is read-only: the scan consults it, never mutates
85
+ * it. */
86
+ function deepRewriteIds(value, known, knownIds, serverId) {
87
+ if (typeof value === 'string') {
88
+ const mapped = known.get(value);
89
+ if (mapped !== undefined)
90
+ return mapped;
91
+ if (knownIds !== undefined && !isVirtual(value) && knownIds.has(value))
92
+ return toVirtual(serverId, value);
93
+ return value;
94
+ }
95
+ if (Array.isArray(value))
96
+ return value.map((item) => deepRewriteIds(item, known, knownIds, serverId));
97
+ if (isRecord(value)) {
98
+ const out = {};
99
+ for (const [key, item] of Object.entries(value))
100
+ out[key] = deepRewriteIds(item, known, knownIds, serverId);
101
+ return out;
102
+ }
103
+ return value;
104
+ }
105
+ /** PB-25a: the position-based half of a `rewriteIds: { fields }` route's
106
+ * rewrite. Runs AFTER the value scan above, on the (rebuilt, private) parsed
107
+ * answer: each declared dot path is walked object-by-object, and a path that
108
+ * exists and holds a NON-EMPTY, NON-virtual string gains its virtual shell
109
+ * (`zr~<serverId>~<id>`, the serverId of the request this answer belongs to
110
+ * — a forward always located one). A path that does not exist — or that
111
+ * would walk through an array or a scalar — is a no-op: the answer's shape
112
+ * is the plugin's business, never a rewrite trigger (no shape guessing), and
113
+ * array elements stay the value scan's business (the declaration refuses
114
+ * numeric segments outright). Already-virtual values are skipped, so a known
115
+ * id sitting on a declared path is rewritten exactly once, by whichever half
116
+ * reached it first. */
117
+ function rewriteIdsAtFields(root, fields, serverId) {
118
+ for (const field of fields) {
119
+ const segments = field.split('.');
120
+ let node = root;
121
+ for (const segment of segments.slice(0, -1)) {
122
+ node = isRecord(node) ? node[segment] : undefined;
123
+ }
124
+ if (!isRecord(node))
125
+ continue;
126
+ const key = segments[segments.length - 1];
127
+ const value = node[key];
128
+ if (typeof value === 'string' && value !== '' && !isVirtual(value))
129
+ node[key] = toVirtual(serverId, value);
130
+ }
131
+ }
132
+ /** Pump a response body reader into the browser response with real
133
+ * backpressure — the one discipline every verbatim passthrough runs. The
134
+ * `close` listener settles the wait when the browser vanishes mid-body (a
135
+ * lost drain event would otherwise hang the write loop). */
136
+ async function pumpResponse(res, reader) {
137
+ while (true) {
138
+ const { done, value } = await reader.read();
139
+ if (done)
140
+ break;
141
+ if (!res.write(value)) {
142
+ await new Promise((resolve) => {
143
+ const settled = () => {
144
+ res.off('drain', settled);
145
+ res.off('close', settled);
146
+ resolve();
147
+ };
148
+ res.once('drain', settled);
149
+ res.once('close', settled);
150
+ });
151
+ }
152
+ }
153
+ }
154
+ /** PB-22/PB-25a/PB-25b: a rewriteIds route's JSON answer — buffered whole
155
+ * (the 8 MB adapter cap), deep-scanned with the request's own real→virtual
156
+ * mapping PLUS the known-real-session inventory (read live by the caller),
157
+ * plus, when the route declared field paths, the position rewrite on those
158
+ * paths, re-serialized with a corrected content-length. Past the cap, a
159
+ * non-JSON content type, or a parse failure the answer passes through
160
+ * untouched (the adapter discipline): a rewrite is strictly
161
+ * better-than-nothing, never a reason to mangle or refuse an answer. The
162
+ * caller has already handled the relay's refusal marker — this owns the
163
+ * entire normal answer. */
164
+ async function answerRewriteIds(res, response, known, fields, serverId, knownIds) {
165
+ const contentType = response.headers.get('content-type');
166
+ const verbatimHeaders = {
167
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
168
+ ...(response.headers.get('content-length') !== null ? { 'content-length': response.headers.get('content-length') } : {}),
169
+ ...(response.headers.get('cache-control') !== null ? { 'cache-control': response.headers.get('cache-control') } : {}),
170
+ };
171
+ const reader = response.body?.getReader();
172
+ if (reader === undefined || contentType === null || !contentType.includes('application/json')) {
173
+ res.writeHead(response.status, verbatimHeaders);
174
+ if (reader !== undefined)
175
+ await pumpResponse(res, reader);
176
+ res.end();
177
+ return;
178
+ }
179
+ const chunks = [];
180
+ let size = 0;
181
+ let overflow = false;
182
+ while (true) {
183
+ const { done, value } = await reader.read();
184
+ if (done)
185
+ break;
186
+ size += value.length;
187
+ // The over-cap chunk ITSELF is body content — it lands in the
188
+ // passthrough buffer too; only the REWRITE is cancelled.
189
+ chunks.push(Buffer.from(value));
190
+ if (size > MAX_ADAPTER_RESPONSE_BUFFER_BYTES) {
191
+ overflow = true;
192
+ break;
193
+ }
194
+ }
195
+ if (overflow) {
196
+ res.writeHead(response.status, verbatimHeaders);
197
+ for (const chunk of chunks) {
198
+ if (!res.write(chunk)) {
199
+ await new Promise((resolve) => {
200
+ const settled = () => {
201
+ res.off('drain', settled);
202
+ res.off('close', settled);
203
+ resolve();
204
+ };
205
+ res.once('drain', settled);
206
+ res.once('close', settled);
207
+ });
208
+ }
209
+ }
210
+ await pumpResponse(res, reader);
211
+ res.end();
212
+ return;
213
+ }
214
+ const text = Buffer.concat(chunks).toString('utf8');
215
+ let outBody;
216
+ try {
217
+ const scanned = deepRewriteIds(JSON.parse(text), known, knownIds, serverId);
218
+ if (fields !== undefined)
219
+ rewriteIdsAtFields(scanned, fields, serverId);
220
+ outBody = Buffer.from(JSON.stringify(scanned), 'utf8');
221
+ }
222
+ catch {
223
+ // Not JSON after all (or the scan hit something unserializable): the
224
+ // original bytes pass through, length recomputed for the corrected
225
+ // header set.
226
+ outBody = Buffer.from(text, 'utf8');
227
+ res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) });
228
+ res.end(outBody);
229
+ return;
230
+ }
231
+ res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) });
232
+ res.end(outBody);
233
+ }
34
234
  /** Pure inspection of the RAW webServer instance — nothing is mutated, so a
35
235
  * failed check leaves every route exactly as it was. */
36
236
  export function checkWebServerShape(webServer) {
@@ -51,6 +251,26 @@ export function checkWebServerShape(webServer) {
51
251
  }
52
252
  /** Longest failure ring kept for the status surface. */
53
253
  const MAX_FAILURES = 10;
254
+ /** The NormalizedBridgeRoute read through the adapter-core route shapes
255
+ * (PB-21): the declaration-v2 locators must ride the SAME locate →
256
+ * classify → rewrite functions the adapters run — never a second copy of
257
+ * those rules — so the cooperative route is handed to them as the
258
+ * NormalizedAdapterRoute those functions consume. Every field comes from a
259
+ * plugin-bridge effective reader; `deny` stays unset — the cooperative
260
+ * `deny: true` flag is a static declaration, not an adapter-style hook, so
261
+ * the surrogate halves judge it themselves (PB-22). */
262
+ function bridgeRouteAsAdapterRoute(route) {
263
+ return {
264
+ path: route.path,
265
+ match: routePrefixOf(route) ? 'prefix' : 'exact',
266
+ methods: routeMethodsOf(route),
267
+ maxBody: routeMaxBodyOf(route),
268
+ stream: route.stream,
269
+ upgrade: routeUpgradesOf(route),
270
+ locator: routeLocatorOf(route),
271
+ local: false,
272
+ };
273
+ }
54
274
  export function installPluginRouteIntercept(options) {
55
275
  // Shape FIRST, like every wrap: a caller that skipped checkWebServerShape
56
276
  // (or a host whose shape changed between the check and this call) gets an
@@ -82,6 +302,7 @@ export function installPluginRouteIntercept(options) {
82
302
  const { webServer, registry, relay, getServerId, getStatus, log } = options;
83
303
  const adapters = options.adapters ?? [];
84
304
  const getCurrentSession = options.getCurrentSession;
305
+ const getKnownRealIds = options.getKnownRealIds;
85
306
  const target = webServer;
86
307
  const savedMatch = target.match;
87
308
  let installed = true;
@@ -137,29 +358,42 @@ export function installPluginRouteIntercept(options) {
137
358
  const handlerOf = (route) => route !== undefined && isRecord(route) && typeof route.handler === 'function'
138
359
  ? route.handler
139
360
  : undefined;
140
- /** The declared route this pathname hits — EXACT equality only this task
141
- * (a declared prefix does not cover longer paths; that arrives with
142
- * PB-12). First plugin in registry order wins, deterministically. */
361
+ /** The declared route this pathname hits — exact equality, or, for a
362
+ * route declared `prefix: true` (PB-21), equality or one path segment
363
+ * deeper on the RAW undecoded pathname. The rule is `adapterPathMatches`
364
+ * itself, run against the route's adapter-core view, so the cooperative
365
+ * match can never drift from the adapters'. First plugin in registry
366
+ * order wins, and within it the first route in declaration order — a
367
+ * pathname two routes of one plugin can both hit belongs to the earlier
368
+ * one, exactly as the exact-only lookup always did. */
143
369
  const declaredRouteOf = (pathname) => {
144
370
  for (const declaration of registry.list()) {
145
371
  for (const route of declaration.routes) {
146
- if (route.path === pathname) {
147
- return {
148
- plugin: declaration.plugin,
149
- version: declaration.version,
150
- digest: declaration.digest,
151
- path: route.path,
152
- sessionParam: route.sessionParam,
153
- methods: routeMethodsOf(route),
154
- maxBody: routeMaxBodyOf(route),
155
- };
156
- }
372
+ const view = bridgeRouteAsAdapterRoute(route);
373
+ if (!adapterPathMatches(view, pathname))
374
+ continue;
375
+ return {
376
+ plugin: declaration.plugin,
377
+ version: declaration.version,
378
+ digest: declaration.digest,
379
+ path: route.path,
380
+ locator: routeLocatorOf(route),
381
+ prefix: routePrefixOf(route),
382
+ methods: routeMethodsOf(route),
383
+ maxBody: routeMaxBodyOf(route),
384
+ deny: routeDeniesRemoteOf(route),
385
+ rewriteIds: routeRewritesIdsOf(route),
386
+ rewriteFields: routeRewriteFieldsOf(route),
387
+ view,
388
+ };
157
389
  }
158
390
  }
159
391
  return undefined;
160
392
  };
161
- /** The surrogate handler: ONE query parse, the virtual-id gate, the
162
- * unbridged walls in contract order, then the relay round-trip. */
393
+ /** The surrogate handler, query locator's half (every 2.1 route and an
394
+ * explicit query declaration): ONE query parse, the virtual-id gate, the
395
+ * unbridged walls in contract order, then the relay round-trip. A
396
+ * declaration-v2 locator dispatches to the PB-21 half below. */
163
397
  const surrogateHandler = (declared, originalHandler) => async (req, res) => {
164
398
  const passthrough = async () => {
165
399
  if (originalHandler !== undefined) {
@@ -175,17 +409,29 @@ export function installPluginRouteIntercept(options) {
175
409
  await passthrough();
176
410
  return;
177
411
  }
412
+ // PB-21: a v2 locator (path-segment / json-body) runs the adapter-core
413
+ // pipeline in its own half — the query locator keeps THIS code path
414
+ // verbatim. The dispatch sits after the installed check, so the v2
415
+ // half never runs chained-over either.
416
+ if (declared.locator.in !== 'query') {
417
+ await surrogateV2Handler(declared, originalHandler)(req, res);
418
+ return;
419
+ }
178
420
  // ONE parse of the query (the session parameter's home — `match` only
179
- // ever saw the pathname): everything below reads THIS parse.
180
- let searchParams;
421
+ // ever saw the pathname): everything below reads THIS parse. The
422
+ // parameter NAME is the effective locator's (the 2.1 sessionParam for
423
+ // a route that declared no locator — the same value routeLocatorOf
424
+ // reads back out).
425
+ let requestUrl;
181
426
  try {
182
- searchParams = new URL(req.url ?? '/', 'http://local').searchParams;
427
+ requestUrl = new URL(req.url ?? '/', 'http://local');
183
428
  }
184
429
  catch {
185
430
  answerUnbridged(res, declared.plugin, 'bad-query');
186
431
  return;
187
432
  }
188
- const values = searchParams.getAll(declared.sessionParam);
433
+ const searchParams = requestUrl.searchParams;
434
+ const values = searchParams.getAll(declared.locator.name);
189
435
  // The parameter appearing MORE than once is a shape violation the
190
436
  // bridge refuses (bad-query) — never a passthrough, and never a
191
437
  // forward of whichever duplicate a parser happens to keep. Its TOTAL
@@ -196,6 +442,20 @@ export function installPluginRouteIntercept(options) {
196
442
  return;
197
443
  }
198
444
  const value = values.length === 1 ? values[0] : undefined;
445
+ // PB-22: a `deny: true` route refuses a REMOTE context outright — the
446
+ // request locating a virtual id, or the browser's CURRENT session
447
+ // being one (T62's signal; requests like open.external carry no id of
448
+ // their own). The uniform 503 with the route's own reason: the local
449
+ // handler is never reached, and the forward path below is unreachable
450
+ // for this route. A local session's request without a virtual id falls
451
+ // through — `deny` must not touch local behavior.
452
+ if (declared.deny) {
453
+ const current = getCurrentSession?.();
454
+ if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
455
+ answerUnbridged(res, declared.plugin, 'route-denied');
456
+ return;
457
+ }
458
+ }
199
459
  // A LOCAL id (or none at all): the original route's business — its
200
460
  // handler runs exactly as if we were never here.
201
461
  if (value === undefined || value === '' || !value.startsWith('zr~')) {
@@ -246,7 +506,15 @@ export function installPluginRouteIntercept(options) {
246
506
  log?.('plugin route %s forwards for %s', declared.path, declared.plugin);
247
507
  // The session id rides the CHECKED parse, restored to its ORIGINAL
248
508
  // form, re-serialized once — what was judged is what is forwarded.
249
- searchParams.set(declared.sessionParam, parts.id);
509
+ searchParams.set(declared.locator.name, parts.id);
510
+ // PB-22: the request's own real→virtual pair — the READ-ONLY mapping a
511
+ // rewriteIds route's answer scan consults (`zr~` carries the real id
512
+ // inside, so the ids this very request named are exactly the ones the
513
+ // answer may hand back). PB-25b: the scan consults ONE more source
514
+ // beside this mapping — the known-real-session inventory, read live at
515
+ // the answer (never copied here) and consumed read-only; nothing here
516
+ // writes any session table.
517
+ const idMapping = new Map([[parts.id, value]]);
250
518
  const controller = new AbortController();
251
519
  // The counting gate's failure has its OWN abort signal (PB-9-fix, the
252
520
  // mirror of the relay route's bodyGateError): tripping the gate is a
@@ -301,7 +569,11 @@ export function installPluginRouteIntercept(options) {
301
569
  plugin: declared.plugin,
302
570
  version: declared.version,
303
571
  digest: declared.digest,
304
- path: declared.path,
572
+ // A prefix route (PB-21) forwards its FULL matched pathname —
573
+ // the declared base alone would lose the variable tail; an
574
+ // exact route's pathname IS the declared path, the bytes that
575
+ // always travelled.
576
+ path: declared.prefix ? requestUrl.pathname : declared.path,
305
577
  query: searchParams.toString(),
306
578
  method,
307
579
  ...(hasBody ? { body: bodyStream } : {}),
@@ -319,6 +591,17 @@ export function installPluginRouteIntercept(options) {
319
591
  answerUnbridged(res, declared.plugin, refusal);
320
592
  return;
321
593
  }
594
+ // PB-22: a rewriteIds route's answer is buffered and deep-scanned
595
+ // (the ids this request named come back virtual); everything else —
596
+ // non-JSON answers included — streams verbatim. PB-25a: a
597
+ // `{ fields }` declaration adds the position rewrite, under THIS
598
+ // request's own server id. PB-25b: the scan's lookup widens to the
599
+ // known inventory, read live right here (undefined when the wiring
600
+ // passed no getter — the scan stays request-local).
601
+ if (declared.rewriteIds) {
602
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.());
603
+ return;
604
+ }
322
605
  const contentType = response.headers.get('content-type');
323
606
  const contentLength = response.headers.get('content-length');
324
607
  const cacheControl = response.headers.get('cache-control');
@@ -330,24 +613,8 @@ export function installPluginRouteIntercept(options) {
330
613
  // Stream the body through with real backpressure; a mid-stream
331
614
  // failure with headers already out can only cut the response.
332
615
  const reader = response.body?.getReader();
333
- if (reader !== undefined) {
334
- while (true) {
335
- const { done, value } = await reader.read();
336
- if (done)
337
- break;
338
- if (!res.write(value)) {
339
- await new Promise((resolve) => {
340
- const settled = () => {
341
- res.off('drain', settled);
342
- res.off('close', settled);
343
- resolve();
344
- };
345
- res.once('drain', settled);
346
- res.once('close', settled);
347
- });
348
- }
349
- }
350
- }
616
+ if (reader !== undefined)
617
+ await pumpResponse(res, reader);
351
618
  res.end();
352
619
  }
353
620
  catch (error) {
@@ -386,6 +653,308 @@ export function installPluginRouteIntercept(options) {
386
653
  res.off('close', onClientGone);
387
654
  }
388
655
  };
656
+ /** The PB-21 surrogate half for the declaration-v2 locators — json-body,
657
+ * and path-segment on a prefix route: the adapter surrogate's pipeline
658
+ * (locate → classify → walls → rewrite → the relay round-trip) run
659
+ * through the SAME adapter-core functions against the route's
660
+ * adapter-core view. The response discipline stays the query half's —
661
+ * three headers, no hooks: `deny` and `rewriteIds` are PB-22's business.
662
+ * Dispatch above guarantees `installed` and a non-query locator. */
663
+ const surrogateV2Handler = (declared, originalHandler) => async (req, res) => {
664
+ const plugin = declared.plugin;
665
+ // The local passthrough. A json-body route has ALREADY consumed the
666
+ // request body by the time this runs (the classify needs the parsed
667
+ // body), so the replay hands the buffered bytes back behind the
668
+ // original request's metadata — the shape a local handler actually
669
+ // reads (method/url/headers/pipe).
670
+ const passthrough = async (replayBody) => {
671
+ if (originalHandler === undefined) {
672
+ res.writeHead(404);
673
+ res.end();
674
+ return;
675
+ }
676
+ if (replayBody !== undefined) {
677
+ const replay = new PassThrough();
678
+ replay.end(replayBody);
679
+ Object.assign(replay, { method: req.method, url: req.url, headers: req.headers });
680
+ await originalHandler(replay, res);
681
+ return;
682
+ }
683
+ await originalHandler(req, res);
684
+ };
685
+ let controller;
686
+ const onClientGone = () => {
687
+ if (controller !== undefined && !res.writableEnded)
688
+ controller.abort();
689
+ };
690
+ res.on('close', onClientGone);
691
+ // The counting gate's LOCAL flag (the query half's PB-9-fix shape): a
692
+ // tripped gate answers the local 413 in the catch below — never an
693
+ // offline verdict, never a half-written response.
694
+ let bodyGateFailed = false;
695
+ const bodyAbort = new AbortController();
696
+ try {
697
+ let url;
698
+ try {
699
+ url = new URL(req.url ?? '/', 'http://local');
700
+ }
701
+ catch {
702
+ answerUnbridged(res, plugin, 'bad-query');
703
+ return;
704
+ }
705
+ const pathname = url.pathname;
706
+ const searchParams = url.searchParams;
707
+ const method = req.method ?? 'GET';
708
+ const hasBody = method !== 'GET' && method !== 'HEAD';
709
+ // PB-22: the deny wall's current-session half — a remote panel is
710
+ // the request's origin even when it carries no id of its own
711
+ // (open.external's shape). Refused before any body byte is read.
712
+ if (declared.deny) {
713
+ const current = getCurrentSession?.();
714
+ if (current !== undefined && isVirtual(current)) {
715
+ answerUnbridged(res, plugin, 'route-denied');
716
+ return;
717
+ }
718
+ }
719
+ // A json-body route buffers the request body up front, bounded by
720
+ // the route's own maxBody — the cooperative 413 shape, answered
721
+ // locally, the plugin never reached. A path-segment route's body is
722
+ // never consumed by locating; it streams through the counting gate
723
+ // at the forward.
724
+ let bufferedBody;
725
+ if (hasBody && declared.locator.in === 'json-body') {
726
+ const chunks = [];
727
+ let size = 0;
728
+ let over = false;
729
+ for await (const chunk of req) {
730
+ size += chunk.length;
731
+ if (size > declared.maxBody) {
732
+ over = true;
733
+ break;
734
+ }
735
+ chunks.push(Buffer.from(chunk));
736
+ }
737
+ if (over) {
738
+ res.setHeader('content-type', 'application/json; charset=utf-8');
739
+ res.writeHead(413);
740
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }));
741
+ recordRefusal(plugin, 'body-too-large');
742
+ log?.('plugin request for %s refused locally: body-too-large (%s buffered)', declared.path, size);
743
+ return;
744
+ }
745
+ bufferedBody = Buffer.concat(chunks);
746
+ }
747
+ // A present body must parse as JSON when the route locates in it —
748
+ // a corrupted body refuses rather than falling through to the local
749
+ // plugin with a body the locate could not judge (the adapter half's
750
+ // shared rule).
751
+ let parsedBody;
752
+ let bodyGiven = false;
753
+ if (hasBody && declared.locator.in === 'json-body' && bufferedBody !== undefined && bufferedBody.length > 0) {
754
+ try {
755
+ parsedBody = JSON.parse(bufferedBody.toString('utf8'));
756
+ bodyGiven = true;
757
+ }
758
+ catch {
759
+ answerUnbridged(res, plugin, 'bad-query');
760
+ return;
761
+ }
762
+ }
763
+ // The remote/local verdict on the ids as they arrived — the
764
+ // adapter-core classify (locate inside), no second copy of the
765
+ // rule: every id virtual → forward, none → local, a mix → the
766
+ // uniform refusal (docs/adr/0001: a remote session never reaches a
767
+ // local plugin).
768
+ let verdict;
769
+ try {
770
+ verdict = classifyAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined);
771
+ }
772
+ catch (error) {
773
+ if (error instanceof AdapterMultiValueQueryError) {
774
+ answerUnbridged(res, plugin, 'bad-query');
775
+ return;
776
+ }
777
+ throw error;
778
+ }
779
+ // PB-22: the deny wall's request half — the locate found a virtual
780
+ // id (a remote verdict, or a mixed one: that too means a virtual id
781
+ // sat in the request). This route never serves a remote context, so
782
+ // the answer is the route's own refusal, not the mixed shape.
783
+ if (declared.deny && verdict.kind !== 'local') {
784
+ answerUnbridged(res, plugin, 'route-denied');
785
+ return;
786
+ }
787
+ if (verdict.kind === 'local') {
788
+ await passthrough(bufferedBody);
789
+ return;
790
+ }
791
+ if (verdict.kind === 'mixed') {
792
+ answerUnbridged(res, plugin, 'bad-query');
793
+ return;
794
+ }
795
+ // Remote: the route's method wall, then the per-id server walls —
796
+ // the query half's gates, re-run per located id.
797
+ if (!declared.methods.includes(method)) {
798
+ answerUnbridged(res, plugin, 'method-not-bridged');
799
+ return;
800
+ }
801
+ const serverId = getServerId();
802
+ // PB-22: the request's own real→virtual pairs, collected while the
803
+ // wall validates each hit — the READ-ONLY mapping a rewriteIds
804
+ // route's answer scan consults (`zr~` carries the real id inside, so
805
+ // the ids this very request named are exactly the ones the answer
806
+ // may hand back). PB-25a: the SAME unwrap hands the position rewrite
807
+ // its server id — the ids this request located live under it, so a
808
+ // NEWLY-created id joins them (the query half's source verbatim;
809
+ // `remote` always has at least one hit). PB-25b: the scan consults
810
+ // ONE more source beside this mapping — the known-real-session
811
+ // inventory, read live at the answer (never copied here) and
812
+ // consumed read-only; nothing here writes any session table.
813
+ const idMapping = new Map();
814
+ let locatedServerId;
815
+ for (const idHit of verdict.hits) {
816
+ const parts = fromVirtual(idHit.value);
817
+ if (parts === undefined || parts.id === '') {
818
+ answerUnbridged(res, plugin, 'bad-query');
819
+ return;
820
+ }
821
+ if (parts.serverId !== serverId) {
822
+ answerUnbridged(res, plugin, 'server-changed');
823
+ return;
824
+ }
825
+ idMapping.set(parts.id, idHit.value);
826
+ locatedServerId = parts.serverId;
827
+ }
828
+ // The bridge verdicts judged from the SAME live table the query
829
+ // half reads.
830
+ const status = getStatus().find((entry) => entry.plugin === plugin);
831
+ if (status === undefined || !status.bridged) {
832
+ answerUnbridged(res, plugin, status?.reason ?? 'not-on-server');
833
+ return;
834
+ }
835
+ // Real ids in, coordinates rewritten — the adapter-core rewrite:
836
+ // the query re-serialized, the path segment re-encoded, the JSON
837
+ // body re-serialized. The forward carries the REWRITTEN pathname —
838
+ // a prefix route's full matched path, never the declared base.
839
+ const rewrite = rewriteAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined, verdict.hits, (virtualId) => fromVirtual(virtualId).id);
840
+ forwarded += 1;
841
+ log?.('plugin route %s forwards for %s', rewrite.pathname, plugin);
842
+ controller = new AbortController();
843
+ const forwardSignal = AbortSignal.any([controller.signal, bodyAbort.signal]);
844
+ const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined;
845
+ const range = typeof req.headers.range === 'string' ? req.headers.range : undefined;
846
+ const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined;
847
+ const declaredLength = Number(req.headers['content-length']);
848
+ let bodyStream = null;
849
+ let contentLength;
850
+ if (hasBody && declared.locator.in === 'json-body') {
851
+ // The buffer forwards in full: the (possibly rewritten)
852
+ // re-serialization when ids sat in it, the original bytes
853
+ // otherwise. The REWRITTEN byte count is the declared length —
854
+ // the budget reads what actually travels.
855
+ const payload = rewrite.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : bufferedBody ?? Buffer.alloc(0);
856
+ contentLength = payload.length;
857
+ bodyStream = Readable.toWeb(Readable.from(payload));
858
+ }
859
+ else if (hasBody) {
860
+ // The pre-check first — a DECLARED Content-Length past the cap
861
+ // never starts a round-trip — then the counting gate for a
862
+ // chunked body, exactly the query half's PB-9 discipline.
863
+ if (Number.isFinite(declaredLength) && declaredLength > declared.maxBody) {
864
+ res.setHeader('content-type', 'application/json; charset=utf-8');
865
+ res.writeHead(413);
866
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }));
867
+ recordRefusal(plugin, 'body-too-large');
868
+ log?.('plugin request for %s refused locally: body-too-large (%s declared)', declared.path, req.headers['content-length']);
869
+ return;
870
+ }
871
+ let pumped = 0;
872
+ const gate = new Transform({
873
+ transform(chunk, _encoding, callback) {
874
+ pumped += chunk.length;
875
+ if (pumped > declared.maxBody) {
876
+ callback(new Error('zr-plugin-body-too-large'));
877
+ return;
878
+ }
879
+ callback(null, chunk);
880
+ },
881
+ });
882
+ req.on('error', (error) => gate.destroy(error));
883
+ gate.on('error', () => {
884
+ if (!bodyGateFailed)
885
+ bodyGateFailed = true;
886
+ bodyAbort.abort();
887
+ });
888
+ bodyStream = Readable.toWeb(gate);
889
+ req.pipe(gate);
890
+ if (Number.isFinite(declaredLength) && declaredLength > 0)
891
+ contentLength = declaredLength;
892
+ }
893
+ const response = await relay.pluginRequest({
894
+ plugin,
895
+ version: declared.version,
896
+ digest: declared.digest,
897
+ path: rewrite.pathname,
898
+ query: rewrite.query.toString(),
899
+ method,
900
+ ...(bodyStream !== null ? { body: bodyStream } : {}),
901
+ ...(bodyStream !== null && requestContentType !== undefined ? { contentType: requestContentType } : {}),
902
+ ...(bodyStream !== null && contentLength !== undefined ? { contentLength } : {}),
903
+ ...(accept !== undefined ? { accept } : {}),
904
+ ...(range !== undefined ? { range } : {}),
905
+ }, forwardSignal);
906
+ // Down from here the query half's response discipline verbatim: the
907
+ // relay refusal marker, the three cooperative headers, the pump
908
+ // with real backpressure — plus (PB-22) the rewriteIds branch.
909
+ const refusal = response.headers.get('x-zen-remote-error');
910
+ if (refusal !== null) {
911
+ await response.body?.cancel().catch(() => { });
912
+ answerUnbridged(res, plugin, refusal);
913
+ return;
914
+ }
915
+ if (declared.rewriteIds) {
916
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId, getKnownRealIds?.());
917
+ return;
918
+ }
919
+ const contentType = response.headers.get('content-type');
920
+ const responseLength = response.headers.get('content-length');
921
+ const cacheControl = response.headers.get('cache-control');
922
+ res.writeHead(response.status, {
923
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
924
+ ...(responseLength !== null ? { 'content-length': responseLength } : {}),
925
+ ...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
926
+ });
927
+ const reader = response.body?.getReader();
928
+ if (reader !== undefined)
929
+ await pumpResponse(res, reader);
930
+ res.end();
931
+ }
932
+ catch (error) {
933
+ // The gate's local flag first (the query half's shape): the 413
934
+ // still fits while the browser's headers are not out.
935
+ if (bodyGateFailed && !res.headersSent) {
936
+ res.setHeader('content-type', 'application/json; charset=utf-8');
937
+ res.writeHead(413);
938
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }));
939
+ recordRefusal(plugin, 'body-too-large');
940
+ return;
941
+ }
942
+ if (controller?.signal.aborted || res.destroyed || res.headersSent) {
943
+ try {
944
+ res.destroy();
945
+ }
946
+ catch {
947
+ // Already gone.
948
+ }
949
+ return;
950
+ }
951
+ const code = typeof error?.code === 'string' ? error.code : 'offline';
952
+ answerUnbridged(res, plugin, code === 'aborted' ? 'offline' : code);
953
+ }
954
+ finally {
955
+ res.off('close', onClientGone);
956
+ }
957
+ };
389
958
  // ---- PB-11: the adapter interception ---------------------------------------
390
959
  //
391
960
  // An adapter (src/adapters/, validated into a NormalizedAdapter by the
@@ -662,24 +1231,8 @@ export function installPluginRouteIntercept(options) {
662
1231
  ...Object.assign({}, ...allowlistedHeaders),
663
1232
  });
664
1233
  const reader = response.body?.getReader();
665
- if (reader !== undefined) {
666
- while (true) {
667
- const { done, value } = await reader.read();
668
- if (done)
669
- break;
670
- if (!res.write(value)) {
671
- await new Promise((resolve) => {
672
- const settled = () => {
673
- res.off('drain', settled);
674
- res.off('close', settled);
675
- resolve();
676
- };
677
- res.once('drain', settled);
678
- res.once('close', settled);
679
- });
680
- }
681
- }
682
- }
1234
+ if (reader !== undefined)
1235
+ await pumpResponse(res, reader);
683
1236
  res.end();
684
1237
  return;
685
1238
  }
@@ -724,22 +1277,7 @@ export function installPluginRouteIntercept(options) {
724
1277
  });
725
1278
  }
726
1279
  }
727
- while (true) {
728
- const { done, value } = await reader.read();
729
- if (done)
730
- break;
731
- if (!res.write(value)) {
732
- await new Promise((resolve) => {
733
- const settled = () => {
734
- res.off('drain', settled);
735
- res.off('close', settled);
736
- resolve();
737
- };
738
- res.once('drain', settled);
739
- res.once('close', settled);
740
- });
741
- }
742
- }
1280
+ await pumpResponse(res, reader);
743
1281
  res.end();
744
1282
  return;
745
1283
  }
@@ -796,9 +1334,10 @@ export function installPluginRouteIntercept(options) {
796
1334
  /** The wrapped match: original verdict first, then the adapter routes
797
1335
  * (PB-11 — BEFORE the declaration lookup: the adapter's registry entry
798
1336
  * IS a declaration, and the cooperative surrogate's query-sessionParam
799
- * logic is not how an adapter request is judged), then the exact
800
- * declaration lookup — an undeclared pathname returns whatever the
801
- * original match answered, untouched. */
1337
+ * logic is not how an adapter request is judged), then the declaration
1338
+ * lookup — exact paths plus (PB-21) a declared prefix route's full
1339
+ * match. An undeclared pathname returns whatever the original match
1340
+ * answered, untouched. */
802
1341
  const wrappedMatch = function match(pathname) {
803
1342
  const route = savedMatch.call(this, pathname);
804
1343
  if (!installed)
@@ -848,6 +1387,7 @@ export function installPluginRouteIntercept(options) {
848
1387
  digest: declaration.digest,
849
1388
  path: route.path,
850
1389
  sessionParam: route.sessionParam,
1390
+ deny: routeDeniesRemoteOf(route),
851
1391
  };
852
1392
  }
853
1393
  }
@@ -890,6 +1430,18 @@ export function installPluginRouteIntercept(options) {
890
1430
  return;
891
1431
  }
892
1432
  const value = values.length === 1 ? values[0] : undefined;
1433
+ // PB-22: the deny wall on the tunnel — the HTTP surrogate's rule
1434
+ // verbatim. A remote context (the request's own virtual id, or the
1435
+ // browser's reported current session) never opens a byte tunnel into
1436
+ // a route declared `deny: true`; a local session's upgrade passes
1437
+ // through untouched.
1438
+ if (declared.deny) {
1439
+ const current = getCurrentSession?.();
1440
+ if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
1441
+ answerUnbridgedSocket(socket, declared.plugin, 'route-denied');
1442
+ return;
1443
+ }
1444
+ }
893
1445
  // A LOCAL session (or no parameter at all): the original upgrade
894
1446
  // handler's business, the head bytes untouched.
895
1447
  if (value === undefined || value === '' || !value.startsWith('zr~')) {