dsh-zen-remote 2.3.11 → 2.4.1

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 (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -30,11 +30,11 @@ import { withPeer } from './owner-peer.js'
30
30
  import { controlProjectionFrame } from './merge-streams.js'
31
31
  import { randomBytes, timingSafeEqual } from 'node:crypto'
32
32
  import { Readable, Transform } from 'node:stream'
33
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
34
33
  import { createRequire } from 'node:module'
35
34
  import { join } from 'node:path'
36
35
  import type { IncomingMessage, ServerResponse } from 'node:http'
37
36
  import type { ShareStore } from './share-store.js'
37
+ import { ensureServerIdMigrated, readStateFile, STATE_FILE_NAME, writeStateFile } from './state-store.js'
38
38
  import { decideHttpRoute, decideInvoke, decideStream, decideUploadQuery, parseEventResultBody } from './relay-access.js'
39
39
  import {
40
40
  createWorkspaceFollowState,
@@ -79,6 +79,26 @@ export { MAX_UPLOAD_BYTES }
79
79
  * host's own registration (RT dsh-client-file-upload lib/index.js:73). */
80
80
  export const UPLOAD_HTTP_PATH = '/api/session/uploadFileBinary'
81
81
 
82
+ /**
83
+ * The duplex uplink's buffering ceiling (TA-1): how many bytes of
84
+ * not-yet-consumed uplink frames one live stream may hold — the same budget
85
+ * the host's own stream inbox applies (`DEFAULT_STREAM_INBOX_BYTES`), so a
86
+ * stalled terminal consumer backs up at exactly the point the local session
87
+ * would have. Past it the `/v1/stream-uplink` route answers 413
88
+ * `uplink-overflow` and the stream is aborted.
89
+ */
90
+ export const MAX_STREAM_UPLINK_BYTES = 262_144
91
+
92
+ /** The per-stream uplink window the client may advertise (TA-1): how many
93
+ * bytes the host's uplink inbox buffers before it stops draining, and the
94
+ * per-frame framing overhead it charges against that budget. The relay
95
+ * forwards it verbatim — only the shape is validated, the numbers are the
96
+ * host's own contract with itself. */
97
+ export interface RelayUplinkWindow {
98
+ maxBytes: number
99
+ frameOverheadBytes: number
100
+ }
101
+
82
102
  /** Error messages forwarded to the client are clipped to this many chars. */
83
103
  const MAX_MESSAGE_CHARS = 500
84
104
 
@@ -187,6 +207,12 @@ export interface RelayGateway {
187
207
  args: unknown
188
208
  signal?: AbortSignal
189
209
  peer?: unknown
210
+ /** TA-1: the duplex uplink queue (a duplex method like `terminal/attach`
211
+ * reads keystrokes/resize frames from it). Absent on one-direction
212
+ * streams — the field's presence IS the duplex flag. */
213
+ uplink?: AsyncIterable<unknown>
214
+ /** TA-1: the client's advertised uplink window, forwarded verbatim. */
215
+ uplinkWindow?: RelayUplinkWindow
190
216
  }): Promise<AsyncIterable<unknown>>
191
217
  /** The host's wire adapter, shared by the WebSocket mux and the local
192
218
  * transports. `open` is the async 0.2.0 method. */
@@ -316,30 +342,38 @@ export interface RelayHandler {
316
342
  * countable about the deployment. */
317
343
  const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/
318
344
 
319
- const SERVER_ID_FILE = 'zen-remote-server.json'
345
+ /**
346
+ * The state file the server identity lives in since MS-1 — the same file the
347
+ * share table (`sessions`) and the paired-servers list (`servers`) use, so
348
+ * every writer of one field must preserve the other two. `loadServerId`
349
+ * reads and writes it only through state-store's merged doors.
350
+ */
351
+ const SERVER_ID_FILE = STATE_FILE_NAME
320
352
 
321
353
  /**
322
354
  * Read the persisted server id, creating (and persisting) one on first use.
323
- * A missing file is the normal first run; an unreadable or damaged file must
324
- * never take the host down, so every failure degrades to a fresh per-process
325
- * id that simply is not remembered across restarts.
355
+ * A pre-multi-server `zen-remote-server.json` identity migrates in first
356
+ * (and its file is deleted) — an upgraded host keeps its id, so paired
357
+ * devices never see a stranger. A missing file is the normal first run; an
358
+ * unreadable or damaged file must never take the host down, so every failure
359
+ * degrades to a fresh per-process id that simply is not remembered across
360
+ * restarts.
326
361
  */
327
362
  export function loadServerId(home: string): string {
328
363
  const file = join(home, SERVER_ID_FILE)
364
+ ensureServerIdMigrated(file)
329
365
  try {
330
- const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
331
- const id = (parsed as { serverId?: unknown } | null)?.serverId
332
- if (typeof id === 'string' && SERVER_ID_PATTERN.test(id)) return id
366
+ const state = readStateFile(file)
367
+ if (state.serverId !== undefined && SERVER_ID_PATTERN.test(state.serverId)) return state.serverId
333
368
  } catch {
334
- // First run, unreadable path, or unparsable JSON — mint a new one below.
369
+ // Damaged state: mint a new one below (state-store never throws, but the
370
+ // belt stays on).
335
371
  }
336
372
  const id = randomBytes(4).toString('hex')
337
373
  try {
338
- // tmp + rename, the same atomic dance the share table uses: a crash
339
- // mid-write must not leave a half-file that would corrupt the next read.
340
- mkdirSync(home, { recursive: true })
341
- writeFileSync(`${file}.tmp`, JSON.stringify({ serverId: id }))
342
- renameSync(`${file}.tmp`, file)
374
+ // The merged write: sessions/servers already in the file survive, and the
375
+ // atomic tmp(0600)+rename dance is state-store's job now.
376
+ writeStateFile(file, (current) => ({ ...current, version: 1, serverId: id }))
343
377
  } catch {
344
378
  // Unwritable home: the id stays process-local. Nothing may throw here.
345
379
  }
@@ -782,6 +816,114 @@ function errorOf(error: unknown): { code: string; message?: string; details?: Re
782
816
  return out
783
817
  }
784
818
 
819
+ /**
820
+ * The bounded async queue behind one live duplex stream's uplink half (TA-1).
821
+ * The `/v1/stream-uplink` route pushes the client's frames in; the gateway's
822
+ * stream implementation (the host's own uplink consumer) pulls them out.
823
+ *
824
+ * Three states beyond "open", each with its own door:
825
+ * - `ended`: the client half-closed (`end: true`). Queued items still drain;
826
+ * the iteration finishes cleanly once the buffer is empty. A push AFTER the
827
+ * end is a client bug — it records `failure` and aborts the stream.
828
+ * - `failure`: a post-end push or a buffer overflow. The recorded error is
829
+ * thrown to the consumer, and the stream's abort fires (the route cannot
830
+ * push anymore, so the downstream is torn down with it).
831
+ * - `closed`: the stream is over (its own finally, or closeAll). Everything
832
+ * is dropped; further pushes are silently swallowed (`'ok'`) — the route's
833
+ * answer for a dead stream is the 404 unknown-stream, so the swallow only
834
+ * covers the race between the two.
835
+ *
836
+ * The waiter is woken on EVERY state change (item, end, close, failure) so a
837
+ * parked `next()` never sits on a stale promise: the iteration is the host's
838
+ * consumer, and a missed wake would hang a terminal that just died.
839
+ */
840
+ class RelayUplinkQueue {
841
+ private readonly items: Array<{ value: unknown; bytes: number }> = []
842
+ private bufferedBytes = 0
843
+ private ended = false
844
+ private closed = false
845
+ private failure: Error | undefined
846
+ private waiter: (() => void) | undefined
847
+
848
+ constructor(
849
+ private readonly maxBytes: number,
850
+ private readonly onFail: () => void,
851
+ ) {}
852
+
853
+ /** One frame in. `frameBytes` is the frame's serialized size — the same
854
+ * accounting the host's inbox charges, so the ceiling here means the same
855
+ * thing it means locally. */
856
+ push(value: unknown, frameBytes: number): 'ok' | 'after-end' | 'overflow' {
857
+ if (this.closed || this.failure !== undefined) return 'ok'
858
+ if (this.ended) {
859
+ this.failure = new Error('uplink frame arrived after end')
860
+ this.wake()
861
+ this.onFail()
862
+ return 'after-end'
863
+ }
864
+ if (this.bufferedBytes + frameBytes > this.maxBytes) {
865
+ this.failure = new Error('uplink buffer overflow')
866
+ this.wake()
867
+ this.onFail()
868
+ return 'overflow'
869
+ }
870
+ this.items.push({ value, bytes: frameBytes })
871
+ this.bufferedBytes += frameBytes
872
+ this.wake()
873
+ return 'ok'
874
+ }
875
+
876
+ /** The client half-closed: queued items still drain, then the iteration
877
+ * ends. Idempotent. */
878
+ end(): void {
879
+ if (this.ended) return
880
+ this.ended = true
881
+ this.wake()
882
+ }
883
+
884
+ /** The whole stream is over: drop everything, finish every parked reader,
885
+ * swallow all future pushes. Idempotent. */
886
+ close(): void {
887
+ if (this.closed) return
888
+ this.closed = true
889
+ this.items.length = 0
890
+ this.bufferedBytes = 0
891
+ this.wake()
892
+ }
893
+
894
+ private wake(): void {
895
+ const wake = this.waiter
896
+ this.waiter = undefined
897
+ wake?.()
898
+ }
899
+
900
+ [Symbol.asyncIterator](): AsyncIterator<unknown> {
901
+ return {
902
+ next: () => this.next(),
903
+ return: () => {
904
+ this.close()
905
+ return Promise.resolve({ value: undefined, done: true })
906
+ },
907
+ }
908
+ }
909
+
910
+ private async next(): Promise<IteratorResult<unknown>> {
911
+ while (true) {
912
+ if (this.closed) return { value: undefined, done: true }
913
+ const entry = this.items.shift()
914
+ if (entry !== undefined) {
915
+ this.bufferedBytes -= entry.bytes
916
+ return { value: entry.value, done: false }
917
+ }
918
+ if (this.failure !== undefined) throw this.failure
919
+ if (this.ended) return { value: undefined, done: true }
920
+ await new Promise<void>((resolve) => {
921
+ this.waiter = resolve
922
+ })
923
+ }
924
+ }
925
+ }
926
+
785
927
  /**
786
928
  * One live `$zr/events` subscription's forwarding bookkeeping (T32). The
787
929
  * answer route (`relay/v1/event-result`) proves an `eventId` was forwarded
@@ -896,6 +1038,27 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
896
1038
  * subscription removes itself; closeAll empties the set. */
897
1039
  const liveEventSubscriptions = new Set<EventsSubscription>()
898
1040
 
1041
+ /**
1042
+ * The live duplex streams' uplink halves (TA-1), keyed by the `streamId`
1043
+ * the handshake line handed the client. The `/v1/stream-uplink` route
1044
+ * resolves every post through THIS table, which is the whole ownership
1045
+ * story: the id is a 16-byte random value only the opening device ever
1046
+ * learned, the device check binds it to that one pairing, the session
1047
+ * list is re-judged against the share table at post time, and the seq
1048
+ * counter keeps replays and reordered posts out. A stream's own finally
1049
+ * removes its entry; closeAll closes every queue first so no consumer
1050
+ * parks on a dead stream.
1051
+ */
1052
+ interface ActiveUplinkStream {
1053
+ streamId: string
1054
+ device: string
1055
+ sessionIds: readonly string[]
1056
+ nextSeq: number
1057
+ queue: RelayUplinkQueue
1058
+ abort: () => void
1059
+ }
1060
+ const liveUplinkStreams = new Map<string, ActiveUplinkStream>()
1061
+
899
1062
  /** Latest `job/list` rows seen per session — the recent-state half of the
900
1063
  * job ownership check (the other half is a one-shot list on a cache miss).
901
1064
  * An unshared session's rows are forgotten (T22b-fix): its jobs are none of
@@ -2287,6 +2450,69 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2287
2450
  return
2288
2451
  }
2289
2452
 
2453
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream-uplink`) {
2454
+ // The duplex uplink's post route (TA-1): one JSON body of frames for a
2455
+ // stream opened with `uplink: true`, resolved against the live-uplink
2456
+ // table. Four walls in order, each with its own status: the shape
2457
+ // (400), the stream id and its owning device (404 — unknown-stream,
2458
+ // deliberately indistinguishable between "no such stream" and "not
2459
+ // yours", so a foreign device learns nothing), the share table at post
2460
+ // time (403 — an unshared session's keystrokes must die with the
2461
+ // share), and the sequence counter (409 — replays and reordered posts
2462
+ // would corrupt the host's own input sequencing).
2463
+ const body = await readBodyOrRespond(req, res)
2464
+ if (body === undefined) return
2465
+ const streamId = body.streamId
2466
+ const seq = body.seq
2467
+ const items = body.items
2468
+ const end = body.end
2469
+ if (
2470
+ typeof streamId !== 'string' ||
2471
+ streamId === '' ||
2472
+ typeof seq !== 'number' ||
2473
+ !Number.isSafeInteger(seq) ||
2474
+ seq < 1 ||
2475
+ (items !== undefined && !Array.isArray(items)) ||
2476
+ (end !== undefined && typeof end !== 'boolean') ||
2477
+ !((Array.isArray(items) && items.length > 0) || end === true)
2478
+ ) {
2479
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
2480
+ return
2481
+ }
2482
+ const device = headerValue(req, 'x-zen-remote-device') ?? ''
2483
+ const entry = liveUplinkStreams.get(streamId)
2484
+ if (entry === undefined || entry.device !== device) {
2485
+ responseJson(res, 404, { ok: false, error: { code: 'unknown-stream' } })
2486
+ return
2487
+ }
2488
+ if (!entry.sessionIds.every((id) => isAccessible(id))) {
2489
+ responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
2490
+ return
2491
+ }
2492
+ if (seq !== entry.nextSeq) {
2493
+ responseJson(res, 409, { ok: false, error: { code: 'out-of-order' } })
2494
+ return
2495
+ }
2496
+ entry.nextSeq += 1
2497
+ if (Array.isArray(items)) {
2498
+ for (const item of items) {
2499
+ const itemBytes = Buffer.byteLength(JSON.stringify(item) ?? '', 'utf8')
2500
+ const status = entry.queue.push(item, itemBytes)
2501
+ if (status === 'after-end') {
2502
+ responseJson(res, 400, { ok: false, error: { code: 'uplink-after-end' } })
2503
+ return
2504
+ }
2505
+ if (status === 'overflow') {
2506
+ responseJson(res, 413, { ok: false, error: { code: 'uplink-overflow' } })
2507
+ return
2508
+ }
2509
+ }
2510
+ }
2511
+ if (end === true) entry.queue.end()
2512
+ responseJson(res, 200, { ok: true })
2513
+ return
2514
+ }
2515
+
2290
2516
  if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream`) {
2291
2517
  // Body validation identical to invoke: one JSON object naming the
2292
2518
  // method and carrying object args.
@@ -2314,6 +2540,25 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2314
2540
  responseJson(res, 403, { ok: false, error: { code: decision.reason } })
2315
2541
  return
2316
2542
  }
2543
+ // TA-1: the client's advertised uplink window, shape-checked ONCE here —
2544
+ // only a well-formed window travels to the gateway; garbage (a zero or
2545
+ // negative budget, a fractional value) is dropped, never refused: the
2546
+ // numbers are the host's own contract with itself, and a missing one
2547
+ // just means the host default applies.
2548
+ let uplinkWindow: RelayUplinkWindow | undefined
2549
+ if (isPlainObject(body.uplinkWindow)) {
2550
+ const candidate = body.uplinkWindow
2551
+ if (
2552
+ typeof candidate.maxBytes === 'number' &&
2553
+ Number.isSafeInteger(candidate.maxBytes) &&
2554
+ candidate.maxBytes > 0 &&
2555
+ typeof candidate.frameOverheadBytes === 'number' &&
2556
+ Number.isSafeInteger(candidate.frameOverheadBytes) &&
2557
+ candidate.frameOverheadBytes >= 0
2558
+ ) {
2559
+ uplinkWindow = { maxBytes: candidate.maxBytes, frameOverheadBytes: candidate.frameOverheadBytes }
2560
+ }
2561
+ }
2317
2562
  const device = headerValue(req, 'x-zen-remote-device') ?? ''
2318
2563
  if ((streamsByDevice.get(device) ?? 0) >= MAX_STREAMS_PER_DEVICE) {
2319
2564
  responseJson(res, 429, { ok: false, error: { code: 'too-many-streams' } })
@@ -2479,6 +2724,31 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2479
2724
  })
2480
2725
  }
2481
2726
 
2727
+ // ---- TA-1: the duplex uplink -------------------------------------
2728
+ // A duplex decision the client took up: build the bounded queue the
2729
+ // gateway will read keystrokes from, register it for the
2730
+ // /v1/stream-uplink route, and send the handshake line FIRST —
2731
+ // before the upstream is even open — so the client may start posting
2732
+ // the moment it sees the id. The write chain serializes the line
2733
+ // ahead of any frame; registration precedes the open so a post that
2734
+ // races the handshake still finds the entry (its seq order and the
2735
+ // share table keep it honest either way).
2736
+ let uplinkStreamId: string | undefined
2737
+ let uplinkQueue: RelayUplinkQueue | undefined
2738
+ if (decision.uplink === true && body.uplink === true) {
2739
+ uplinkStreamId = randomBytes(16).toString('hex')
2740
+ uplinkQueue = new RelayUplinkQueue(MAX_STREAM_UPLINK_BYTES, () => hangUp.abort())
2741
+ liveUplinkStreams.set(uplinkStreamId, {
2742
+ streamId: uplinkStreamId,
2743
+ device,
2744
+ sessionIds: decision.sessionIds,
2745
+ nextSeq: 1,
2746
+ queue: uplinkQueue,
2747
+ abort: () => hangUp.abort(),
2748
+ })
2749
+ void writeLine({ type: 'uplink', streamId: uplinkStreamId })
2750
+ }
2751
+
2482
2752
  // ---- share-change synchronization (T22b §4) --------------------
2483
2753
 
2484
2754
  // The `$zr/events` forwarding bookkeeping (T32): registered for the
@@ -2638,11 +2908,25 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2638
2908
  } else {
2639
2909
  // `$zr/events` opens through the wire adapter (the ONLY door to
2640
2910
  // the forwarded-event stream); every other route is a typert
2641
- // stream method.
2911
+ // stream method. TA-1: a duplex stream rides with its uplink
2912
+ // queue (the field's PRESENCE is the duplex flag the gateway
2913
+ // reads) and the shape-checked window, both optional.
2642
2914
  const iterable =
2643
2915
  eventsSub !== undefined
2644
2916
  ? await openEventsStream(hangUp.signal)
2645
- : await gateway.stream(withPeer({ namespace, method, args, signal: hangUp.signal }, options.getPeer))
2917
+ : await gateway.stream(
2918
+ withPeer(
2919
+ {
2920
+ namespace,
2921
+ method,
2922
+ args,
2923
+ signal: hangUp.signal,
2924
+ ...(uplinkQueue !== undefined ? { uplink: uplinkQueue } : {}),
2925
+ ...(uplinkWindow !== undefined ? { uplinkWindow } : {}),
2926
+ },
2927
+ options.getPeer,
2928
+ ),
2929
+ )
2646
2930
  for await (const frame of iterable) {
2647
2931
  if (finished || clientGone) break
2648
2932
  if (namespace === 'job' && method === 'list') rememberJobs(ownerSessionIdOf(args), frame)
@@ -2667,6 +2951,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2667
2951
  clearInterval(heartbeat)
2668
2952
  unsubscribe()
2669
2953
  tableUnsubscribers.delete(unsubscribe)
2954
+ // TA-1: the uplink half dies with the stream — deregistered BEFORE
2955
+ // the queue closes, so a post racing the teardown answers
2956
+ // unknown-stream instead of feeding a dead queue, and the gateway's
2957
+ // consumer is finished instead of parked forever.
2958
+ if (uplinkStreamId !== undefined) liveUplinkStreams.delete(uplinkStreamId)
2959
+ uplinkQueue?.close()
2670
2960
  if (eventsSub !== undefined) {
2671
2961
  liveEventSubscriptions.delete(eventsSub)
2672
2962
  eventsSub.events.clear()
@@ -2710,6 +3000,12 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2710
3000
  sub.events.clear()
2711
3001
  }
2712
3002
  liveEventSubscriptions.clear()
3003
+ // TA-1: the duplex uplinks die with the handler — close() finishes every
3004
+ // parked gateway consumer (a terminal read must not hang on a reload)
3005
+ // and later posts answer unknown-stream; the streams' own finallys then
3006
+ // find their entries already gone and delete nothing.
3007
+ for (const entry of liveUplinkStreams.values()) entry.queue.close()
3008
+ liveUplinkStreams.clear()
2713
3009
  // Remember the reason for requests still inside their ownership probe —
2714
3010
  // they read it at the pre-open gate. Killing the open streams runs their
2715
3011
  // normal teardown (error line, res.end, abort), whose finally releases
@@ -3,10 +3,10 @@
3
3
  * effect (T17): `role` chooses which halves load; `port` / `host` /
4
4
  * `targetPort` / `rateLimit` / `trustedProxies` become the gateway child's
5
5
  * runtime shape; `vapidSubject` / `lang` / `pushEvents` / `pushDebounceMs` /
6
- * `pushSummary` / `pushTurnEnd` / `pushTool` are the push leg's startup
7
- * values. Everything else (serverName, idleHours, autoShareNewSessions,
8
- * serverUrl, deviceToken, and the interface-half knobs) is read live per use
9
- * and needs no reload.
6
+ * `pushSummary` / `pushTurnEnd` / `pushTool` / `pushEnabled` /
7
+ * `pushSessionName` are the push leg's startup values. Everything else
8
+ * (serverName, idleHours, autoShareNewSessions, serverUrl, deviceToken, and
9
+ * the interface-half knobs) is read live per use and needs no reload.
10
10
  *
11
11
  * Single source shared by both halves: the host's restart watcher
12
12
  * (src/restart-watcher.ts) fingerprints exactly these fields, and the
@@ -30,6 +30,8 @@ export const RESTART_FIELDS = [
30
30
  'pushSummary',
31
31
  'pushTurnEnd',
32
32
  'pushTool',
33
+ 'pushEnabled',
34
+ 'pushSessionName',
33
35
  ] as const
34
36
 
35
37
  /** One field of {@link RESTART_FIELDS}. */
package/src/share-ops.ts CHANGED
@@ -32,11 +32,92 @@ export interface AgentStatusLike {
32
32
 
33
33
  /** The slice of a DSH session header the share decision reads. Field names
34
34
  * per `validateSessionHeader`: `id` (matches the session id), `origin`
35
- * (only legal value `'subagent'`), `parentSession` (a string when set). */
35
+ * (only legal value `'subagent'`), `parentSession` (a string when set),
36
+ * `isSeeded` (true on a fork). */
36
37
  export interface CreatedSessionHeaderLike {
37
38
  id?: unknown
38
39
  origin?: unknown
39
40
  parentSession?: unknown
41
+ isSeeded?: unknown
42
+ }
43
+
44
+ /** The slice of a DSH Session read to detect an unused blank placeholder
45
+ * reclaimed by `session/create` (`dsh-api-session-controller`'s `claim`
46
+ * resumes persisted blank sessions with `source: 'resume'`). */
47
+ export interface CreatedSessionLike {
48
+ header?: CreatedSessionHeaderLike | null | undefined
49
+ snapshotEvents?: (() => readonly { type?: unknown }[]) | undefined
50
+ }
51
+
52
+ /** The slice of `ctx.agents.turnBoundaryOf(session)` (DSH 0.2+ projection
53
+ * reader, the non-deprecated replacement for `snapshotEvents()`). */
54
+ export interface TurnBoundaryLike {
55
+ openTurnStartSeq?: unknown
56
+ lastTurn?: unknown
57
+ }
58
+
59
+ /**
60
+ * Whether `session` is an unseeded, non-subagent blank session (zero turns
61
+ * ever started). DSH's `createInWorkspace` reuses persisted blank sessions
62
+ * via `session/create` → `agents.resume` (`source: 'resume'`); judging
63
+ * blankness lets `agent/created` auto-share a reclaimed blank placeholder
64
+ * while keeping established conversations closed across reopens.
65
+ *
66
+ * Prefers `turnBoundaryOf(session)` (DSH's `sessionProjections`-backed
67
+ * `ctx.agents.turnBoundaryOf`, the predicate `dsh-api-session-controller`'s
68
+ * own `isBlank` uses: `openTurnStartSeq === null && lastTurn === 0`); falls
69
+ * back to `session.snapshotEvents()` when `turnBoundaryOf` is absent (DSH
70
+ * 0.1.7 compatibility or unit-test mocks; ponytail: remove `snapshotEvents`
71
+ * fallback once 0.1.7 support is dropped and `@deepseek-ai/dsh-session`
72
+ * retires synchronous event-log reads).
73
+ */
74
+ export function isBlankSession(
75
+ session: CreatedSessionLike | undefined | null,
76
+ turnBoundaryOf?: ((session: CreatedSessionLike) => TurnBoundaryLike | undefined) | undefined,
77
+ ): boolean {
78
+ if (session === null || typeof session !== 'object') return false
79
+ const header = session.header
80
+ if (header === null || typeof header !== 'object') return false
81
+ if (header.origin === 'subagent' || header.isSeeded === true) return false
82
+ if (typeof header.id !== 'string' || header.id === '') return false
83
+ if (typeof turnBoundaryOf === 'function') {
84
+ try {
85
+ const boundary = turnBoundaryOf(session)
86
+ if (boundary !== null && typeof boundary === 'object') {
87
+ return boundary.openTurnStartSeq === null && boundary.lastTurn === 0
88
+ }
89
+ } catch {
90
+ // Fall through to snapshotEvents when the projection reader is unavailable.
91
+ }
92
+ }
93
+ if (typeof session.snapshotEvents !== 'function') return false
94
+ try {
95
+ const events = session.snapshotEvents()
96
+ if (!Array.isArray(events)) return false
97
+ return !events.some((event) => event !== null && typeof event === 'object' && event.type === 'turn/start')
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Whether `event` is the very first `turn/start` (`turn === 1`) of an
105
+ * unseeded non-subagent session. Covers the in-memory `reuseLive` path of
106
+ * `session/create`, where a live blank session (created before
107
+ * `autoShareNewSessions` was enabled, or after its idle window expired in
108
+ * memory) is reused without emitting `agent/created`.
109
+ */
110
+ export function isFirstTurnStart(
111
+ session: { header?: CreatedSessionHeaderLike | null | undefined } | undefined | null,
112
+ event: { type?: unknown; data?: unknown } | undefined | null,
113
+ ): boolean {
114
+ if (event === null || typeof event !== 'object' || event.type !== 'turn/start') return false
115
+ const data = event.data as { turn?: unknown } | null | undefined
116
+ if (data === null || typeof data !== 'object' || data.turn !== 1) return false
117
+ const header = session?.header
118
+ if (header === null || typeof header !== 'object') return false
119
+ if (header.origin === 'subagent' || header.isSeeded === true) return false
120
+ return typeof header.id === 'string' && header.id !== ''
40
121
  }
41
122
 
42
123
  /**
@@ -24,8 +24,10 @@
24
24
  * idle by definition until the caller says otherwise.
25
25
  */
26
26
 
27
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
28
- import { dirname } from 'node:path'
27
+ import { readFileSync, renameSync } from 'node:fs'
28
+ import { basename } from 'node:path'
29
+ import { isVirtual } from './virtual-id.js'
30
+ import { ensureSharesMigrated, readStateFile, STATE_FILE_NAME, writeStateFile } from './state-store.js'
29
31
 
30
32
  /** One row of the table, as `list()` reports it. */
31
33
  export interface ShareEntry {
@@ -154,37 +156,62 @@ export function createShareStore(options: ShareStoreOptions): ShareStore {
154
156
 
155
157
  // ---- startup load (synchronous, once) ----
156
158
  //
157
- // Missing file = first run, empty table. Unreadable path (a directory,
158
- // permissions) also starts empty but is NOT quarantined — renaming
159
- // something we could not even read could eat a live directory.
160
- let raw: string | null = null
161
- try {
162
- raw = readFileSync(file, 'utf8')
163
- } catch {
164
- raw = null
165
- }
166
- if (raw !== null) {
167
- let parsed: unknown
159
+ // Two container shapes. When this store backs the MS-1 three-in-one state
160
+ // file (`zen-remote-state.json`), loading goes through state-store's
161
+ // reader: the legacy `zen-remote-shares.json` migrates in first (and is
162
+ // deleted), and a version:1 container without a sessions map is legal —
163
+ // the file also carries serverId/servers other writers may have filled.
164
+ // Any OTHER file name keeps the historical shares-file semantics verbatim:
165
+ // anything that is not `{version:1, sessions:{…}}` quarantines the whole
166
+ // file. Unreadable paths (a directory, permissions) start empty either way
167
+ // and are NOT quarantined — renaming something we could not even read
168
+ // could eat a live directory.
169
+ const isStateFile = basename(file) === STATE_FILE_NAME
170
+ if (isStateFile) {
168
171
  try {
169
- parsed = JSON.parse(raw)
172
+ ensureSharesMigrated(file)
170
173
  } catch {
171
- parsed = undefined
174
+ // Startup must not fail on a migration hiccup; the legacy file stays.
172
175
  }
173
- // Container-level damage (unparseable JSON, wrong version, wrong shape)
174
- // quarantines the whole file; row-level damage (one bad record) only
175
- // skips that record — the rest of the table is worth keeping.
176
- if (isPlainObject(parsed) && parsed.version === FILE_VERSION && isPlainObject(parsed.sessions)) {
177
- for (const [id, value] of Object.entries(parsed.sessions)) {
176
+ const state = readStateFile(file)
177
+ if (state.sessions !== undefined) {
178
+ for (const [id, value] of Object.entries(state.sessions)) {
178
179
  if (!isValidId(id) || !isPlainObject(value)) continue
179
180
  const { sharedAt, lastActivityAt } = value
180
181
  if (!isFiniteNumber(sharedAt) || !isFiniteNumber(lastActivityAt)) continue
181
182
  sessions.set(id, { sharedAt, lastActivityAt, busy: false })
182
183
  }
183
- } else {
184
+ }
185
+ } else {
186
+ let raw: string | null = null
187
+ try {
188
+ raw = readFileSync(file, 'utf8')
189
+ } catch {
190
+ raw = null
191
+ }
192
+ if (raw !== null) {
193
+ let parsed: unknown
184
194
  try {
185
- renameSync(file, `${file}.corrupt-${now()}`)
195
+ parsed = JSON.parse(raw)
186
196
  } catch {
187
- // Keep the empty table either way; startup must not fail here.
197
+ parsed = undefined
198
+ }
199
+ // Container-level damage (unparseable JSON, wrong version, wrong shape)
200
+ // quarantines the whole file; row-level damage (one bad record) only
201
+ // skips that record — the rest of the table is worth keeping.
202
+ if (isPlainObject(parsed) && parsed.version === FILE_VERSION && isPlainObject(parsed.sessions)) {
203
+ for (const [id, value] of Object.entries(parsed.sessions)) {
204
+ if (!isValidId(id) || !isPlainObject(value)) continue
205
+ const { sharedAt, lastActivityAt } = value
206
+ if (!isFiniteNumber(sharedAt) || !isFiniteNumber(lastActivityAt)) continue
207
+ sessions.set(id, { sharedAt, lastActivityAt, busy: false })
208
+ }
209
+ } else {
210
+ try {
211
+ renameSync(file, `${file}.corrupt-${now()}`)
212
+ } catch {
213
+ // Keep the empty table either way; startup must not fail here.
214
+ }
188
215
  }
189
216
  }
190
217
  }
@@ -212,9 +239,10 @@ export function createShareStore(options: ShareStoreOptions): ShareStore {
212
239
  fileSessions[id] = { sharedAt: row.sharedAt, lastActivityAt: row.lastActivityAt }
213
240
  }
214
241
  try {
215
- mkdirSync(dirname(file), { recursive: true })
216
- writeFileSync(`${file}.tmp`, JSON.stringify({ version: FILE_VERSION, sessions: fileSessions }))
217
- renameSync(`${file}.tmp`, file)
242
+ // The merged write door: whatever ELSE the file carries (serverId and
243
+ // servers in the MS-1 state file, any extra top-level fields in a
244
+ // plain shares file) survives every table write.
245
+ writeStateFile(file, (current) => ({ ...current, version: FILE_VERSION, sessions: fileSessions }))
218
246
  lastWriteError = undefined
219
247
  } catch (error) {
220
248
  lastWriteError = error
@@ -237,6 +265,11 @@ export function createShareStore(options: ShareStoreOptions): ShareStore {
237
265
 
238
266
  const store: ShareStore = {
239
267
  share(sessionId) {
268
+ // A virtual id (`zr~<serverId>~…`) names a session that lives on a
269
+ // REMOTE server — this table is the local server's access control, and
270
+ // letting a virtual id in would make the local relay hand out access
271
+ // under an id it cannot resolve. Never enters the table.
272
+ if (isVirtual(sessionId)) return false
240
273
  if (!isValidId(sessionId)) return false
241
274
  const t = now()
242
275
  const existing = sessions.get(sessionId)