dsh-zen-remote 2.3.4 → 2.3.6

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 (53) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/interface.md +2 -2
  4. package/docs/plugin-bridge.md +2 -2
  5. package/docs/remote-access.en.md +3 -3
  6. package/docs/remote-access.md +3 -3
  7. package/lib/adapters/dsh-better-sidebar/index.js +31 -2
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/client.js +185 -48
  10. package/lib/index.js +13 -0
  11. package/lib/index.js.map +1 -1
  12. package/lib/intercept.js +43 -1
  13. package/lib/intercept.js.map +1 -1
  14. package/lib/plugin-adapter-core.js +30 -1
  15. package/lib/plugin-adapter-core.js.map +1 -1
  16. package/lib/plugin-route-intercept.js +506 -11
  17. package/lib/plugin-route-intercept.js.map +1 -1
  18. package/lib/plugin-tunnel.js +210 -18
  19. package/lib/plugin-tunnel.js.map +1 -1
  20. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
  21. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  22. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  23. package/lib/types/client/effects/turn-fold.d.ts +42 -0
  24. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  25. package/lib/types/client/locales.d.ts +2 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/styles/content.css.d.ts +1 -1
  28. package/lib/types/client/styles/content.css.d.ts.map +1 -1
  29. package/lib/types/client/styles/layout.css.d.ts +1 -1
  30. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  31. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/intercept.d.ts +6 -0
  34. package/lib/types/intercept.d.ts.map +1 -1
  35. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  36. package/lib/types/plugin-route-intercept.d.ts +8 -0
  37. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  38. package/lib/types/plugin-tunnel.d.ts +37 -2
  39. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  40. package/package.json +37 -37
  41. package/scripts/check-info-sheet-stacking.mjs +85 -0
  42. package/src/adapters/dsh-better-sidebar/index.ts +31 -2
  43. package/src/client/MobileSessionInfo.tsx +29 -2
  44. package/src/client/effects/turn-fold.ts +97 -26
  45. package/src/client/locales.ts +4 -0
  46. package/src/client/styles/content.css.ts +44 -14
  47. package/src/client/styles/layout.css.ts +9 -3
  48. package/src/client/styles/turn-fold.css.ts +9 -0
  49. package/src/index.ts +13 -0
  50. package/src/intercept.ts +46 -0
  51. package/src/plugin-adapter-core.ts +29 -2
  52. package/src/plugin-route-intercept.ts +516 -12
  53. package/src/plugin-tunnel.ts +230 -20
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { connect as netConnect } from 'node:net'
27
27
  import { connect as tlsConnect } from 'node:tls'
28
+ import { randomBytes } from 'node:crypto'
28
29
  import type { IncomingMessage, ServerResponse } from 'node:http'
29
30
  import type { Socket } from 'node:net'
30
31
  import { pluginBridgeError, responseJson } from './http.js'
@@ -47,8 +48,11 @@ export type PluginCallValidation =
47
48
  /** The json-body locator's body reader (PB-24): the caller owns the request
48
49
  * stream — `v1/plugin` buffers it bounded by the route's `maxBody` (413 past
49
50
  * the cap, mirroring the adapter half's discipline), the tunnel open omits
50
- * the reader entirely (an open request is empty-bodied by contract, so a
51
- * body-locating upgrade route can never be validated and refuses). */
51
+ * the reader entirely (an open request is empty-bodied by contract). Since
52
+ * PB-26b that open-phase gap is fine for an `upgrade: true` route — its ids
53
+ * ride the LATER WebSocket text frames and the send gate share-checks each
54
+ * one — while a NON-upgrade json-body route still refuses at open: nothing
55
+ * would ever own its ids. */
52
56
  export type PluginCallBodyReader = (maxBody: number) => Promise<
53
57
  | { kind: 'body'; buffered: Buffer; parsed?: unknown }
54
58
  | { kind: 'over' }
@@ -126,6 +130,15 @@ export async function validatePluginCall(
126
130
  if (queryId === undefined || queryId === '') return { ok: false, status: 400, code: 'bad-query' }
127
131
  if (!isAccessible(queryId)) return { ok: false, status: 403, code: 'not-shared' }
128
132
  sessionId = queryId
133
+ } else if (locator.in === 'json-body' && readJsonBody === undefined && routeUpgradesOf(route)) {
134
+ // PB-26b: a json-body UPGRADE route's handshake is an empty-bodied GET —
135
+ // the ids ride the JSON text frames the browser sends AFTER the
136
+ // handshake, and the tunnel's send gate share-checks every one of those
137
+ // frames ({@link frameShareVerdict}) before a byte reaches the plugin.
138
+ // The open phase locates nothing and validates no body; the consent
139
+ // wall below still applies, and the tunnel carries the route so the
140
+ // gate can judge the frames.
141
+ sessionId = ''
129
142
  } else {
130
143
  let parsedBody: unknown
131
144
  if (locator.in === 'json-body') {
@@ -169,6 +182,136 @@ export const TUNNEL_UP_CHUNK_BYTES = 64 * 1024
169
182
  /** Concurrent tunnels per device. */
170
183
  export const MAX_TUNNELS_PER_DEVICE = 8
171
184
 
185
+ // ---- PB-26b: the RFC 6455 frame gate for json-body upgrade routes -----------
186
+ //
187
+ // A `locator: { in: 'json-body' }` upgrade route carries its session ids in
188
+ // the JSON text frames the browser sends AFTER the handshake (the sidechat
189
+ // ws watch: `{"op":"watch","threadIds":[...]}`) — the handshake itself is an
190
+ // empty-bodied GET, so the open-phase share wall has nothing to judge. The
191
+ // gate lives on the SEND path instead: the sub-client's upstream chunks are
192
+ // split into frames, and every text frame is located + share-checked before
193
+ // a byte of it reaches the plugin. Query-locator upgrade routes never parse
194
+ // a frame — their bytes stay a 100% opaque stream (docs/adr/0002).
195
+
196
+ /** One RFC 6455 frame split off a byte stream. */
197
+ export interface Rfc6455Frame {
198
+ opcode: number
199
+ fin: boolean
200
+ /** The payload with the client mask removed (both directions parse; the
201
+ * mask belongs to client frames only and is undone here either way). */
202
+ payload: Buffer
203
+ /** The frame's complete wire bytes, mask included — the verbatim form a
204
+ * validated frame is forwarded in. */
205
+ raw: Buffer
206
+ }
207
+
208
+ /** The frame-size cap the gate parses (per frame, declared length): a
209
+ * browser watch frame is a few KiB at most, and an unbounded declared
210
+ * length would let a broken sender grow the parse buffer forever. */
211
+ export const MAX_GATE_FRAME_BYTES = 1024 * 1024
212
+
213
+ /** Split the COMPLETE frames at the front of `buffer` — client frames
214
+ * (masked) and server frames (unmasked) alike. A partial frame at the tail
215
+ * stays unconsumed for the next chunk; `violated` reports a frame whose
216
+ * DECLARED length exceeds {@link MAX_GATE_FRAME_BYTES} (the caller refuses,
217
+ * never waits for bytes that must not arrive). */
218
+ export function parseRfc6455Frames(buffer: Buffer): { frames: Rfc6455Frame[]; consumed: number; violated: boolean } {
219
+ const frames: Rfc6455Frame[] = []
220
+ let offset = 0
221
+ while (true) {
222
+ const remain = buffer.length - offset
223
+ if (remain < 2) break
224
+ const fin = (buffer[offset] & 0x80) !== 0
225
+ const opcode = buffer[offset] & 0x0f
226
+ const masked = (buffer[offset + 1] & 0x80) !== 0
227
+ let length = buffer[offset + 1] & 0x7f
228
+ let headerLength = 2
229
+ if (length === 126) {
230
+ if (remain < 4) break
231
+ length = buffer.readUInt16BE(offset + 2)
232
+ headerLength = 4
233
+ } else if (length === 127) {
234
+ if (remain < 10) break
235
+ const declared = buffer.readBigUInt64BE(offset + 2)
236
+ if (declared > BigInt(MAX_GATE_FRAME_BYTES)) return { frames, consumed: offset, violated: true }
237
+ length = Number(declared)
238
+ headerLength = 10
239
+ }
240
+ if (length > MAX_GATE_FRAME_BYTES) return { frames, consumed: offset, violated: true }
241
+ const maskLength = masked ? 4 : 0
242
+ if (remain < headerLength + maskLength + length) break
243
+ const maskAt = offset + headerLength
244
+ const payloadAt = maskAt + maskLength
245
+ const end = payloadAt + length
246
+ const payload = Buffer.from(buffer.subarray(payloadAt, end))
247
+ if (masked) {
248
+ const mask = buffer.subarray(maskAt, payloadAt)
249
+ for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4]
250
+ }
251
+ frames.push({ opcode, fin, payload, raw: Buffer.from(buffer.subarray(offset, end)) })
252
+ offset = end
253
+ }
254
+ return { frames, consumed: offset, violated: false }
255
+ }
256
+
257
+ /** Encode one RFC 6455 frame. `mask: true` produces the CLIENT side's
258
+ * masked form (4 fresh random bytes, payload XORed) — the bytes the sub-
259
+ * client forwards must look like the browser's own. `false` is the server
260
+ * side's plain form (frames toward the browser). `fin` (default true) is
261
+ * the frame's FIN bit — a fragmented frame keeps its fragmentation shape
262
+ * when the gate re-frames it. */
263
+ export function encodeRfc6455Frame(opcode: number, payload: Buffer, mask: boolean, fin = true): Buffer {
264
+ const firstByte = (fin ? 0x80 : 0x00) | (opcode & 0x0f)
265
+ const maskBit = mask ? 0x80 : 0
266
+ const length = payload.length
267
+ let head: Buffer
268
+ if (length < 126) {
269
+ head = Buffer.from([firstByte, maskBit | length])
270
+ } else if (length < 65536) {
271
+ head = Buffer.alloc(4)
272
+ head[0] = firstByte
273
+ head[1] = maskBit | 126
274
+ head.writeUInt16BE(length, 2)
275
+ } else {
276
+ head = Buffer.alloc(10)
277
+ head[0] = firstByte
278
+ head[1] = maskBit | 127
279
+ head.writeBigUInt64BE(BigInt(length), 2)
280
+ }
281
+ if (!mask) return Buffer.concat([head, payload])
282
+ const maskKey = randomBytes(4)
283
+ const masked = Buffer.from(payload)
284
+ for (let i = 0; i < masked.length; i += 1) masked[i] ^= maskKey[i % 4]
285
+ return Buffer.concat([head, maskKey, masked])
286
+ }
287
+
288
+ /** The share-wall verdict of ONE client text frame (PB-26b): the frame's
289
+ * JSON is located against the route's OWN locator and every hit is share-
290
+ * checked — the same adapter-core rule the open phase runs for query and
291
+ * body routes. Non-text frames (close/ping/pong/binary) carry no session
292
+ * ids and pass. A frame that locates NOTHING (an empty `threadIds` array —
293
+ * the unsubscribe-all — or a non-object JSON) is admitted: there is no id
294
+ * to check. */
295
+ function frameShareVerdict(route: NormalizedBridgeRoute, frame: Rfc6455Frame, isAccessible: (sessionId: string) => boolean): { ok: true } | { ok: false; status: number; code: string } {
296
+ if (frame.opcode !== 0x1) return { ok: true }
297
+ let parsed: unknown
298
+ try {
299
+ parsed = JSON.parse(frame.payload.toString('utf8'))
300
+ } catch {
301
+ return { ok: false, status: 400, code: 'bad-request' }
302
+ }
303
+ let hits
304
+ try {
305
+ hits = locateAdapterIds(asAdapterRoute(route), route.path, new URLSearchParams(), parsed)
306
+ } catch {
307
+ return { ok: false, status: 400, code: 'bad-query' }
308
+ }
309
+ for (const hit of hits) {
310
+ if (!isAccessible(hit.value)) return { ok: false, status: 403, code: 'not-shared' }
311
+ }
312
+ return { ok: true }
313
+ }
314
+
172
315
  /** The four WebSocket headers the open body may carry, verbatim. */
173
316
  const TUNNEL_CLIENT_HEADERS = ['sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions'] as const
174
317
 
@@ -192,6 +335,13 @@ interface Tunnel {
192
335
  /** The next upstream sequence the client must send. */
193
336
  expectedSeq: number
194
337
  heartbeat: ReturnType<typeof setInterval>
338
+ /** PB-26b: the validated route — a json-body upgrade tunnel's send gate
339
+ * reads its locator to judge every upstream text frame. */
340
+ route: NormalizedBridgeRoute
341
+ /** PB-26b: the upstream frame-parse buffer. PRESENT ONLY on a json-body
342
+ * upgrade tunnel (the frame gate is enabled); a query-locator upgrade
343
+ * stays `undefined` — its bytes pass through 100% opaque, zero parsing. */
344
+ upFrameBuffer: Buffer | undefined
195
345
  }
196
346
 
197
347
  export interface CreatePluginTunnelOptions {
@@ -260,6 +410,27 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
260
410
  return typeof value === 'string' ? value : undefined
261
411
  }
262
412
 
413
+ /** PB-26b: run the frame gate over the tunnel's accumulated upstream
414
+ * buffer — split the complete frames, share-check every TEXT one against
415
+ * the route's locator and return the verified wire bytes for the plugin.
416
+ * A refusal returns the verdict and the caller owns the channel (an HTTP
417
+ * status on `send`; an end line on the early head bytes): the refused
418
+ * frame and everything after it NEVER reach upstream. Frames that locate
419
+ * no id (the empty-array unsubscribe) are admitted verbatim. */
420
+ const admitUpstreamFrames = (tunnel: Tunnel, isAccessible: (sessionId: string) => boolean): { ok: true; bytes: Buffer } | { ok: false; status: number; code: string } => {
421
+ const buffer = tunnel.upFrameBuffer!
422
+ const parsed = parseRfc6455Frames(buffer)
423
+ if (parsed.violated) return { ok: false, status: 400, code: 'bad-request' }
424
+ const verified: Buffer[] = []
425
+ for (const frame of parsed.frames) {
426
+ const verdict = frameShareVerdict(tunnel.route, frame, isAccessible)
427
+ if (!verdict.ok) return verdict
428
+ verified.push(frame.raw)
429
+ }
430
+ tunnel.upFrameBuffer = buffer.subarray(parsed.consumed)
431
+ return { ok: true, bytes: Buffer.concat(verified) }
432
+ }
433
+
263
434
  return {
264
435
  tunnelCount() {
265
436
  return tunnels.size
@@ -336,9 +507,11 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
336
507
  pluginBridgeError(res, 400, 'bad-request')
337
508
  return
338
509
  }
339
- // No body reader here: an open request is empty-bodied by contract, so
340
- // a json-body-locator route can never be validated and answers
341
- // bad-request inside the shared chain.
510
+ // No body reader here: an open request is empty-bodied by contract. A
511
+ // NON-upgrade json-body route answers bad-request inside the shared
512
+ // chain (nothing would ever own its ids); an `upgrade: true` one is
513
+ // admitted with an empty session (PB-26b) — its ids arrive in the
514
+ // later WebSocket frames and the send gate owns them.
342
515
  const validation = await validatePluginCall(bridge, coords, options.isAccessible)
343
516
  if (!validation.ok) {
344
517
  pluginBridgeError(res, validation.status, validation.code)
@@ -381,11 +554,14 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
381
554
  return
382
555
  }
383
556
  const id = (await import('node:crypto')).randomBytes(16).toString('hex')
557
+ const gated = routeUpgradesOf(validation.route) && routeLocatorOf(validation.route).in === 'json-body'
384
558
  const tunnel: Tunnel = {
385
559
  device,
386
560
  upstream,
387
561
  res,
388
562
  expectedSeq: 0,
563
+ route: validation.route,
564
+ upFrameBuffer: gated ? Buffer.alloc(0) : undefined,
389
565
  heartbeat: setInterval(() => {
390
566
  writeLine(res, { type: 'ping' })
391
567
  }, options.heartbeatMs),
@@ -417,7 +593,22 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
417
593
  '\r\n',
418
594
  ]
419
595
  upstream.write(requestLines.join('\r\n'))
420
- if (head.length > 0) upstream.write(head)
596
+ // PB-26b: the gated tunnel admits even its EARLY head bytes through
597
+ // the same per-frame wall — the client half never sends head on a
598
+ // json-body route, so bytes here are a bypass attempt and the tunnel
599
+ // dies with an end line (the river is already open — no HTTP status
600
+ // fits anymore) instead of slipping ids past the share check.
601
+ if (tunnel.upFrameBuffer !== undefined && head.length > 0) {
602
+ tunnel.upFrameBuffer = Buffer.concat([tunnel.upFrameBuffer, head])
603
+ const admitted = admitUpstreamFrames(tunnel, options.isAccessible)
604
+ if (!admitted.ok) {
605
+ teardown(tunnel, id, 'client-closed')
606
+ return
607
+ }
608
+ if (admitted.bytes.length > 0) upstream.write(admitted.bytes)
609
+ } else if (head.length > 0) {
610
+ upstream.write(head)
611
+ }
421
612
 
422
613
  // Upstream bytes → downstream data lines, at most 48 KiB PER LINE, with
423
614
  // the slow-reader discipline: a full socket pauses the UPSTREAM read, so
@@ -508,22 +699,41 @@ export function createPluginTunnel(options: CreatePluginTunnelOptions): PluginTu
508
699
  }
509
700
  const bytes = Buffer.concat(chunks)
510
701
  tunnel.expectedSeq = seq + 1
702
+ // PB-26b: a json-body upgrade tunnel parses its upstream bytes into
703
+ // RFC 6455 frames FIRST — every text frame is located and share-
704
+ // checked before a byte of it reaches the plugin; a refused frame
705
+ // answers the send's own HTTP status and tears the tunnel down. An
706
+ // opaque (query-locator) tunnel skips the gate entirely.
707
+ let outbound: Buffer = bytes
708
+ if (tunnel.upFrameBuffer !== undefined) {
709
+ tunnel.upFrameBuffer = Buffer.concat([tunnel.upFrameBuffer, bytes])
710
+ const admitted = admitUpstreamFrames(tunnel, options.isAccessible)
711
+ if (!admitted.ok) {
712
+ pluginBridgeError(res, admitted.status, admitted.code)
713
+ teardown(tunnel, tunnelId, 'client-closed')
714
+ return
715
+ }
716
+ outbound = admitted.bytes
717
+ }
511
718
  // Backpressure: the answer waits for the upstream socket to take the
512
719
  // bytes, so a stalled plugin makes THIS send slow instead of growing
513
- // buffers here.
514
- const flushed = tunnel.upstream.write(bytes)
515
- if (!flushed) {
516
- await new Promise<void>((resolve) => {
517
- const settled = (): void => {
518
- tunnel.upstream.off('drain', settled)
519
- tunnel.upstream.off('close', settled)
520
- tunnel.upstream.off('error', settled)
521
- resolve()
522
- }
523
- tunnel.upstream.once('drain', settled)
524
- tunnel.upstream.once('close', settled)
525
- tunnel.upstream.once('error', settled)
526
- })
720
+ // buffers here. A gated chunk may hold no complete frame yet — nothing
721
+ // to write, the bytes stay in the parse buffer, the send answers ok.
722
+ if (outbound.length > 0) {
723
+ const flushed = tunnel.upstream.write(outbound)
724
+ if (!flushed) {
725
+ await new Promise<void>((resolve) => {
726
+ const settled = (): void => {
727
+ tunnel.upstream.off('drain', settled)
728
+ tunnel.upstream.off('close', settled)
729
+ tunnel.upstream.off('error', settled)
730
+ resolve()
731
+ }
732
+ tunnel.upstream.once('drain', settled)
733
+ tunnel.upstream.once('close', settled)
734
+ tunnel.upstream.once('error', settled)
735
+ })
736
+ }
527
737
  }
528
738
  if (tunnels.get(tunnelId) !== tunnel) {
529
739
  // The tunnel died while the bytes were draining.