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
@@ -29,9 +29,9 @@ import { withPeer } from './owner-peer.js';
29
29
  import { controlProjectionFrame } from './merge-streams.js';
30
30
  import { randomBytes, timingSafeEqual } from 'node:crypto';
31
31
  import { Readable, Transform } from 'node:stream';
32
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
33
32
  import { createRequire } from 'node:module';
34
33
  import { join } from 'node:path';
34
+ import { ensureServerIdMigrated, readStateFile, STATE_FILE_NAME, writeStateFile } from './state-store.js';
35
35
  import { decideHttpRoute, decideInvoke, decideStream, decideUploadQuery, parseEventResultBody } from './relay-access.js';
36
36
  import { createWorkspaceFollowState, filterControlFrame, filterJobListFrame, filterModelCatalogResult, filterSessionListResult, filterWorkspaceFrame, } from './relay-filter.js';
37
37
  import { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri } from './session-reference.js';
@@ -60,6 +60,15 @@ export { MAX_UPLOAD_BYTES };
60
60
  /** The `/api` route the upload channel dispatches into, verbatim from the
61
61
  * host's own registration (RT dsh-client-file-upload lib/index.js:73). */
62
62
  export const UPLOAD_HTTP_PATH = '/api/session/uploadFileBinary';
63
+ /**
64
+ * The duplex uplink's buffering ceiling (TA-1): how many bytes of
65
+ * not-yet-consumed uplink frames one live stream may hold — the same budget
66
+ * the host's own stream inbox applies (`DEFAULT_STREAM_INBOX_BYTES`), so a
67
+ * stalled terminal consumer backs up at exactly the point the local session
68
+ * would have. Past it the `/v1/stream-uplink` route answers 413
69
+ * `uplink-overflow` and the stream is aborted.
70
+ */
71
+ export const MAX_STREAM_UPLINK_BYTES = 262_144;
63
72
  /** Error messages forwarded to the client are clipped to this many chars. */
64
73
  const MAX_MESSAGE_CHARS = 500;
65
74
  /**
@@ -133,31 +142,39 @@ const JOB_CACHE_LIMIT = 1024;
133
142
  * handful of servers apart in a client's server list without leaking anything
134
143
  * countable about the deployment. */
135
144
  const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/;
136
- const SERVER_ID_FILE = 'zen-remote-server.json';
145
+ /**
146
+ * The state file the server identity lives in since MS-1 — the same file the
147
+ * share table (`sessions`) and the paired-servers list (`servers`) use, so
148
+ * every writer of one field must preserve the other two. `loadServerId`
149
+ * reads and writes it only through state-store's merged doors.
150
+ */
151
+ const SERVER_ID_FILE = STATE_FILE_NAME;
137
152
  /**
138
153
  * Read the persisted server id, creating (and persisting) one on first use.
139
- * A missing file is the normal first run; an unreadable or damaged file must
140
- * never take the host down, so every failure degrades to a fresh per-process
141
- * id that simply is not remembered across restarts.
154
+ * A pre-multi-server `zen-remote-server.json` identity migrates in first
155
+ * (and its file is deleted) — an upgraded host keeps its id, so paired
156
+ * devices never see a stranger. A missing file is the normal first run; an
157
+ * unreadable or damaged file must never take the host down, so every failure
158
+ * degrades to a fresh per-process id that simply is not remembered across
159
+ * restarts.
142
160
  */
143
161
  export function loadServerId(home) {
144
162
  const file = join(home, SERVER_ID_FILE);
163
+ ensureServerIdMigrated(file);
145
164
  try {
146
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
147
- const id = parsed?.serverId;
148
- if (typeof id === 'string' && SERVER_ID_PATTERN.test(id))
149
- return id;
165
+ const state = readStateFile(file);
166
+ if (state.serverId !== undefined && SERVER_ID_PATTERN.test(state.serverId))
167
+ return state.serverId;
150
168
  }
151
169
  catch {
152
- // First run, unreadable path, or unparsable JSON — mint a new one below.
170
+ // Damaged state: mint a new one below (state-store never throws, but the
171
+ // belt stays on).
153
172
  }
154
173
  const id = randomBytes(4).toString('hex');
155
174
  try {
156
- // tmp + rename, the same atomic dance the share table uses: a crash
157
- // mid-write must not leave a half-file that would corrupt the next read.
158
- mkdirSync(home, { recursive: true });
159
- writeFileSync(`${file}.tmp`, JSON.stringify({ serverId: id }));
160
- renameSync(`${file}.tmp`, file);
175
+ // The merged write: sessions/servers already in the file survive, and the
176
+ // atomic tmp(0600)+rename dance is state-store's job now.
177
+ writeStateFile(file, (current) => ({ ...current, version: 1, serverId: id }));
161
178
  }
162
179
  catch {
163
180
  // Unwritable home: the id stays process-local. Nothing may throw here.
@@ -520,6 +537,114 @@ function errorOf(error) {
520
537
  out.details = details;
521
538
  return out;
522
539
  }
540
+ /**
541
+ * The bounded async queue behind one live duplex stream's uplink half (TA-1).
542
+ * The `/v1/stream-uplink` route pushes the client's frames in; the gateway's
543
+ * stream implementation (the host's own uplink consumer) pulls them out.
544
+ *
545
+ * Three states beyond "open", each with its own door:
546
+ * - `ended`: the client half-closed (`end: true`). Queued items still drain;
547
+ * the iteration finishes cleanly once the buffer is empty. A push AFTER the
548
+ * end is a client bug — it records `failure` and aborts the stream.
549
+ * - `failure`: a post-end push or a buffer overflow. The recorded error is
550
+ * thrown to the consumer, and the stream's abort fires (the route cannot
551
+ * push anymore, so the downstream is torn down with it).
552
+ * - `closed`: the stream is over (its own finally, or closeAll). Everything
553
+ * is dropped; further pushes are silently swallowed (`'ok'`) — the route's
554
+ * answer for a dead stream is the 404 unknown-stream, so the swallow only
555
+ * covers the race between the two.
556
+ *
557
+ * The waiter is woken on EVERY state change (item, end, close, failure) so a
558
+ * parked `next()` never sits on a stale promise: the iteration is the host's
559
+ * consumer, and a missed wake would hang a terminal that just died.
560
+ */
561
+ class RelayUplinkQueue {
562
+ maxBytes;
563
+ onFail;
564
+ items = [];
565
+ bufferedBytes = 0;
566
+ ended = false;
567
+ closed = false;
568
+ failure;
569
+ waiter;
570
+ constructor(maxBytes, onFail) {
571
+ this.maxBytes = maxBytes;
572
+ this.onFail = onFail;
573
+ }
574
+ /** One frame in. `frameBytes` is the frame's serialized size — the same
575
+ * accounting the host's inbox charges, so the ceiling here means the same
576
+ * thing it means locally. */
577
+ push(value, frameBytes) {
578
+ if (this.closed || this.failure !== undefined)
579
+ return 'ok';
580
+ if (this.ended) {
581
+ this.failure = new Error('uplink frame arrived after end');
582
+ this.wake();
583
+ this.onFail();
584
+ return 'after-end';
585
+ }
586
+ if (this.bufferedBytes + frameBytes > this.maxBytes) {
587
+ this.failure = new Error('uplink buffer overflow');
588
+ this.wake();
589
+ this.onFail();
590
+ return 'overflow';
591
+ }
592
+ this.items.push({ value, bytes: frameBytes });
593
+ this.bufferedBytes += frameBytes;
594
+ this.wake();
595
+ return 'ok';
596
+ }
597
+ /** The client half-closed: queued items still drain, then the iteration
598
+ * ends. Idempotent. */
599
+ end() {
600
+ if (this.ended)
601
+ return;
602
+ this.ended = true;
603
+ this.wake();
604
+ }
605
+ /** The whole stream is over: drop everything, finish every parked reader,
606
+ * swallow all future pushes. Idempotent. */
607
+ close() {
608
+ if (this.closed)
609
+ return;
610
+ this.closed = true;
611
+ this.items.length = 0;
612
+ this.bufferedBytes = 0;
613
+ this.wake();
614
+ }
615
+ wake() {
616
+ const wake = this.waiter;
617
+ this.waiter = undefined;
618
+ wake?.();
619
+ }
620
+ [Symbol.asyncIterator]() {
621
+ return {
622
+ next: () => this.next(),
623
+ return: () => {
624
+ this.close();
625
+ return Promise.resolve({ value: undefined, done: true });
626
+ },
627
+ };
628
+ }
629
+ async next() {
630
+ while (true) {
631
+ if (this.closed)
632
+ return { value: undefined, done: true };
633
+ const entry = this.items.shift();
634
+ if (entry !== undefined) {
635
+ this.bufferedBytes -= entry.bytes;
636
+ return { value: entry.value, done: false };
637
+ }
638
+ if (this.failure !== undefined)
639
+ throw this.failure;
640
+ if (this.ended)
641
+ return { value: undefined, done: true };
642
+ await new Promise((resolve) => {
643
+ this.waiter = resolve;
644
+ });
645
+ }
646
+ }
647
+ }
523
648
  /**
524
649
  * Build the relay route handler mounted under {@link RELAY_PREFIX}. Exported
525
650
  * as a factory so the route tests can drive it against a plain node:http
@@ -592,6 +717,7 @@ export function createRelayHandler(options) {
592
717
  * route searches these for every incoming eventId. A closed
593
718
  * subscription removes itself; closeAll empties the set. */
594
719
  const liveEventSubscriptions = new Set();
720
+ const liveUplinkStreams = new Map();
595
721
  /** Latest `job/list` rows seen per session — the recent-state half of the
596
722
  * job ownership check (the other half is a one-shot list on a cache miss).
597
723
  * An unshared session's rows are forgotten (T22b-fix): its jobs are none of
@@ -1991,6 +2117,68 @@ export function createRelayHandler(options) {
1991
2117
  }
1992
2118
  return;
1993
2119
  }
2120
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream-uplink`) {
2121
+ // The duplex uplink's post route (TA-1): one JSON body of frames for a
2122
+ // stream opened with `uplink: true`, resolved against the live-uplink
2123
+ // table. Four walls in order, each with its own status: the shape
2124
+ // (400), the stream id and its owning device (404 — unknown-stream,
2125
+ // deliberately indistinguishable between "no such stream" and "not
2126
+ // yours", so a foreign device learns nothing), the share table at post
2127
+ // time (403 — an unshared session's keystrokes must die with the
2128
+ // share), and the sequence counter (409 — replays and reordered posts
2129
+ // would corrupt the host's own input sequencing).
2130
+ const body = await readBodyOrRespond(req, res);
2131
+ if (body === undefined)
2132
+ return;
2133
+ const streamId = body.streamId;
2134
+ const seq = body.seq;
2135
+ const items = body.items;
2136
+ const end = body.end;
2137
+ if (typeof streamId !== 'string' ||
2138
+ streamId === '' ||
2139
+ typeof seq !== 'number' ||
2140
+ !Number.isSafeInteger(seq) ||
2141
+ seq < 1 ||
2142
+ (items !== undefined && !Array.isArray(items)) ||
2143
+ (end !== undefined && typeof end !== 'boolean') ||
2144
+ !((Array.isArray(items) && items.length > 0) || end === true)) {
2145
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } });
2146
+ return;
2147
+ }
2148
+ const device = headerValue(req, 'x-zen-remote-device') ?? '';
2149
+ const entry = liveUplinkStreams.get(streamId);
2150
+ if (entry === undefined || entry.device !== device) {
2151
+ responseJson(res, 404, { ok: false, error: { code: 'unknown-stream' } });
2152
+ return;
2153
+ }
2154
+ if (!entry.sessionIds.every((id) => isAccessible(id))) {
2155
+ responseJson(res, 403, { ok: false, error: { code: 'not-shared' } });
2156
+ return;
2157
+ }
2158
+ if (seq !== entry.nextSeq) {
2159
+ responseJson(res, 409, { ok: false, error: { code: 'out-of-order' } });
2160
+ return;
2161
+ }
2162
+ entry.nextSeq += 1;
2163
+ if (Array.isArray(items)) {
2164
+ for (const item of items) {
2165
+ const itemBytes = Buffer.byteLength(JSON.stringify(item) ?? '', 'utf8');
2166
+ const status = entry.queue.push(item, itemBytes);
2167
+ if (status === 'after-end') {
2168
+ responseJson(res, 400, { ok: false, error: { code: 'uplink-after-end' } });
2169
+ return;
2170
+ }
2171
+ if (status === 'overflow') {
2172
+ responseJson(res, 413, { ok: false, error: { code: 'uplink-overflow' } });
2173
+ return;
2174
+ }
2175
+ }
2176
+ }
2177
+ if (end === true)
2178
+ entry.queue.end();
2179
+ responseJson(res, 200, { ok: true });
2180
+ return;
2181
+ }
1994
2182
  if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream`) {
1995
2183
  // Body validation identical to invoke: one JSON object naming the
1996
2184
  // method and carrying object args.
@@ -2017,6 +2205,23 @@ export function createRelayHandler(options) {
2017
2205
  responseJson(res, 403, { ok: false, error: { code: decision.reason } });
2018
2206
  return;
2019
2207
  }
2208
+ // TA-1: the client's advertised uplink window, shape-checked ONCE here —
2209
+ // only a well-formed window travels to the gateway; garbage (a zero or
2210
+ // negative budget, a fractional value) is dropped, never refused: the
2211
+ // numbers are the host's own contract with itself, and a missing one
2212
+ // just means the host default applies.
2213
+ let uplinkWindow;
2214
+ if (isPlainObject(body.uplinkWindow)) {
2215
+ const candidate = body.uplinkWindow;
2216
+ if (typeof candidate.maxBytes === 'number' &&
2217
+ Number.isSafeInteger(candidate.maxBytes) &&
2218
+ candidate.maxBytes > 0 &&
2219
+ typeof candidate.frameOverheadBytes === 'number' &&
2220
+ Number.isSafeInteger(candidate.frameOverheadBytes) &&
2221
+ candidate.frameOverheadBytes >= 0) {
2222
+ uplinkWindow = { maxBytes: candidate.maxBytes, frameOverheadBytes: candidate.frameOverheadBytes };
2223
+ }
2224
+ }
2020
2225
  const device = headerValue(req, 'x-zen-remote-device') ?? '';
2021
2226
  if ((streamsByDevice.get(device) ?? 0) >= MAX_STREAMS_PER_DEVICE) {
2022
2227
  responseJson(res, 429, { ok: false, error: { code: 'too-many-streams' } });
@@ -2178,6 +2383,30 @@ export function createRelayHandler(options) {
2178
2383
  res.end();
2179
2384
  });
2180
2385
  };
2386
+ // ---- TA-1: the duplex uplink -------------------------------------
2387
+ // A duplex decision the client took up: build the bounded queue the
2388
+ // gateway will read keystrokes from, register it for the
2389
+ // /v1/stream-uplink route, and send the handshake line FIRST —
2390
+ // before the upstream is even open — so the client may start posting
2391
+ // the moment it sees the id. The write chain serializes the line
2392
+ // ahead of any frame; registration precedes the open so a post that
2393
+ // races the handshake still finds the entry (its seq order and the
2394
+ // share table keep it honest either way).
2395
+ let uplinkStreamId;
2396
+ let uplinkQueue;
2397
+ if (decision.uplink === true && body.uplink === true) {
2398
+ uplinkStreamId = randomBytes(16).toString('hex');
2399
+ uplinkQueue = new RelayUplinkQueue(MAX_STREAM_UPLINK_BYTES, () => hangUp.abort());
2400
+ liveUplinkStreams.set(uplinkStreamId, {
2401
+ streamId: uplinkStreamId,
2402
+ device,
2403
+ sessionIds: decision.sessionIds,
2404
+ nextSeq: 1,
2405
+ queue: uplinkQueue,
2406
+ abort: () => hangUp.abort(),
2407
+ });
2408
+ void writeLine({ type: 'uplink', streamId: uplinkStreamId });
2409
+ }
2181
2410
  // ---- share-change synchronization (T22b §4) --------------------
2182
2411
  // The `$zr/events` forwarding bookkeeping (T32): registered for the
2183
2412
  // answer route the moment this stream starts being served, removed
@@ -2342,10 +2571,19 @@ export function createRelayHandler(options) {
2342
2571
  else {
2343
2572
  // `$zr/events` opens through the wire adapter (the ONLY door to
2344
2573
  // the forwarded-event stream); every other route is a typert
2345
- // stream method.
2574
+ // stream method. TA-1: a duplex stream rides with its uplink
2575
+ // queue (the field's PRESENCE is the duplex flag the gateway
2576
+ // reads) and the shape-checked window, both optional.
2346
2577
  const iterable = eventsSub !== undefined
2347
2578
  ? await openEventsStream(hangUp.signal)
2348
- : await gateway.stream(withPeer({ namespace, method, args, signal: hangUp.signal }, options.getPeer));
2579
+ : await gateway.stream(withPeer({
2580
+ namespace,
2581
+ method,
2582
+ args,
2583
+ signal: hangUp.signal,
2584
+ ...(uplinkQueue !== undefined ? { uplink: uplinkQueue } : {}),
2585
+ ...(uplinkWindow !== undefined ? { uplinkWindow } : {}),
2586
+ }, options.getPeer));
2349
2587
  for await (const frame of iterable) {
2350
2588
  if (finished || clientGone)
2351
2589
  break;
@@ -2376,6 +2614,13 @@ export function createRelayHandler(options) {
2376
2614
  clearInterval(heartbeat);
2377
2615
  unsubscribe();
2378
2616
  tableUnsubscribers.delete(unsubscribe);
2617
+ // TA-1: the uplink half dies with the stream — deregistered BEFORE
2618
+ // the queue closes, so a post racing the teardown answers
2619
+ // unknown-stream instead of feeding a dead queue, and the gateway's
2620
+ // consumer is finished instead of parked forever.
2621
+ if (uplinkStreamId !== undefined)
2622
+ liveUplinkStreams.delete(uplinkStreamId);
2623
+ uplinkQueue?.close();
2379
2624
  if (eventsSub !== undefined) {
2380
2625
  liveEventSubscriptions.delete(eventsSub);
2381
2626
  eventsSub.events.clear();
@@ -2423,6 +2668,13 @@ export function createRelayHandler(options) {
2423
2668
  sub.events.clear();
2424
2669
  }
2425
2670
  liveEventSubscriptions.clear();
2671
+ // TA-1: the duplex uplinks die with the handler — close() finishes every
2672
+ // parked gateway consumer (a terminal read must not hang on a reload)
2673
+ // and later posts answer unknown-stream; the streams' own finallys then
2674
+ // find their entries already gone and delete nothing.
2675
+ for (const entry of liveUplinkStreams.values())
2676
+ entry.queue.close();
2677
+ liveUplinkStreams.clear();
2426
2678
  // Remember the reason for requests still inside their ownership probe —
2427
2679
  // they read it at the pre-open gate. Killing the open streams runs their
2428
2680
  // normal teardown (error line, res.end, abort), whose finally releases