dsh-zen-remote 1.1.16 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. package/src/virtual-id.ts +59 -0
@@ -0,0 +1,2025 @@
1
+ /**
2
+ * Server-side relay routes for the desktop client (T22a routes, T22b
3
+ * streaming, T32 event forwarding, T41b plain-HTTP passthrough):
4
+ * authentication, ping, handshake, the single invoke passthrough, the
5
+ * `relay/v1/http` GET dispatch through the host's shared `/api` fetch
6
+ * handler, the NDJSON stream subscription route with share-change
7
+ * synchronization, and the forwarded-event half — the
8
+ * `$zr/events` subscription over the gateway's `$events` wire stream plus
9
+ * the `relay/v1/event-result` answer route. Activity stats remain a later
10
+ * task.
11
+ *
12
+ * Why the secret: the desktop client is a Node process on another machine —
13
+ * it has no DSH login cookie, so the gateway authenticates it with a Bearer
14
+ * pairing token and marks the forwarded request with `x-zen-remote-*` headers.
15
+ * But DSH only listens on 127.0.0.1, where any local process could bypass the
16
+ * gateway and forge those headers. The plugin therefore mints a per-apply
17
+ * secret, hands it to the gateway child through `LAN_GATE_RELAY_SECRET`, the
18
+ * gateway stamps every device-authenticated forward with it, and these routes
19
+ * re-verify it on every request with `timingSafeEqual`.
20
+ *
21
+ * Path dispatch never decodes the request path. The gateway only admits relay
22
+ * paths whose raw and URL-normalized forms are byte-identical, which lets
23
+ * `/_dsh/zen-remote/relay/..%2f..%2fapi` through — decoding it here before
24
+ * matching would reopen the traversal the gateway just closed. Every route is
25
+ * an exact comparison on `new URL(req.url).pathname` as-is; anything else is
26
+ * a 404.
27
+ */
28
+
29
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
30
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
31
+ import { createRequire } from 'node:module'
32
+ import { join } from 'node:path'
33
+ import type { IncomingMessage, ServerResponse } from 'node:http'
34
+ import type { ShareStore } from './share-store.js'
35
+ import { decideHttpRoute, decideInvoke, decideStream, decideUploadQuery, parseEventResultBody } from './relay-access.js'
36
+ import {
37
+ createWorkspaceFollowState,
38
+ filterControlFrame,
39
+ filterJobListFrame,
40
+ filterModelCatalogResult,
41
+ filterSessionListResult,
42
+ filterWorkspaceFrame,
43
+ } from './relay-filter.js'
44
+ import { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri } from './session-reference.js'
45
+
46
+ /**
47
+ * Registration prefix on the host webServer. Deliberately WITHOUT the
48
+ * trailing slash: the webserver matches a prefix route as `pathname === p ||
49
+ * pathname.startsWith(p + '/')`, so a trailing slash here would match only
50
+ * `…/relay//…` shapes and never `…/relay/ping`.
51
+ */
52
+ export const RELAY_PREFIX = '/_dsh/zen-remote/relay'
53
+
54
+ /** The one protocol version this file speaks; the handshake reports it. */
55
+ const RELAY_PROTOCOL = 1
56
+
57
+ /** Request body ceiling for the POST routes other than invoke. */
58
+ const MAX_BODY_BYTES = 1024 * 1024
59
+
60
+ /**
61
+ * Body ceiling for the binary upload channel alone (T51): a shared session's
62
+ * non-image file attachments. DSH's own upload route has NO byte cap — the
63
+ * host stores the stream verbatim (`saveFileStreamVerbatim` →
64
+ * `publishImmutableObjectStream`, RT dsh-attachment-local lib/index.js
65
+ * ~699-716; the 20 MiB `maxImageBytes` there covers INLINE images only), so
66
+ * there is no host number to mirror and this relay picks its own: 100 MiB
67
+ * per upload, checked first against `Content-Length` (refused before a byte
68
+ * moves) and then counted while the stream is pumped (a chunked body has no
69
+ * length header — the sub-client's undici forbids hand-set length headers,
70
+ * so the streaming count is the path its uploads normally take). Past the
71
+ * cap the forward is aborted, the rest of the request is drained and the
72
+ * answer is 413 `payload-too-large`.
73
+ *
74
+ * SLOW-LINK BOUND (T51-fix, for the docs): a single legitimate upload is
75
+ * also bounded in TIME — the host's HTTP server keeps its default 300 s
76
+ * request deadline, and the sub-client's own round-trip budget caps at the
77
+ * same 300 s (relay-client.ts, the T31-fix rule) — so a 100 MiB body needs
78
+ * roughly 2.8 Mbps sustained end to end; slower links fail the call (the
79
+ * client-side refusal keeps the connection state) rather than hanging.
80
+ */
81
+ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
82
+
83
+ /** The `/api` route the upload channel dispatches into, verbatim from the
84
+ * host's own registration (RT dsh-client-file-upload lib/index.js:73). */
85
+ export const UPLOAD_HTTP_PATH = '/api/session/uploadFileBinary'
86
+
87
+ /**
88
+ * Body ceiling for the invoke route alone: the prompt routes carry inline
89
+ * images in their content blocks (`session/prompt` / `subagents/prompt` take
90
+ * base64 `image` parts; DSH admits one image up to 20 MiB by default —
91
+ * `maxImageBytes`, dsh-attachment-local — which base64 inflates to ~27 MiB),
92
+ * so one full-size inline image plus its envelope needs 32 MiB. It is NOT
93
+ * sized for `fileUploads/upload`: the UI's real file uploads ride the HTTP
94
+ * route `api/session/uploadFileBinary` (T41b), not this JSON relay. The
95
+ * buffer only ever grows to the REAL body size; the cap decides
96
+ * accept/refuse, and a body that outgrows it is drained and answered 413
97
+ * like any other oversized read.
98
+ */
99
+ const MAX_INVOKE_BODY_BYTES = 32 * 1024 * 1024
100
+
101
+ /** Error messages forwarded to the client are clipped to this many chars. */
102
+ const MAX_MESSAGE_CHARS = 500
103
+
104
+ /**
105
+ * DSH's own error vocabulary is `namespace/name` (`gateway/cancelled`,
106
+ * `session/unknown-session`, …). Only codes of that exact SHAPE travel to the
107
+ * client with their message — Node's system errors also carry a string
108
+ * `code` (`ENOENT`), and their messages quote server-side absolute paths, so
109
+ * anything not shaped like a DSH code reports `internal` with no message.
110
+ */
111
+ const DSH_ERROR_CODE = /^[a-z][a-zA-Z-]*\/[a-zA-Z-]+$/
112
+
113
+ /** Concurrent streams one device may hold open (per `x-zen-remote-device`). */
114
+ const MAX_STREAMS_PER_DEVICE = 32
115
+
116
+ /**
117
+ * Concurrent invokes one device may keep in flight (CP4): a runaway or
118
+ * hostile client must not pin unbounded host work behind this route the way
119
+ * it must not hold unbounded streams. CP5 raises the cap to 32, the same
120
+ * budget as the stream cap above: opening one remote session in the UI fires
121
+ * a burst of concurrent invokes, and under high latency they stay in flight
122
+ * long enough for the old 8 to refuse real traffic. Past the cap the request
123
+ * answers 429 `too-many-invokes` and the counter is untouched.
124
+ */
125
+ const MAX_INVOKES_PER_DEVICE = 32
126
+
127
+ /**
128
+ * Concurrent uploads one device may keep in flight (T51-fix): the invoke
129
+ * twin of the budgets above, at the same 8 that fits a burst of attachments.
130
+ * Counted only after the query, handler and budget walls passed; a 429 is
131
+ * answered (after the body is drained) without touching the counter. Without
132
+ * this, a stalled or hostile client pins unbounded host uploads — each an
133
+ * in-process dispatch writing to the attachments store — behind this route.
134
+ */
135
+ const MAX_UPLOADS_PER_DEVICE = 8
136
+
137
+ /** Most recent forwarded waterfall events remembered per `$zr/events`
138
+ * subscription (T32-fix): the registry is only an ownership record, so this
139
+ * bounds a pathological burst the same way the job cache does — past the
140
+ * cap the OLDEST entry is dropped (a forwarded-but-forgotten event answers
141
+ * `unknown-event`, which the client surfaces as the same silent ok DSH
142
+ * gives a stale result). */
143
+ const EVENT_REGISTRY_LIMIT = 500
144
+
145
+ /** Stream heartbeat when the caller does not inject one. */
146
+ const DEFAULT_HEARTBEAT_MS = 15_000
147
+
148
+ /** How long a stream's finish may wait for a backed-up socket buffer to
149
+ * drain before the response is destroyed (T43-A). */
150
+ const DEFAULT_END_DRAIN_TIMEOUT_MS = 5_000
151
+
152
+ /** The upload cap used unless the wiring injects one (tests shrink it). */
153
+ const DEFAULT_UPLOAD_CAP_BYTES = MAX_UPLOAD_BYTES
154
+
155
+ /**
156
+ * Decode the gateway's `x-zen-remote-device-name` marking header (T59): the
157
+ * gateway percent-encodes the device table's current name; `undefined` when
158
+ * the header is absent, blank, or not valid percent-encoding (an old gateway,
159
+ * a direct local call). Never throws.
160
+ */
161
+ function deviceNameHeaderOf(req: IncomingMessage): string | undefined {
162
+ const raw = headerValue(req, 'x-zen-remote-device-name')
163
+ if (raw === undefined || raw === '') return undefined
164
+ try {
165
+ const decoded = decodeURIComponent(raw)
166
+ return decoded === '' ? undefined : decoded
167
+ } catch {
168
+ return undefined
169
+ }
170
+ }
171
+
172
+ /** Most recent jobs remembered per session for ownership checks (4b): the
173
+ * latest `job/list` frame replaces the whole set, so this only bounds a
174
+ * pathological single frame. */
175
+ const JOB_CACHE_LIMIT = 1024
176
+
177
+ /** Shape of the `typertGateway` service this route needs (measured live,
178
+ * docs/spike-relay.md §2.1: invoke returns the unwrapped business value and
179
+ * throws errors carrying a string `code`; stream opens one `mode: 'stream'`
180
+ * method and resolves to an async iterable of its frames). Declared
181
+ * structurally instead of augmenting `Context`: the providing package is not
182
+ * a devDependency here, and a local augmentation could collide with its own
183
+ * once that changes.
184
+ *
185
+ * T32 adds the two event surfaces, both OPTIONAL: only `$zr/events` and
186
+ * `relay/v1/event-result` touch them, so a composition (or test fake) without
187
+ * them keeps every other route working and the event routes answer
188
+ * `gateway/service-unavailable`.
189
+ *
190
+ * - `wireStream.open('$events', …)` is the ONLY way to the forwarded-event
191
+ * stream — `gw.stream({namespace:'$events'})` refuses with
192
+ * `gateway/invocation-unavailable` (docs/spike-relay.md §2.1 坑 1). It
193
+ * resolves to an async iterable whose first frame is the `ready` frame.
194
+ * - `dispatchRpc('$events/result', payload, signal, peer)` is the only way to
195
+ * answer a forwarded waterfall — the gateway special-cases the endpoint
196
+ * before its typert dispatch — and returns the `{ok,…}` envelope itself.
197
+ */
198
+ export interface RelayGateway {
199
+ invoke(call: { namespace: string; method: string; args: unknown; signal?: AbortSignal }): Promise<unknown>
200
+ stream(call: {
201
+ namespace: string
202
+ method: string
203
+ args: unknown
204
+ signal?: AbortSignal
205
+ }): Promise<AsyncIterable<unknown>>
206
+ /** The host's wire adapter, shared by the WebSocket mux and the local
207
+ * transports. `open` is the async 0.2.0 method. */
208
+ wireStream?: {
209
+ open(endpoint: string, payload: unknown, uplink: unknown, peer: unknown, signal: AbortSignal | undefined): unknown
210
+ }
211
+ /** The host's `/api` dispatch, owning the `$events/result` special case. */
212
+ dispatchRpc?(
213
+ endpoint: string,
214
+ payload: unknown,
215
+ signal: AbortSignal | undefined,
216
+ peer: unknown,
217
+ ): Promise<unknown>
218
+ }
219
+
220
+ /** What the handshake reports about this server. `serverName` is a CALLBACK
221
+ * on purpose: the row value is a volatile (`{ get() }` wrapped) setting that
222
+ * can change without restarting the plugin row, so it must be recomputed at
223
+ * handshake time, not snapshotted at registration. `fingerprints` is a
224
+ * callback too (T42), awaited per handshake so a just-restarted server never
225
+ * serves a stale map; a THROWN compute degrades to the empty map here so a
226
+ * broken fingerprint source can never fail the handshake itself — the
227
+ * client's comparison judges every group `unavailable`, never `different`. */
228
+ export interface RelayServerInfo {
229
+ serverId: string
230
+ serverName: () => string
231
+ dshVersion: string
232
+ fingerprints?: () => Record<string, string> | Promise<Record<string, string>>
233
+ }
234
+
235
+ export interface RelayHandlerOptions {
236
+ /** The per-apply shared secret; an empty one refuses every request. */
237
+ secret: string
238
+ /** The shared-session table deciding reachability. */
239
+ store: ShareStore
240
+ /** The host gateway service invokes and streams go through. */
241
+ gateway: RelayGateway
242
+ /** Handshake facts. */
243
+ serverInfo: RelayServerInfo
244
+ /** Ancestor lookup for subagent reachability; defaults to "no parent". */
245
+ parentOf?: (id: string) => string | undefined
246
+ /** The host's `/api` shared-fetch dispatcher (T41b), probed lazily per
247
+ * request: `undefined` — no connection service, or no shared handler —
248
+ * answers 501 `unsupported` instead of touching the network. The wiring
249
+ * builds it from `connection.createSharedFetchHandler('/api').fetch`
250
+ * (dsh-client-connection), the in-process entry that dispatches a
251
+ * synthetic Request to the same exact-fetch route table the browser's
252
+ * `/api` transport uses — no loopback HTTP, no login state. */
253
+ getApiFetch?: () => ((request: Request) => Promise<Response>) | undefined
254
+ /** Stream heartbeat interval in ms (a `{"type":"ping"}` line that keeps
255
+ * reverse proxies from timing the idle stream away); defaults to 15000.
256
+ * Tests inject a small value. */
257
+ heartbeatMs?: number
258
+ /** How long a stream's finish may wait for a backed-up write buffer to
259
+ * drain before the response is destroyed (T43-A: a client that stopped
260
+ * reading must not pin the viewer count and the device budget forever);
261
+ * defaults to 5000. Tests inject a small value. */
262
+ endDrainTimeoutMs?: number
263
+ /** The binary upload channel's byte cap (T51); defaults to
264
+ * {@link MAX_UPLOAD_BYTES} (100 MiB). Tests inject a small value so the
265
+ * Content-Length and streaming-count refusals stay cheap to drive. */
266
+ uploadCapBytes?: number
267
+ /** How long an upload refusal's tail drain may run before the socket is
268
+ * destroyed (the {@link DRAIN_DEADLINE_MS} bound); defaults to 10000.
269
+ * Tests inject a small value so the deadline path stays cheap to drive. */
270
+ drainDeadlineMs?: number
271
+ }
272
+
273
+ /**
274
+ * The relay route handler plus its introspection surface. A function WITH
275
+ * properties on purpose: the webServer registration wants exactly a request
276
+ * handler, and the "who is looking" badge (a later UI task) wants the
277
+ * per-session viewer counts — while the row-reload path (T22b-fix) needs to
278
+ * tear every open stream down with the handler that owns it.
279
+ */
280
+ export interface RelayHandler {
281
+ (req: IncomingMessage, res: ServerResponse): Promise<void>
282
+ /** How many session-scoped relay streams currently reference the session
283
+ * (across all devices). */
284
+ viewerCount(sessionId: string): number
285
+ /** End every currently open stream: each client gets one
286
+ * `error{code:'server-restart'}` line, then the response ends (a client
287
+ * that stopped reading is destroyed after {@link RelayHandlerOptions.endDrainTimeoutMs}),
288
+ * the upstream subscription aborts and every counter/listener cleans up —
289
+ * including the handler's own share-table subscriptions, so a table change
290
+ * after the close never reaches this handler again (T43-B). A plugin row
291
+ * reload builds a new handler and share table; without this the streams of
292
+ * the OLD handler would keep pushing, unreachable by any unshare. */
293
+ closeAll(reason: string): void
294
+ }
295
+
296
+ /** One 8-hex-character short server id: four random bytes, enough to tell a
297
+ * handful of servers apart in a client's server list without leaking anything
298
+ * countable about the deployment. */
299
+ const SERVER_ID_PATTERN = /^[0-9a-f]{8}$/
300
+
301
+ const SERVER_ID_FILE = 'zen-remote-server.json'
302
+
303
+ /**
304
+ * Read the persisted server id, creating (and persisting) one on first use.
305
+ * A missing file is the normal first run; an unreadable or damaged file must
306
+ * never take the host down, so every failure degrades to a fresh per-process
307
+ * id that simply is not remembered across restarts.
308
+ */
309
+ export function loadServerId(home: string): string {
310
+ const file = join(home, SERVER_ID_FILE)
311
+ try {
312
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
313
+ const id = (parsed as { serverId?: unknown } | null)?.serverId
314
+ if (typeof id === 'string' && SERVER_ID_PATTERN.test(id)) return id
315
+ } catch {
316
+ // First run, unreadable path, or unparsable JSON — mint a new one below.
317
+ }
318
+ const id = randomBytes(4).toString('hex')
319
+ try {
320
+ // tmp + rename, the same atomic dance the share table uses: a crash
321
+ // mid-write must not leave a half-file that would corrupt the next read.
322
+ mkdirSync(home, { recursive: true })
323
+ writeFileSync(`${file}.tmp`, JSON.stringify({ serverId: id }))
324
+ renameSync(`${file}.tmp`, file)
325
+ } catch {
326
+ // Unwritable home: the id stays process-local. Nothing may throw here.
327
+ }
328
+ return id
329
+ }
330
+
331
+ /**
332
+ * The DSH version reported in the handshake: `DSH_CLIENT_VERSION` when the
333
+ * host sets it, else the version of the `@deepseek-ai/dsh` package resolved
334
+ * from here (compositions without that package installed — tests, Electron —
335
+ * report `'unknown'`). Never throws.
336
+ */
337
+ export function resolveDshVersion(): string {
338
+ const fromEnv = process.env.DSH_CLIENT_VERSION
339
+ if (typeof fromEnv === 'string' && fromEnv !== '') return fromEnv
340
+ try {
341
+ const require = createRequire(import.meta.url)
342
+ const pkg = require('@deepseek-ai/dsh/package.json') as { version?: unknown }
343
+ if (pkg !== null && typeof pkg === 'object' && typeof pkg.version === 'string') return pkg.version
344
+ } catch {
345
+ // Not resolvable in this composition — 'unknown' is an honest answer.
346
+ }
347
+ return 'unknown'
348
+ }
349
+
350
+ function responseJson(res: ServerResponse, status: number, body: unknown): void {
351
+ const bytes = Buffer.from(JSON.stringify(body))
352
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
353
+ res.setHeader('Content-Length', String(bytes.length))
354
+ res.setHeader('Cache-Control', 'no-store')
355
+ res.setHeader('X-Content-Type-Options', 'nosniff')
356
+ res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
357
+ res.writeHead(status)
358
+ res.end(bytes)
359
+ }
360
+
361
+ /** First (and in practice only) value of one request header. */
362
+ function headerValue(req: IncomingMessage, name: string): string | undefined {
363
+ const raw = req.headers[name]
364
+ const value = Array.isArray(raw) ? raw[0] : raw
365
+ return typeof value === 'string' ? value : undefined
366
+ }
367
+
368
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
369
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
370
+ }
371
+
372
+ /** Non-empty string — the client face's `isRemoteEventId` /
373
+ * `isRemoteEventAgentId` / `validRemoteEventName` (dsh-api-gateway
374
+ * lib/client.js). */
375
+ function isEventId(value: unknown): value is string {
376
+ return typeof value === 'string' && value !== ''
377
+ }
378
+
379
+ /**
380
+ * The two `$events` frame shapes a sub-client may be shown, proven
381
+ * well-formed BEFORE forwarding (T32-fix2). The client face's
382
+ * `parseRemoteEventFrame` validates every variant with exact keys — one
383
+ * extra or missing field throws and fails the sub-client's whole `$events`
384
+ * generation — so the relay only forwards frames that would survive that
385
+ * parse. A waterfall's `request` must additionally be a plain object
386
+ * without `agent`/`signal`: the host projection strips those, and the
387
+ * client face refuses them back.
388
+ */
389
+ interface ForwardableWaterfallFrame extends Record<string, unknown> {
390
+ type: 'waterfall'
391
+ event: string
392
+ eventId: string
393
+ agentId: string
394
+ request: Record<string, unknown>
395
+ }
396
+
397
+ interface ForwardableCancelFrame extends Record<string, unknown> {
398
+ type: 'cancel'
399
+ eventId: string
400
+ }
401
+
402
+ function isForwardableWaterfall(frame: Record<string, unknown>): frame is ForwardableWaterfallFrame {
403
+ return (
404
+ Reflect.ownKeys(frame).length === 5 &&
405
+ ['type', 'event', 'eventId', 'agentId', 'request'].every((key) => Object.hasOwn(frame, key)) &&
406
+ isEventId(frame.event) &&
407
+ isEventId(frame.eventId) &&
408
+ isEventId(frame.agentId) &&
409
+ isPlainObject(frame.request) &&
410
+ !Object.hasOwn(frame.request, 'agent') &&
411
+ !Object.hasOwn(frame.request, 'signal')
412
+ )
413
+ }
414
+
415
+ function isForwardableCancel(frame: Record<string, unknown>): frame is ForwardableCancelFrame {
416
+ return (
417
+ Reflect.ownKeys(frame).length === 2 &&
418
+ Object.hasOwn(frame, 'type') &&
419
+ Object.hasOwn(frame, 'eventId') &&
420
+ isEventId(frame.eventId)
421
+ )
422
+ }
423
+
424
+ /**
425
+ * Constant-time secret comparison. Different lengths are trivially unequal
426
+ * (timingSafeEqual itself throws on that); an empty configured secret refuses
427
+ * everything, so "the gateway half is off" can never fail open.
428
+ */
429
+ function secretsMatch(provided: string | undefined, secret: string): boolean {
430
+ if (secret === '' || provided === undefined) return false
431
+ const left = Buffer.from(provided, 'utf8')
432
+ const right = Buffer.from(secret, 'utf8')
433
+ if (left.length !== right.length) return false
434
+ return timingSafeEqual(left, right)
435
+ }
436
+
437
+ /**
438
+ * Read one JSON object body under the byte cap. The EMPTY body parses as the
439
+ * empty object (the handshake takes no arguments; invoke then fails its own
440
+ * field checks). Three outcomes, kept apart: a parsed object (`ok`),
441
+ * unparsable or non-object JSON (`invalid` → the routes answer 400), and a
442
+ * body past the cap (`too-large` → the routes answer 413). An oversized body
443
+ * is DRAINED, not aborted mid-read: destroying the socket before the
444
+ * response goes out would leave the desktop client with a connection error
445
+ * instead of the status code explaining the refusal. A `Content-Length`
446
+ * that already names more than the cap skips the buffering entirely — the
447
+ * drain still runs to the body's end before the verdict resolves.
448
+ */
449
+ type JsonBody =
450
+ | { kind: 'ok'; body: Record<string, unknown> }
451
+ | { kind: 'invalid' }
452
+ | { kind: 'too-large' }
453
+
454
+ const PAYLOAD_TOO_LARGE = Object.freeze({ ok: false, error: { code: 'payload-too-large', details: {} } })
455
+
456
+ /**
457
+ * One readJsonObject plus the two failure answers every POST route shares:
458
+ * an oversized body is 413 `payload-too-large`, unparsable JSON is 400
459
+ * `bad-request` — the two are distinct answers, not one "bad body" pile.
460
+ * `undefined` means the response is already written and the caller returns.
461
+ */
462
+ async function readBodyOrRespond(
463
+ req: IncomingMessage,
464
+ res: ServerResponse,
465
+ cap = MAX_BODY_BYTES,
466
+ ): Promise<Record<string, unknown> | undefined> {
467
+ const read = await readJsonObject(req, cap)
468
+ if (read.kind === 'too-large') {
469
+ responseJson(res, 413, PAYLOAD_TOO_LARGE)
470
+ return undefined
471
+ }
472
+ if (read.kind === 'invalid') {
473
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
474
+ return undefined
475
+ }
476
+ return read.body
477
+ }
478
+
479
+ async function readJsonObject(req: IncomingMessage, cap = MAX_BODY_BYTES): Promise<JsonBody> {
480
+ return new Promise((resolve) => {
481
+ const chunks: Buffer[] = []
482
+ // `Number(undefined)` is NaN, and `NaN > cap` is false: a chunked body
483
+ // (no length header) simply falls through to the byte counting below.
484
+ let tooLarge = Number(req.headers['content-length']) > cap
485
+ let size = 0
486
+ let done = false
487
+ const finish = (value: JsonBody): void => {
488
+ if (!done) {
489
+ done = true
490
+ resolve(value)
491
+ }
492
+ }
493
+ req.on('data', (chunk: Buffer) => {
494
+ if (done || tooLarge) return
495
+ size += chunk.length
496
+ if (size > cap) {
497
+ tooLarge = true
498
+ chunks.length = 0
499
+ return
500
+ }
501
+ chunks.push(chunk)
502
+ // Past the cap: keep consuming without buffering.
503
+ })
504
+ req.on('end', () => {
505
+ if (done) return
506
+ if (tooLarge) {
507
+ finish({ kind: 'too-large' })
508
+ return
509
+ }
510
+ if (size === 0) {
511
+ finish({ kind: 'ok', body: {} })
512
+ return
513
+ }
514
+ let parsed: unknown
515
+ try {
516
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
517
+ } catch {
518
+ finish({ kind: 'invalid' })
519
+ return
520
+ }
521
+ finish(
522
+ parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
523
+ ? { kind: 'ok', body: parsed as Record<string, unknown> }
524
+ : { kind: 'invalid' },
525
+ )
526
+ })
527
+ req.on('error', () => finish({ kind: 'invalid' }))
528
+ })
529
+ }
530
+
531
+ /**
532
+ * How much MORE of an upload request's body a refusal is willing to swallow
533
+ * beyond what was already read (`fromBytes` — the cap pump's count carries
534
+ * over, the drain never gets a fresh allowance), and how long it will wait
535
+ * for that tail before cutting the socket: the answer goes out BEFORE the
536
+ * drain (the two 413 paths write it with `connection: close`), so a
537
+ * still-sending client reads the status code first and its tail is disposed
538
+ * second. A hostile endless stream costs at most this much for this long —
539
+ * never the answer, never the process.
540
+ */
541
+ const DRAIN_SLACK_BYTES = 8 * 1024 * 1024
542
+ const DRAIN_DEADLINE_MS = 10_000
543
+
544
+ /**
545
+ * Consume and discard the tail of one upload request, bounded by
546
+ * {@link DRAIN_SLACK_BYTES} past `fromBytes` and by `deadlineMs`. Resolves on
547
+ * end, error, close, or either bound (the socket is destroyed on a bound — by
548
+ * then the caller's answer is already on the wire). Every upload refusal path
549
+ * runs this; the ones whose answer is NOT yet written (400/403/429/501) await
550
+ * it before answering, so a client still mid-send reads the status code
551
+ * rather than a reset.
552
+ */
553
+ function drainUpload(req: IncomingMessage, fromBytes: number, deadlineMs: number): Promise<void> {
554
+ return new Promise((resolve) => {
555
+ let size = fromBytes
556
+ let settled = false
557
+ const timer = setTimeout(() => {
558
+ req.destroy()
559
+ finish()
560
+ }, deadlineMs)
561
+ if (typeof timer.unref === 'function') timer.unref()
562
+ const onData = (chunk: Buffer): void => {
563
+ size += chunk.byteLength
564
+ if (size - fromBytes > DRAIN_SLACK_BYTES) {
565
+ req.destroy()
566
+ finish()
567
+ }
568
+ }
569
+ const finish = (): void => {
570
+ if (settled) return
571
+ settled = true
572
+ req.off('data', onData)
573
+ req.off('end', finish)
574
+ req.off('error', finish)
575
+ req.off('close', finish)
576
+ clearTimeout(timer)
577
+ resolve()
578
+ }
579
+ req.on('data', onData)
580
+ req.once('end', finish)
581
+ req.once('error', finish)
582
+ req.once('close', finish)
583
+ req.resume()
584
+ })
585
+ }
586
+
587
+ /**
588
+ * What may travel to the client about a failed gateway call. DSH's own errors
589
+ * always carry a `namespace/name`-shaped string `code` — those pass through
590
+ * with a clipped message. EVERYTHING else — a plugin bug, or a Node system
591
+ * error whose `code` (`ENOENT`, …) and message (`… '/Users/x/…'`) quote the
592
+ * server's filesystem — reports `internal` and NO message at all (T22a-fix).
593
+ */
594
+ function errorOf(error: unknown): { code: string; message?: string } {
595
+ const code =
596
+ error !== null && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'string'
597
+ ? (error as { code: string }).code
598
+ : ''
599
+ if (!DSH_ERROR_CODE.test(code)) return { code: 'internal' }
600
+ // CP4: a DSH `*/internal` code (`gateway/internal`, …) is the server saying
601
+ // "my own failure" — its message quotes server-side facts just like a Node
602
+ // error would, so only the code travels.
603
+ if (code.endsWith('/internal')) return { code }
604
+ const text = error instanceof Error ? error.message : String(error)
605
+ return { code, message: text.slice(0, MAX_MESSAGE_CHARS) }
606
+ }
607
+
608
+ /**
609
+ * One live `$zr/events` subscription's forwarding bookkeeping (T32). The
610
+ * answer route (`relay/v1/event-result`) proves an `eventId` was forwarded
611
+ * through HERE before it composes the gateway payload, so the registry is
612
+ * the whole ownership story: event → the agent (session) it belongs to,
613
+ * judged accessible AT ANSWER TIME, and the subscription's own `clientId`
614
+ * — the server is ONE client of the host's `$events` stream, and DSH
615
+ * matches every result against THAT id (`parseRemoteEventResult`), never
616
+ * the sub-client's local one.
617
+ *
618
+ * T32-fix: the events handed to the sub-client are rewritten to
619
+ * `<token>.<eventId>` so an answer names the subscription it belongs to —
620
+ * answers are matched against THAT sub only, on THAT sub's device, never
621
+ * across devices. The registry survives the event's own `cancel` frame
622
+ * NO LONGER: cancel closes the entry (the client already closed its
623
+ * prompt; a later answer refuses `unknown-event`, which the client turns
624
+ * into the same silent ok DSH gives a stale result).
625
+ */
626
+ interface EventsSubscription {
627
+ /** The `x-zen-remote-device` the subscription rides on; answers from any
628
+ * other device are refused before the registry is read. */
629
+ device: string
630
+ /** The random token baked into every forwarded eventId. */
631
+ token: string
632
+ /** The server's `$events` clientId, taken from the subscription's ready
633
+ * frame; undefined until it arrived. */
634
+ clientId: string | undefined
635
+ /** Forwarded waterfall eventIds (ORIGINAL ids — the token stays in the
636
+ * wire form) → the `agentId` each belongs to. Capped at
637
+ * {@link EVENT_REGISTRY_LIMIT}, oldest dropped. */
638
+ events: Map<string, string>
639
+ }
640
+
641
+ /**
642
+ * Build the relay route handler mounted under {@link RELAY_PREFIX}. Exported
643
+ * as a factory so the route tests can drive it against a plain node:http
644
+ * server with a fake gateway and a real share store — no harness required.
645
+ *
646
+ * Every request passes the same gate first: gateway secret, then the two
647
+ * marking headers. Failures answer a uniform 401 that does not say WHICH
648
+ * check failed — the difference would only help someone probing the wall.
649
+ * After the gate, `x-zen-remote-device` is the caller's device id (the
650
+ * per-device stream budget hangs off it).
651
+ *
652
+ * @param options - secret, share store, gateway service and server facts.
653
+ * @returns the handler owning the full response lifecycle of one request,
654
+ * with `viewerCount` alongside for the "who is looking" surface.
655
+ */
656
+ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
657
+ const { secret, store, gateway, serverInfo } = options
658
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS
659
+ const endDrainTimeoutMs = options.endDrainTimeoutMs ?? DEFAULT_END_DRAIN_TIMEOUT_MS
660
+ const uploadCapBytes = options.uploadCapBytes ?? DEFAULT_UPLOAD_CAP_BYTES
661
+ const drainDeadlineMs = options.drainDeadlineMs ?? DRAIN_DEADLINE_MS
662
+ const parentOf = options.parentOf ?? (() => undefined)
663
+ const isAccessible = (sessionId: string): boolean => store.isAccessible(sessionId, parentOf)
664
+
665
+ /**
666
+ * The server display name as of the last second (T59-fix): every stream's
667
+ * heartbeat reads it per ping, and a deployment whose `serverName()`
668
+ * callback re-reads the row's configuration would otherwise do so per
669
+ * STREAM per tick — all streams share this one value instead, refreshed
670
+ * at most once a second (a 15s heartbeat still reads a fresh name every
671
+ * round; the handshake route keeps reading the callback directly).
672
+ */
673
+ let cachedServerName: string | undefined
674
+ let cachedServerNameAt = 0
675
+ const currentServerName = (): string => {
676
+ const now = Date.now()
677
+ if (cachedServerName === undefined || now - cachedServerNameAt >= 1_000) {
678
+ cachedServerName = serverInfo.serverName()
679
+ cachedServerNameAt = now
680
+ }
681
+ return cachedServerName
682
+ }
683
+
684
+ /** Session-scoped streams currently open, per session (cross-device). */
685
+ const viewers = new Map<string, number>()
686
+
687
+ /** Open streams per device id, for the per-device budget. */
688
+ const streamsByDevice = new Map<string, number>()
689
+
690
+ /** Invokes currently in flight per device id, for the invoke budget. */
691
+ const invokesByDevice = new Map<string, number>()
692
+
693
+ /** Uploads currently in flight per device id, for the upload budget (T51-fix). */
694
+ const uploadsByDevice = new Map<string, number>()
695
+
696
+ /**
697
+ * The device NAME this process last saw per device id (T59): every relay
698
+ * request rides the gateway's fresh `x-zen-remote-device-name` marking
699
+ * header, so the map learns a rename as soon as that device sends anything
700
+ * at all — and the NDJSON heartbeat reads the LATEST known name per ping,
701
+ * which is how a server-side rename reaches a connected client without a
702
+ * reconnect. Tiny by construction (paired device ids only).
703
+ */
704
+ const deviceNames = new Map<string, string>()
705
+
706
+ /** The `$zr/events` subscriptions currently open — the event-result
707
+ * route searches these for every incoming eventId. A closed
708
+ * subscription removes itself; closeAll empties the set. */
709
+ const liveEventSubscriptions = new Set<EventsSubscription>()
710
+
711
+ /** Latest `job/list` rows seen per session — the recent-state half of the
712
+ * job ownership check (the other half is a one-shot list on a cache miss).
713
+ * An unshared session's rows are forgotten (T22b-fix): its jobs are none of
714
+ * this client's business anymore. */
715
+ const recentJobs = new Map<string, Map<string, string | undefined>>()
716
+ /**
717
+ * Every share-table listener this handler registered — the ownership
718
+ * cache's unshare sweeper below, plus one per open stream. closeAll
719
+ * detaches them ALL (T43-B): after the handler closed, a share/unshare on
720
+ * the table must never re-enter the dead handler, not even through a
721
+ * stream whose pump is still parked on a full socket buffer.
722
+ */
723
+ const tableUnsubscribers = new Set<() => void>()
724
+ tableUnsubscribers.add(
725
+ store.subscribe((event) => {
726
+ if (event.type === 'unshared') recentJobs.delete(event.sessionId)
727
+ }),
728
+ )
729
+
730
+ /** Kill switches of the streams currently open on THIS handler, and the
731
+ * reason recorded by closeAll — a request that was mid-ownership-probe
732
+ * when closeAll ran reads it just before it would open its upstream. */
733
+ const openStreamKills = new Set<() => void>()
734
+ let serverClosedReason: string | null = null
735
+
736
+ /** Record one raw `job/list` frame into the ownership cache (unfiltered:
737
+ * ownerless entries are remembered too, as "not owned by anyone"). */
738
+ const rememberJobs = (sessionId: string, frame: unknown): void => {
739
+ if (!isPlainObject(frame) || frame.type !== 'rows' || !Array.isArray(frame.jobs)) return
740
+ const jobs = new Map<string, string | undefined>()
741
+ for (const job of frame.jobs) {
742
+ if (jobs.size >= JOB_CACHE_LIMIT) break
743
+ if (isPlainObject(job) && typeof job.id === 'string' && job.id !== '') {
744
+ jobs.set(job.id, typeof job.owner === 'string' ? job.owner : undefined)
745
+ }
746
+ }
747
+ recentJobs.set(sessionId, jobs)
748
+ }
749
+
750
+ /**
751
+ * Whether `jobId` belongs to `sessionId` — the relay-side ownership check
752
+ * for `job/follow` / `job/kill`. DSH opens ownerless jobs to every session,
753
+ * so without this a shared session would be a window onto server-wide
754
+ * processes. The answer comes from the most recent `job/list` rows; on a
755
+ * cache miss, one throwaway `job/list` stream is opened and abandoned. A
756
+ * job the list does not mention is REFUSED: no answer is treated as no
757
+ * permission, never the reverse.
758
+ */
759
+ const ownsJob = async (sessionId: string, jobId: string, signal: AbortSignal): Promise<boolean> => {
760
+ const cached = recentJobs.get(sessionId)
761
+ if (cached !== undefined && cached.has(jobId)) return cached.get(jobId) === sessionId
762
+ try {
763
+ const iterable = await gateway.stream({
764
+ namespace: 'job',
765
+ method: 'list',
766
+ args: { request: { sessionId } },
767
+ signal,
768
+ })
769
+ for await (const frame of iterable) {
770
+ rememberJobs(sessionId, frame)
771
+ const jobs = recentJobs.get(sessionId)
772
+ // The first frame is the whole recent set; anything the client could
773
+ // have seen lives in it or nowhere.
774
+ return jobs !== undefined && jobs.has(jobId) && jobs.get(jobId) === sessionId
775
+ }
776
+ return false
777
+ } catch {
778
+ return false
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Whether `workspaceId` names a workspace the server currently has, answered
784
+ * from a one-shot `workspace/follow` baseline (T31 — the only workspace read
785
+ * the gateway exposes; the stream is abandoned after its first frame). Any
786
+ * failure — transport, unexpected first frame, missing entry — is `false`:
787
+ * no answer is not permission. The create route pins the call to the
788
+ * workspace rather than to a path on purpose: with `workspaceId` attached,
789
+ * DSH takes `workspace.path` as the session cwd itself and attaches the new
790
+ * session to the workspace (the group the client created it from).
791
+ */
792
+ const workspaceExists = async (workspaceId: string, signal: AbortSignal): Promise<boolean> => {
793
+ try {
794
+ const iterable = await gateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
795
+ for await (const frame of iterable) {
796
+ if (!isPlainObject(frame) || frame.type !== 'baseline') return false
797
+ const value = isPlainObject(frame.value) ? frame.value : undefined
798
+ const items = value !== undefined && Array.isArray(value.items) ? value.items : []
799
+ return items.some((item) => isPlainObject(item) && item.workspaceId === workspaceId)
800
+ }
801
+ return false
802
+ } catch {
803
+ return false
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Open THIS server's own `$events` subscription (T32) — through the
809
+ * host's wire adapter, the only door (docs/spike-relay.md §2.1 坑 1:
810
+ * `gw.stream({namespace:'$events'})` refuses with
811
+ * `gateway/invocation-unavailable`). The payload must be exactly
812
+ * `{args:{}}` (gateway `openRemoteEvents`); the uplink is undefined —
813
+ * the endpoint takes none and the host half-closes whatever arrives.
814
+ */
815
+ const openEventsStream = async (signal: AbortSignal): Promise<AsyncIterable<unknown>> => {
816
+ const open = gateway.wireStream?.open
817
+ if (typeof open !== 'function') {
818
+ throw Object.assign(new Error('forwarded Remote event source is unavailable'), {
819
+ code: 'gateway/service-unavailable',
820
+ })
821
+ }
822
+ const opened = (await (open as (this: unknown, ...openArgs: unknown[]) => unknown).call(
823
+ gateway.wireStream,
824
+ '$events',
825
+ { args: {} },
826
+ undefined,
827
+ undefined,
828
+ signal,
829
+ )) as unknown
830
+ if (
831
+ opened === null ||
832
+ typeof opened !== 'object' ||
833
+ typeof (opened as Record<symbol, unknown>)[Symbol.asyncIterator] !== 'function'
834
+ ) {
835
+ throw Object.assign(new Error('forwarded Remote event stream did not open'), {
836
+ code: 'gateway/service-unavailable',
837
+ })
838
+ }
839
+ return opened as AsyncIterable<unknown>
840
+ }
841
+
842
+ /** Record one forwarded waterfall in the subscription's registry (oldest
843
+ * dropped past the cap), under its ORIGINAL id. */
844
+ const rememberEvent = (sub: EventsSubscription, eventId: string, agentId: string): void => {
845
+ if (sub.events.size >= EVENT_REGISTRY_LIMIT) {
846
+ const oldest = sub.events.keys().next().value
847
+ if (oldest !== undefined) sub.events.delete(oldest)
848
+ }
849
+ sub.events.set(eventId, agentId)
850
+ }
851
+
852
+ /**
853
+ * The `$zr/events` frame discipline (T32, reworked in T32-fix2), one
854
+ * function for the pump:
855
+ *
856
+ * - READY is recorded (the subscription's clientId, for the answer route)
857
+ * and forwarded SCRUBBED — the client learns "ready", never the host
858
+ * facts (`clientId`, `home`) that would let it speak to the gateway
859
+ * around this relay.
860
+ * - WATERFALL frames whose `agentId` is not reachable right now are
861
+ * dropped — NOT answered on the sub-client's behalf (see DROPPED
862
+ * DELIVERIES below). Forwarded ones are registered and rewritten to
863
+ * `<token>.<eventId>`: the answer route resolves the subscription (and
864
+ * its device) from the token, so ids never cross subscriptions.
865
+ * Rewriting to virtual ids for the UI stays the CLIENT's job — it
866
+ * prefixes the whole thing with `zr~<serverId>~`.
867
+ * - CANCEL frames are forwarded only for events this subscription
868
+ * forwarded (an unshared session's activity timeline must not leak
869
+ * through cancels), and the entry goes with the forward.
870
+ * - EMIT frames are dropped entirely (T32-fix): they carry server-wide
871
+ * state (`api-session/added` summaries and titles, account expirations,
872
+ * cordis chatter) that is not share-scoped — forwarding them would leak
873
+ * unshared sessions and feed the sub-client ids it would treat as
874
+ * local. Everything a sub-client needs about a session's state travels
875
+ * the workspace/control streams, which ARE share-filtered.
876
+ * - Anything not shaped exactly like a forwardable waterfall or cancel
877
+ * (T32-fix2, mirroring the client face's `parseRemoteEventFrame`
878
+ * exact-keys validation) is dropped too: a frame that slipped through
879
+ * would fail the sub-client's whole `$events` generation and leave it
880
+ * failing and reconnecting in a loop.
881
+ *
882
+ * DROPPED DELIVERIES STAY PENDING (T32-fix2): a dropped waterfall used to
883
+ * be abstained `next` on behalf. That is gone on purpose. DSH's `next`
884
+ * only retracts the answering client's OWN delivery, and the event
885
+ * settles `next` — falling through to the host's fallback, where
886
+ * `approval/request` resolves `unavailable` (which dsh-user-approval
887
+ * treats as a rejection) and `user-questions/request` throws NO_PROVIDER
888
+ * — only when NO delivery remains (RT dsh-api-gateway
889
+ * `receiveRemoteEventResult`). With the desktop window closed and the
890
+ * phone asleep, the relay IS the only delivery: one abstention would fail
891
+ * every pending approval of the dropped session at once and break the
892
+ * push → wake → approve flow. The price: a waterfall dropped here keeps
893
+ * the relay's delivery pending, so an event the server UI answered `next`
894
+ * waits until the next `$events` client connects — the gateway
895
+ * re-delivers still-pending events to a fresh subscription
896
+ * (`openRemoteEvents`). A wait, never a rejection.
897
+ *
898
+ * KNOWN LIMITATION: an event that arrives while its session is still
899
+ * unshared is dropped; sharing the session later does NOT resurrect it
900
+ * for the sub-client — it only sees such events from the next reconnect
901
+ * onward, when the gateway re-delivers still-pending events to the fresh
902
+ * subscription.
903
+ */
904
+ const filterEventsFrame = (sub: EventsSubscription, frame: unknown): unknown => {
905
+ if (!isPlainObject(frame)) return null
906
+ if (frame.type === 'ready') {
907
+ if (typeof frame.clientId === 'string' && frame.clientId !== '') sub.clientId = frame.clientId
908
+ return { type: 'ready' }
909
+ }
910
+ if (frame.type === 'waterfall') {
911
+ if (!isForwardableWaterfall(frame) || !isAccessible(frame.agentId)) return null
912
+ rememberEvent(sub, frame.eventId, frame.agentId)
913
+ return { ...frame, eventId: `${sub.token}.${frame.eventId}` }
914
+ }
915
+ if (frame.type === 'cancel') {
916
+ if (!isForwardableCancel(frame) || !sub.events.has(frame.eventId)) return null
917
+ sub.events.delete(frame.eventId)
918
+ return { ...frame, eventId: `${sub.token}.${frame.eventId}` }
919
+ }
920
+ return null
921
+ }
922
+
923
+ const handle: RelayHandler = async function handleRelay(req, res) {
924
+ if (
925
+ !secretsMatch(headerValue(req, 'x-zen-remote-secret'), secret) ||
926
+ headerValue(req, 'x-zen-remote-via') !== 'gateway' ||
927
+ headerValue(req, 'x-zen-remote-role') !== 'desktop-client'
928
+ ) {
929
+ responseJson(res, 401, { ok: false, error: { code: 'relay-unauthorized' } })
930
+ return
931
+ }
932
+ // The stream route below budgets concurrent streams per device; other
933
+ // routes do not need the id beyond the gate itself.
934
+ // T59: every authenticated request teaches the name cache — the gateway
935
+ // stamps the device table's CURRENT name on every forward, so this line
936
+ // is where a rename (admin edit, or the device's own push) lands.
937
+ const deviceNameHeader = deviceNameHeaderOf(req)
938
+ const gateDeviceId = headerValue(req, 'x-zen-remote-device')
939
+ if (gateDeviceId !== undefined && gateDeviceId !== '' && deviceNameHeader !== undefined) {
940
+ deviceNames.set(gateDeviceId, deviceNameHeader)
941
+ }
942
+
943
+ // The pathname EXACTLY as the webserver saw it — never decoded before
944
+ // matching (see the module comment): a decoded match would turn the
945
+ // gateway-admitted `…%2f…` shapes back into a traversal.
946
+ let pathname = ''
947
+ try {
948
+ pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
949
+ } catch {
950
+ responseJson(res, 404, { ok: false, error: { code: 'not-found' } })
951
+ return
952
+ }
953
+
954
+ if (req.method === 'GET' && pathname === `${RELAY_PREFIX}/ping`) {
955
+ responseJson(res, 200, { ok: true })
956
+ return
957
+ }
958
+
959
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/handshake`) {
960
+ const body = await readBodyOrRespond(req, res)
961
+ if (body === undefined) return
962
+ // The compute rides its own guard (T42-fix): a throwing fingerprint
963
+ // source answers an empty map and the handshake still succeeds.
964
+ let fingerprints: Record<string, string> = {}
965
+ try {
966
+ fingerprints = (await serverInfo.fingerprints?.()) ?? {}
967
+ } catch {
968
+ fingerprints = {}
969
+ }
970
+ responseJson(res, 200, {
971
+ ok: true,
972
+ relayProtocol: RELAY_PROTOCOL,
973
+ serverId: serverInfo.serverId,
974
+ serverName: serverInfo.serverName(),
975
+ dshVersion: serverInfo.dshVersion,
976
+ // T42: the server's own interface fingerprints (empty when the wiring
977
+ // had nothing to compute — the client judges group by group).
978
+ fingerprints,
979
+ // T59: the CALLING device's name, as the gateway's device table holds
980
+ // it at this moment (the header is written fresh per forward, so a
981
+ // just-renamed device reads its new name here). Undefined without the
982
+ // header — an old gateway or a direct call — and the field then rides
983
+ // no further: the client keeps what it has.
984
+ ...(deviceNameHeaderOf(req) !== undefined ? { deviceName: deviceNameHeaderOf(req) } : {}),
985
+ })
986
+ return
987
+ }
988
+
989
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/unshare`) {
990
+ const body = await readBodyOrRespond(req, res)
991
+ if (body === undefined) return
992
+ const sessionId = body.sessionId
993
+ if (typeof sessionId !== 'string' || sessionId === '') {
994
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
995
+ return
996
+ }
997
+ // The TABLE only — isShared, not isAccessible: a subagent or fork
998
+ // session never entered the table, so it cannot be closed alone; it
999
+ // leaves remote access together with its family or not at all.
1000
+ if (!store.isShared(sessionId)) {
1001
+ responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
1002
+ return
1003
+ }
1004
+ store.unshare(sessionId, 'client')
1005
+ responseJson(res, 200, { ok: true })
1006
+ return
1007
+ }
1008
+
1009
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/event-result`) {
1010
+ // The answer half of `$zr/events` (T32): `{eventId, result}`, where
1011
+ // `eventId` is the OPAQUE `<token>.<eventId>` string this relay handed
1012
+ // the sub-client (wrapped in `zr~<serverId>~` by the client), and
1013
+ // `result` is the Remote event OUTCOME, forwarded verbatim.
1014
+ const body = await readBodyOrRespond(req, res)
1015
+ if (body === undefined) return
1016
+ const parsed = parseEventResultBody(body)
1017
+ if (parsed === undefined) {
1018
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
1019
+ return
1020
+ }
1021
+ // Ownership (T32-fix): the token locates the ONE subscription the
1022
+ // event was forwarded on — the answer is matched against that sub
1023
+ // only, and only from the device that holds it. An unknown token, a
1024
+ // foreign device, or an eventId that subscription did not forward is
1025
+ // the same refusal (no oracle about which half missed).
1026
+ const separator = parsed.eventId.indexOf('.')
1027
+ const token = separator === -1 ? '' : parsed.eventId.slice(0, separator)
1028
+ const originalEventId = separator === -1 ? '' : parsed.eventId.slice(separator + 1)
1029
+ let owner: { clientId: string; agentId: string } | undefined
1030
+ if (token !== '' && originalEventId !== '') {
1031
+ const device = headerValue(req, 'x-zen-remote-device') ?? ''
1032
+ for (const sub of liveEventSubscriptions) {
1033
+ if (sub.token !== token) continue
1034
+ if (sub.device !== device) break
1035
+ const agentId = sub.events.get(originalEventId)
1036
+ if (agentId !== undefined && sub.clientId !== undefined) {
1037
+ owner = { clientId: sub.clientId, agentId }
1038
+ }
1039
+ break
1040
+ }
1041
+ }
1042
+ if (owner === undefined) {
1043
+ responseJson(res, 403, { ok: false, error: { code: 'unknown-event' } })
1044
+ return
1045
+ }
1046
+ if (!isAccessible(owner.agentId)) {
1047
+ responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
1048
+ return
1049
+ }
1050
+ const dispatch = gateway.dispatchRpc
1051
+ if (typeof dispatch !== 'function') {
1052
+ responseJson(res, 200, {
1053
+ ok: false,
1054
+ error: { code: 'gateway/service-unavailable', message: 'forwarded Remote event source is unavailable' },
1055
+ })
1056
+ return
1057
+ }
1058
+ // A hang-up cancels the answer mid-flight, like invoke's.
1059
+ const hangUp = new AbortController()
1060
+ const onClientGone = (): void => {
1061
+ if (!res.writableEnded) hangUp.abort()
1062
+ }
1063
+ res.on('close', onClientGone)
1064
+ try {
1065
+ // Exactly the gateway's payload contract (parseRemoteEventResult):
1066
+ // one `args` field holding `{clientId, eventId, outcome}` — the
1067
+ // subscription's OWN clientId (the gateway matches results against
1068
+ // the client the event was delivered to) and the ORIGINAL eventId
1069
+ // with the token stripped back off; the outcome untouched. The
1070
+ // envelope — including the gateway's own
1071
+ // `{ok:false, error:{code:'gateway/internal',…}}` for a malformed
1072
+ // outcome — rides back as the 200 body.
1073
+ const envelope = (await dispatch.call(
1074
+ gateway,
1075
+ '$events/result',
1076
+ { args: { clientId: owner.clientId, eventId: originalEventId, outcome: parsed.result } },
1077
+ hangUp.signal,
1078
+ undefined,
1079
+ )) as unknown
1080
+ responseJson(res, 200, isPlainObject(envelope) ? envelope : { ok: false, error: { code: 'internal' } })
1081
+ } catch (error) {
1082
+ const { code, message } = errorOf(error)
1083
+ responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } })
1084
+ } finally {
1085
+ res.off('close', onClientGone)
1086
+ }
1087
+ return
1088
+ }
1089
+
1090
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/invoke`) {
1091
+ // The prompt-sized cap: a registered invoke may carry inline images in
1092
+ // its content blocks (see MAX_INVOKE_BODY_BYTES).
1093
+ const body = await readBodyOrRespond(req, res, MAX_INVOKE_BODY_BYTES)
1094
+ if (body === undefined) return
1095
+ const namespace = body.namespace
1096
+ const method = body.method
1097
+ const args = body.args
1098
+ if (
1099
+ typeof namespace !== 'string' ||
1100
+ namespace === '' ||
1101
+ typeof method !== 'string' ||
1102
+ method === '' ||
1103
+ args === null ||
1104
+ typeof args !== 'object' ||
1105
+ Array.isArray(args)
1106
+ ) {
1107
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
1108
+ return
1109
+ }
1110
+ const decision = decideInvoke(namespace, method, args, isAccessible)
1111
+ if (!decision.allow) {
1112
+ responseJson(res, 403, { ok: false, error: { code: decision.reason } })
1113
+ return
1114
+ }
1115
+ // CP4: per-device in-flight budget — the invoke twin of the stream
1116
+ // budget below. Counted only from here (every earlier refusal is
1117
+ // answered before this line) and released in the route's finally,
1118
+ // which every path through the try reaches.
1119
+ const device = headerValue(req, 'x-zen-remote-device') ?? ''
1120
+ if ((invokesByDevice.get(device) ?? 0) >= MAX_INVOKES_PER_DEVICE) {
1121
+ responseJson(res, 429, { ok: false, error: { code: 'too-many-invokes' } })
1122
+ return
1123
+ }
1124
+ invokesByDevice.set(device, (invokesByDevice.get(device) ?? 0) + 1)
1125
+ // A client hanging up must not leave the host running the call to
1126
+ // completion behind a dead socket: the connection's close (arriving
1127
+ // before the response ends) cancels the AbortController whose signal
1128
+ // rides into gateway.invoke. 'close' also fires AFTER a normal end,
1129
+ // so only an unfinished response counts as a hang-up.
1130
+ const hangUp = new AbortController()
1131
+ const onClientGone = (): void => {
1132
+ if (!res.writableEnded) hangUp.abort()
1133
+ }
1134
+ res.on('close', onClientGone)
1135
+ try {
1136
+ // `job/kill` additionally proves the target job belongs to the
1137
+ // claimed session (4b): DSH opens ownerless jobs to everyone, so the
1138
+ // share-table check alone would let one shared session kill
1139
+ // server-wide background jobs.
1140
+ if (namespace === 'job' && method === 'kill') {
1141
+ const request = isPlainObject(args) ? args.request : undefined
1142
+ const jobId =
1143
+ isPlainObject(request) && typeof request.jobId === 'string' && request.jobId !== ''
1144
+ ? request.jobId
1145
+ : undefined
1146
+ const sessionId = isPlainObject(request) && typeof request.sessionId === 'string' ? request.sessionId : ''
1147
+ if (jobId === undefined || sessionId === '' || !(await ownsJob(sessionId, jobId, hangUp.signal))) {
1148
+ responseJson(res, 403, { ok: false, error: { code: 'forbidden' } })
1149
+ return
1150
+ }
1151
+ // The ownership probe can take a round trip — remote may have been
1152
+ // closed while it ran (T22b-fix). The decision above is stale the
1153
+ // moment an await happened; re-run the share-table check before
1154
+ // anything is forwarded.
1155
+ if (!isAccessible(sessionId)) {
1156
+ responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
1157
+ return
1158
+ }
1159
+ }
1160
+ // T31-fix: the forwarded create is REBUILT as a whitelist, not
1161
+ // trimmed by deletion — exactly `{workspaceId, agentPreset?}` and
1162
+ // nothing else, so a hostile or buggy client cannot smuggle extra
1163
+ // fields (env, permissionMode, a caller-chosen sessionId, a decoy
1164
+ // cwd) through to DSH. The workspace was probed above; the new
1165
+ // session's id and cwd are DSH's own business (it mints the id and
1166
+ // takes `workspace.path`; a create naming BOTH workspaceId and cwd
1167
+ // would be a `gateway/bad-request`).
1168
+ if (namespace === 'session' && method === 'create') {
1169
+ const request = isPlainObject(args) ? args.request : undefined
1170
+ const workspaceId =
1171
+ isPlainObject(request) && typeof request.workspaceId === 'string' && request.workspaceId !== ''
1172
+ ? request.workspaceId
1173
+ : undefined
1174
+ if (workspaceId === undefined || !(await workspaceExists(workspaceId, hangUp.signal))) {
1175
+ responseJson(res, 403, { ok: false, error: { code: 'workspace/not-found' } })
1176
+ return
1177
+ }
1178
+ ;(args as Record<string, unknown>).request = {
1179
+ workspaceId,
1180
+ ...(isPlainObject(request) && typeof request.agentPreset === 'string'
1181
+ ? { agentPreset: request.agentPreset }
1182
+ : {}),
1183
+ }
1184
+ }
1185
+ // T41a-fix: a canonical `dsh-session:` address in an injectable text
1186
+ // makes DSH inject the referenced session's content with no access
1187
+ // check of its own (dsh-session-reference prepareDirectMessages →
1188
+ // readSurface). Every referenced session must pass the share table
1189
+ // before the call is forwarded — the prompt-text sibling of the
1190
+ // job/kill ownership probe above. T41a-fix2 closed the two bypasses
1191
+ // the prompt-only scan left: a queue EDIT replaces a queued USER
1192
+ // message's content verbatim (RT dsh-api-session-controller
1193
+ // updateQueue), and a slash command's raw input is steered in as a
1194
+ // USER message by its handler (`/plan <text>` does exactly that, RT
1195
+ // dsh-plan-mode) — both are parsed at the next turn start, exactly
1196
+ // like prompt text. WHERE the injectable texts live is the shared
1197
+ // rule (session-reference.ts), the same one the sub-client rewrites
1198
+ // by; everything else in the arguments is not parsed by the host.
1199
+ if (referencedSessionIds(namespace, method, args).some((id) => !isAccessible(id))) {
1200
+ responseJson(res, 403, { ok: false, error: { code: 'not-shared' } })
1201
+ return
1202
+ }
1203
+ const value = await gateway.invoke({ namespace, method, args, signal: hangUp.signal })
1204
+ // T31: a session created or forked THROUGH the relay is shared
1205
+ // automatically — the client only ever sees shared sessions, so an
1206
+ // unshared result would be born unreachable. Re-sharing (a fork the
1207
+ // new-session listener already caught) is a table no-op.
1208
+ if (namespace === 'session' && (method === 'create' || method === 'fork')) {
1209
+ const created = isPlainObject(value) && typeof value.sessionId === 'string' ? value.sessionId : undefined
1210
+ if (created !== undefined) store.share(created)
1211
+ }
1212
+ // T41a-fix: the @ resolver names EVERY server session in its answer —
1213
+ // a row the share table cannot reach never travels (the session/list
1214
+ // discipline). T52-fix: the model catalog's answer leaves with its
1215
+ // `failures` emptied — the host's per-group error texts may carry
1216
+ // endpoint or credential details (relay-filter.ts
1217
+ // filterModelCatalogResult).
1218
+ const travels =
1219
+ decision.filter === 'session-list'
1220
+ ? filterSessionListResult(value, isAccessible)
1221
+ : decision.filter === 'session-reference-candidates'
1222
+ ? filterAccessibleCandidateRows(value, isAccessible)
1223
+ : decision.filter === 'model-catalog'
1224
+ ? filterModelCatalogResult(value)
1225
+ : value
1226
+ responseJson(res, 200, { ok: true, value: travels })
1227
+ } catch (error) {
1228
+ // The gateway's own failures (unknown namespace, absent service) are
1229
+ // business answers, not transport errors: they ride a 200 envelope
1230
+ // like every other result, with the gateway's string code preserved.
1231
+ const { code, message } = errorOf(error)
1232
+ responseJson(res, 200, message === undefined ? { ok: false, error: { code } } : { ok: false, error: { code, message } })
1233
+ } finally {
1234
+ res.off('close', onClientGone)
1235
+ // The invoke is over — answered, refused mid-flight, or its client
1236
+ // gone: the budget slot goes back.
1237
+ const remaining = (invokesByDevice.get(device) ?? 1) - 1
1238
+ if (remaining <= 0) invokesByDevice.delete(device)
1239
+ else invokesByDevice.set(device, remaining)
1240
+ }
1241
+ return
1242
+ }
1243
+
1244
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/http`) {
1245
+ // The plain-HTTP panel long tail (T41b): `{route, query}` names one
1246
+ // GET the sub-client's `/api/...` fetch wrapper intercepted. GET
1247
+ // semantics ONLY — the body carries the coordinates of the request to
1248
+ // dispatch, never a request body to forward, so a `body` field is a
1249
+ // protocol violation rather than data.
1250
+ const body = await readBodyOrRespond(req, res)
1251
+ if (body === undefined) return
1252
+ if (body.body !== undefined) {
1253
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
1254
+ return
1255
+ }
1256
+ const route = body.route
1257
+ const query = body.query
1258
+ if (typeof route !== 'string' || typeof query !== 'string') {
1259
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
1260
+ return
1261
+ }
1262
+ const decision = decideHttpRoute(route, query, isAccessible)
1263
+ if (!decision.allow) {
1264
+ const status =
1265
+ decision.reason === 'unknown-route'
1266
+ ? 404
1267
+ : decision.reason === 'not-shared'
1268
+ ? 403
1269
+ : 400 // no-session / bad-query — malformed coordinates
1270
+ responseJson(res, status, { ok: false, error: { code: decision.reason } })
1271
+ return
1272
+ }
1273
+ const dispatch = options.getApiFetch?.()
1274
+ if (dispatch === undefined) {
1275
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
1276
+ return
1277
+ }
1278
+ let request: Request
1279
+ try {
1280
+ // The URL is composed from the DECISION's normalized query alone —
1281
+ // never the raw wire string. The two parsers disagree about control
1282
+ // characters (URLSearchParams keeps them in key names, the URL
1283
+ // constructor strips them), and that differential was an
1284
+ // authorization bypass (T41b-fix): decideHttpRoute now rebuilds the
1285
+ // whitelisted parameters itself, so what was checked is exactly what
1286
+ // is dispatched.
1287
+ request = new Request(`http://relay.local/api/${route}?${decision.query}`, {
1288
+ method: 'GET',
1289
+ signal: hangUpOf(res),
1290
+ })
1291
+ } catch {
1292
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
1293
+ return
1294
+ }
1295
+ try {
1296
+ // In-process dispatch through the shared `/api` handler — the same
1297
+ // exact-fetch route table the browser's transport uses, no loopback
1298
+ // HTTP, no login state. Both registered routes answer buffered JSON,
1299
+ // so the whole response is read and re-wrapped as the envelope value.
1300
+ const upstream = await dispatch(request)
1301
+ const bodyText = await upstream.text()
1302
+ const contentType = upstream.headers.get('content-type') ?? undefined
1303
+ responseJson(res, 200, {
1304
+ ok: true,
1305
+ value: { status: upstream.status, ...(contentType !== undefined ? { contentType } : {}), body: bodyText },
1306
+ })
1307
+ } catch {
1308
+ // A dispatch failure is this server's fault, not a business answer —
1309
+ // and errorOf keeps any message off the wire.
1310
+ responseJson(res, 502, { ok: false, error: { code: 'internal' } })
1311
+ }
1312
+ return
1313
+ }
1314
+
1315
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/upload`) {
1316
+ // The binary upload channel (T51): the sub-client's wrapped
1317
+ // `/api/session/uploadFileBinary` forwards a remote session's raw
1318
+ // attachment bytes here, and this route dispatches them through the
1319
+ // host's shared `/api` handler IN PROCESS — the same exact-fetch route
1320
+ // entry the local upload would have reached, so the staged receipt
1321
+ // lands under the same session the later `session/prompt` resolves it
1322
+ // against. The body is a STREAM, not JSON: nothing here buffers it
1323
+ // beyond the cap verdict, and the host's own upload route has no cap
1324
+ // to inherit (see MAX_UPLOAD_BYTES), so this relay brings its own —
1325
+ // Content-Length first, a counting pump as the chunked-body fallback.
1326
+ //
1327
+ // Refusal discipline (T51-fix): the two 413 paths answer FIRST — the
1328
+ // status code plus `connection: close` are on the wire before a single
1329
+ // tail byte is drained, so a client still pumping a huge body reads
1330
+ // the refusal instead of a reset — and only then swallow a bounded,
1331
+ // timed tail (drainUpload). The small-body refusals (query, access,
1332
+ // budget, no dispatcher) keep the drain-first order: their clients are
1333
+ // ordinary requests, and draining keeps the answer off a reset.
1334
+ if (Number(req.headers['content-length']) > uploadCapBytes) {
1335
+ res.setHeader('Connection', 'close')
1336
+ responseJson(res, 413, PAYLOAD_TOO_LARGE)
1337
+ await drainUpload(req, 0, drainDeadlineMs)
1338
+ return
1339
+ }
1340
+ // The query is parsed ONCE (decideUploadQuery, the T41b-fix
1341
+ // discipline): what was share-checked is exactly the normalized query
1342
+ // the synthetic URL below is composed from — a duplicate sessionId, a
1343
+ // tab-carrying key name, or a smuggled second coordinate can never
1344
+ // re-enter between the check and the dispatch.
1345
+ const decision = decideUploadQuery(new URL(req.url ?? '/', 'http://dsh.internal').search, isAccessible)
1346
+ if (!decision.allow) {
1347
+ await drainUpload(req, 0, drainDeadlineMs)
1348
+ responseJson(res, decision.reason === 'not-shared' ? 403 : 400, { ok: false, error: { code: decision.reason } })
1349
+ return
1350
+ }
1351
+ const dispatch = options.getApiFetch?.()
1352
+ if (dispatch === undefined) {
1353
+ await drainUpload(req, 0, drainDeadlineMs)
1354
+ responseJson(res, 501, { ok: false, error: { code: 'unsupported' } })
1355
+ return
1356
+ }
1357
+ // The per-device upload budget (T51-fix), checked after every wall
1358
+ // above and released in the route's finally — one stalled client must
1359
+ // not pin unbounded host uploads behind this route. An over-budget
1360
+ // request is drained and refused without touching the counter. The
1361
+ // slot itself is TAKEN only after the synthetic request below built
1362
+ // (T5x): that construction is the one step between the check and the
1363
+ // dispatch that can fail and return outside the finally, and a taken-
1364
+ // then-abandoned slot would quietly shrink this device's budget.
1365
+ const device = headerValue(req, 'x-zen-remote-device') ?? ''
1366
+ if ((uploadsByDevice.get(device) ?? 0) >= MAX_UPLOADS_PER_DEVICE) {
1367
+ await drainUpload(req, 0, drainDeadlineMs)
1368
+ responseJson(res, 429, { ok: false, error: { code: 'too-many-uploads' } })
1369
+ return
1370
+ }
1371
+ // A hang-up cancels the forward mid-stream, like invoke's — and the
1372
+ // cap pump below aborts the SAME controller when it trips, so an
1373
+ // oversized upload dies at the host side the moment it is judged.
1374
+ const hangUp = new AbortController()
1375
+ const onClientGone = (): void => {
1376
+ if (!res.writableEnded) hangUp.abort()
1377
+ }
1378
+ res.on('close', onClientGone)
1379
+ // The counting pump: the client's chunks are re-emitted into the
1380
+ // synthetic body verbatim, counted on the way through, with real
1381
+ // backpressure (the socket pauses whenever the host handler is not
1382
+ // pulling). Past the cap the pump errors — the host's reader throws
1383
+ // out of `requestBodyChunks` and its upload dies mid-write — and the
1384
+ // answer below is the relay's own 413 (written before the tail drain),
1385
+ // never the host's internal failure envelope. `tooLarge` also detaches
1386
+ // the pump from the socket: the tail belongs to drainUpload's bound,
1387
+ // which continues from the pump's own count.
1388
+ let received = 0
1389
+ let tooLarge = false
1390
+ const body = new ReadableStream<Uint8Array>({
1391
+ start: (controller) => {
1392
+ req.pause()
1393
+ req.on('data', (chunk: Buffer) => {
1394
+ if (tooLarge) return
1395
+ received += chunk.byteLength
1396
+ if (received > uploadCapBytes) {
1397
+ tooLarge = true
1398
+ req.pause()
1399
+ hangUp.abort()
1400
+ try {
1401
+ controller.error(new Error('payload-too-large'))
1402
+ } catch {
1403
+ // The host consumer may have cancelled first.
1404
+ }
1405
+ return
1406
+ }
1407
+ try {
1408
+ controller.enqueue(new Uint8Array(chunk))
1409
+ } catch {
1410
+ req.destroy()
1411
+ return
1412
+ }
1413
+ if (controller.desiredSize !== null && controller.desiredSize <= 0) req.pause()
1414
+ })
1415
+ req.on('end', () => {
1416
+ if (tooLarge) return
1417
+ try {
1418
+ controller.close()
1419
+ } catch {
1420
+ // Already errored by the consumer's cancel.
1421
+ }
1422
+ })
1423
+ req.on('error', () => {
1424
+ if (tooLarge) return
1425
+ try {
1426
+ controller.error(new Error('upload stream failed'))
1427
+ } catch {
1428
+ // Already errored.
1429
+ }
1430
+ })
1431
+ req.on('close', () => {
1432
+ if (tooLarge) return
1433
+ try {
1434
+ controller.close()
1435
+ } catch {
1436
+ // A hang-up destroy after a clean end — nothing to close.
1437
+ }
1438
+ })
1439
+ },
1440
+ pull: () => {
1441
+ req.resume()
1442
+ },
1443
+ cancel: () => {
1444
+ // The host consumer stopped reading (its own error path): stop
1445
+ // pulling from the client too — the drain below finishes the wire.
1446
+ req.pause()
1447
+ },
1448
+ })
1449
+ let request: Request
1450
+ try {
1451
+ // The URL is composed from the DECISION's normalized query alone,
1452
+ // and the content type is the one fact the host route mandates
1453
+ // (RT dsh-client-file-upload lib/index.js:18) — set here, never
1454
+ // copied from the wire.
1455
+ const init: RequestInit = {
1456
+ method: 'POST',
1457
+ headers: { 'content-type': 'application/octet-stream' },
1458
+ body,
1459
+ signal: hangUp.signal,
1460
+ }
1461
+ // `duplex: 'half'` is mandatory for a streaming request body; the
1462
+ // resolved RequestInit type hides the field (the DOM-vs-undici
1463
+ // conditional in @types/node), so the assignment carries its own
1464
+ // cast.
1465
+ ;(init as { duplex?: string }).duplex = 'half'
1466
+ request = new Request(`http://relay.local${UPLOAD_HTTP_PATH}?${decision.query}`, init)
1467
+ } catch {
1468
+ responseJson(res, 502, { ok: false, error: { code: 'internal' } })
1469
+ return
1470
+ }
1471
+ // Construction succeeded — from here every exit runs the finally, so
1472
+ // the slot is safe to take (T5x).
1473
+ uploadsByDevice.set(device, (uploadsByDevice.get(device) ?? 0) + 1)
1474
+ try {
1475
+ const upstream = await dispatch(request)
1476
+ // The host's upload route answers buffered JSON in every branch (RT
1477
+ // dsh-client-file-upload lib/index.js:49-55), so the receipt reads
1478
+ // out in full. The status + content type + body ride back VERBATIM
1479
+ // inside the success envelope — the same wrapper the
1480
+ // `relay/v1/http` route uses — so a 200-with-failure-envelope
1481
+ // business answer keeps its code AND its structured details on this
1482
+ // wire, and the sub-client reconstructs the exact Response its UI
1483
+ // would have seen locally.
1484
+ const bodyText = await upstream.text()
1485
+ if (tooLarge) {
1486
+ res.setHeader('Connection', 'close')
1487
+ responseJson(res, 413, PAYLOAD_TOO_LARGE)
1488
+ await drainUpload(req, received, drainDeadlineMs)
1489
+ return
1490
+ }
1491
+ const contentType = upstream.headers.get('content-type') ?? undefined
1492
+ responseJson(res, 200, {
1493
+ ok: true,
1494
+ value: { status: upstream.status, ...(contentType !== undefined ? { contentType } : {}), body: bodyText },
1495
+ })
1496
+ } catch {
1497
+ // A dispatch failure is this server's fault (or the hang-up's), not
1498
+ // a business answer — errorOf keeps any message off the wire. The
1499
+ // cap verdict outranks it: the abort the pump sent INTO the dispatch
1500
+ // surfaces here as a throw, and the client still gets its 413
1501
+ // (before the tail drain, like every oversize answer).
1502
+ if (tooLarge) {
1503
+ res.setHeader('Connection', 'close')
1504
+ responseJson(res, 413, PAYLOAD_TOO_LARGE)
1505
+ await drainUpload(req, received, drainDeadlineMs)
1506
+ return
1507
+ }
1508
+ responseJson(res, 502, { ok: false, error: { code: 'internal' } })
1509
+ } finally {
1510
+ res.off('close', onClientGone)
1511
+ // The upload is over — answered, refused mid-flight, or its client
1512
+ // gone: the budget slot goes back (T51-fix).
1513
+ const remaining = (uploadsByDevice.get(device) ?? 1) - 1
1514
+ if (remaining <= 0) uploadsByDevice.delete(device)
1515
+ else uploadsByDevice.set(device, remaining)
1516
+ }
1517
+ return
1518
+ }
1519
+
1520
+ if (req.method === 'POST' && pathname === `${RELAY_PREFIX}/v1/stream`) {
1521
+ // Body validation identical to invoke: one JSON object naming the
1522
+ // method and carrying object args.
1523
+ const body = await readBodyOrRespond(req, res)
1524
+ if (body === undefined) return
1525
+ const namespace = body.namespace
1526
+ const method = body.method
1527
+ const args = body.args
1528
+ if (
1529
+ typeof namespace !== 'string' ||
1530
+ namespace === '' ||
1531
+ typeof method !== 'string' ||
1532
+ method === '' ||
1533
+ args === null ||
1534
+ typeof args !== 'object' ||
1535
+ Array.isArray(args)
1536
+ ) {
1537
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request' } })
1538
+ return
1539
+ }
1540
+ const decision = decideStream(namespace, method, args, isAccessible)
1541
+ if (!decision.allow) {
1542
+ // Refusals are plain JSON: the NDJSON protocol only starts once the
1543
+ // stream is allowed, so a 403 here is the same shape invoke answers.
1544
+ responseJson(res, 403, { ok: false, error: { code: decision.reason } })
1545
+ return
1546
+ }
1547
+ const device = headerValue(req, 'x-zen-remote-device') ?? ''
1548
+ if ((streamsByDevice.get(device) ?? 0) >= MAX_STREAMS_PER_DEVICE) {
1549
+ responseJson(res, 429, { ok: false, error: { code: 'too-many-streams' } })
1550
+ return
1551
+ }
1552
+ streamsByDevice.set(device, (streamsByDevice.get(device) ?? 0) + 1)
1553
+ try {
1554
+ // `job/follow` proves job ownership before anything is streamed (4b) —
1555
+ // same check, same refusal as `job/kill` on the invoke route.
1556
+ if (namespace === 'job' && method === 'follow') {
1557
+ const request = isPlainObject(args) ? args.request : undefined
1558
+ const jobId =
1559
+ isPlainObject(request) && typeof request.jobId === 'string' && request.jobId !== ''
1560
+ ? request.jobId
1561
+ : undefined
1562
+ const sessionId = decision.sessionIds[0] ?? ''
1563
+ if (jobId === undefined || sessionId === '' || !(await ownsJob(sessionId, jobId, hangUpOf(res)))) {
1564
+ responseJson(res, 403, { ok: false, error: { code: 'forbidden' } })
1565
+ return
1566
+ }
1567
+ }
1568
+
1569
+ // Headers that keep every intermediary from buffering the line
1570
+ // stream: DSH's web server runs a compression middleware that would
1571
+ // hold the response until it is complete (it skips responses marked
1572
+ // `no-transform`), and nginx keeps buffering unless told otherwise.
1573
+ // Flushed immediately, so the client sees 200 + content type before
1574
+ // the first upstream frame exists.
1575
+ res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8')
1576
+ res.setHeader('Cache-Control', 'no-store, no-transform')
1577
+ res.setHeader('X-Accel-Buffering', 'no')
1578
+ res.setHeader('X-Content-Type-Options', 'nosniff')
1579
+ res.flushHeaders()
1580
+
1581
+ let clientGone = false
1582
+ let finished = false
1583
+ const hangUp = new AbortController()
1584
+ const onClientGone = (): void => {
1585
+ if (!res.writableEnded) {
1586
+ clientGone = true
1587
+ hangUp.abort()
1588
+ }
1589
+ }
1590
+ // 'error' rides along because a write racing a destroyed socket
1591
+ // surfaces there first; the reaction is the same as a hang-up.
1592
+ res.on('close', onClientGone)
1593
+ res.on('error', onClientGone)
1594
+
1595
+ const heartbeat = setInterval(() => {
1596
+ if (!finished && !clientGone) {
1597
+ // T59: a PER-STREAM ping carrying the names as of NOW. The
1598
+ // server's display name is re-read live per tick (a host rename
1599
+ // reaches every connected client within one heartbeat), and the
1600
+ // device name comes from the learned cache (updated by that
1601
+ // device's every request) — the old shared PING_LINE constant
1602
+ // could carry neither.
1603
+ const deviceName = deviceNames.get(device)
1604
+ const line = JSON.stringify({
1605
+ type: 'ping',
1606
+ serverName: currentServerName(),
1607
+ ...(deviceName !== undefined ? { deviceName } : {}),
1608
+ })
1609
+ res.write(Buffer.from(`${line}\n`, 'utf8'))
1610
+ }
1611
+ }, heartbeatMs)
1612
+ // The host process must never be kept alive by a heartbeat alone.
1613
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
1614
+
1615
+ /**
1616
+ * One NDJSON line, honoring backpressure: when the socket buffer is
1617
+ * full the write returns false and the caller waits for `drain`
1618
+ * before the next write runs. A line racing a disconnect resolves
1619
+ * without writing (the 'close' leg of the wait). `tail` exempts the
1620
+ * line closeWith is writing from its own finished gate — without it
1621
+ * the end/error line would silence itself.
1622
+ *
1623
+ * Every write goes through ONE promise chain (T22b-fix): share-change
1624
+ * frames are enqueued behind whatever the pump is draining instead of
1625
+ * `void`-ing into the socket buffer in parallel, so a burst of
1626
+ * synthesized frames neither interleaves the pump's write/drain
1627
+ * cycles nor grows the buffer unboundedly.
1628
+ */
1629
+ let writeChain: Promise<void> = Promise.resolve()
1630
+ const writeLine = (value: unknown, tail = false): Promise<void> => {
1631
+ const run = writeChain.then(() => {
1632
+ if ((!tail && finished) || clientGone || res.destroyed || res.writableEnded) return
1633
+ return new Promise<void>((resolve) => {
1634
+ if (res.write(Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'))) {
1635
+ resolve()
1636
+ return
1637
+ }
1638
+ let settle = (): void => {}
1639
+ const onDrain = (): void => settle()
1640
+ const onClose = (): void => settle()
1641
+ settle = (): void => {
1642
+ res.off('drain', onDrain)
1643
+ res.off('close', onClose)
1644
+ resolve()
1645
+ }
1646
+ res.on('drain', onDrain)
1647
+ res.on('close', onClose)
1648
+ })
1649
+ })
1650
+ writeChain = run.then(
1651
+ () => undefined,
1652
+ () => undefined,
1653
+ )
1654
+ return run
1655
+ }
1656
+
1657
+ /**
1658
+ * The one transition into "response over": idempotent, clears the
1659
+ * heartbeat, writes the optional tail line, then ends the response.
1660
+ *
1661
+ * T43-A/fix: a client that stopped reading must not pin the stream —
1662
+ * with it the viewer count and the device budget — forever. The
1663
+ * watchdog starts UNCONDITIONALLY and owns the whole finish: the
1664
+ * response either FINISHES (tail line and end-of-body handed to the
1665
+ * socket — the `finish` event disarms the timer) or is gone
1666
+ * (`close`), or it is destroyed after `endDrainTimeoutMs`. No
1667
+ * branching on `writableNeedDrain`: a write that returned true can
1668
+ * still be sitting in front of a full kernel buffer, and `finish`
1669
+ * is the only honest "it drained" signal.
1670
+ */
1671
+ const closeWith = (line: Record<string, unknown> | null): void => {
1672
+ if (finished) return
1673
+ finished = true
1674
+ clearInterval(heartbeat)
1675
+ let watchdog: ReturnType<typeof setTimeout> | undefined
1676
+ const stopWatchdog = (): void => {
1677
+ res.off('finish', onSettled)
1678
+ res.off('close', onSettled)
1679
+ if (watchdog !== undefined) {
1680
+ clearTimeout(watchdog)
1681
+ watchdog = undefined
1682
+ }
1683
+ }
1684
+ const onSettled = (): void => {
1685
+ stopWatchdog()
1686
+ }
1687
+ if (!clientGone && !res.destroyed && !res.writableEnded) {
1688
+ res.once('finish', onSettled)
1689
+ res.once('close', onSettled)
1690
+ watchdog = setTimeout(() => {
1691
+ watchdog = undefined
1692
+ res.off('finish', onSettled)
1693
+ res.off('close', onSettled)
1694
+ // The tail is somewhere in the buffers but the client never
1695
+ // reads: destroying is the only way this response (and its
1696
+ // counters) ever finishes.
1697
+ try {
1698
+ res.destroy()
1699
+ } catch {
1700
+ // Already gone.
1701
+ }
1702
+ }, endDrainTimeoutMs)
1703
+ // The host process must never be kept alive by a finish alone.
1704
+ if (typeof watchdog.unref === 'function') watchdog.unref()
1705
+ }
1706
+ const tailLine = line === null ? Promise.resolve() : writeLine(line, true)
1707
+ void tailLine.then(() => {
1708
+ if (!clientGone && !res.destroyed && !res.writableEnded) res.end()
1709
+ })
1710
+ }
1711
+
1712
+ // ---- share-change synchronization (T22b §4) --------------------
1713
+
1714
+ // The `$zr/events` forwarding bookkeeping (T32): registered for the
1715
+ // answer route the moment this stream starts being served, removed
1716
+ // in the pump's finally. The token is baked into every forwarded
1717
+ // eventId and binds the answers to THIS subscription; the device
1718
+ // binds them to THIS connection's pairing.
1719
+ const eventsSub: EventsSubscription | undefined =
1720
+ decision.events === true
1721
+ ? { device, token: randomBytes(8).toString('hex'), clientId: undefined, events: new Map() }
1722
+ : undefined
1723
+ if (eventsSub !== undefined) liveEventSubscriptions.add(eventsSub)
1724
+
1725
+ let unsubscribe = (): void => {}
1726
+ let applyToState: ((frame: unknown) => void) | undefined
1727
+
1728
+ if (decision.filter === 'workspace') {
1729
+ // Keep the UNFILTERED latest workspace state so share changes can
1730
+ // synthesize exactly the frames the client is now allowed to see.
1731
+ const state = createWorkspaceFollowState()
1732
+ applyToState = (frame) => state.apply(frame)
1733
+ unsubscribe = store.subscribe((event) => {
1734
+ if (event.type !== 'shared' && event.type !== 'unshared') return
1735
+ if (finished || clientGone) return
1736
+ for (const frame of state.onShareChange(event.sessionId, isAccessible)) {
1737
+ void writeLine({ type: 'frame', frame })
1738
+ }
1739
+ })
1740
+ } else if (decision.filter === 'control') {
1741
+ // A newly shared session needs its current projections: fetch
1742
+ // them once and emit one synthesized `projection` frame per key,
1743
+ // shaped exactly like the upstream frames. An unshared session
1744
+ // synthesizes nothing — the client drops it via the workspace
1745
+ // stream's re-filtered upserts.
1746
+ const syncProjections = async (sessionId: string): Promise<void> => {
1747
+ let result: unknown
1748
+ try {
1749
+ result = await gateway.invoke({
1750
+ namespace: 'session',
1751
+ method: 'projections',
1752
+ args: { request: { sessionId } },
1753
+ signal: hangUp.signal,
1754
+ })
1755
+ } catch {
1756
+ return // projections unavailable — the next live projection frame will flow anyway
1757
+ }
1758
+ if (!isPlainObject(result) || finished || clientGone || !isAccessible(sessionId)) return
1759
+ const values = isPlainObject(result.values) ? result.values : {}
1760
+ const seq = typeof result.asOfSeq === 'number' ? result.asOfSeq : 0
1761
+ for (const key of Object.keys(values)) {
1762
+ void writeLine({ type: 'frame', frame: { type: 'projection', sessionId, key, value: values[key], seq } })
1763
+ }
1764
+ }
1765
+ unsubscribe = store.subscribe((event) => {
1766
+ if (event.type !== 'shared' || finished || clientGone) return
1767
+ void syncProjections(event.sessionId)
1768
+ })
1769
+ } else if (eventsSub !== undefined) {
1770
+ // `$zr/events` (T32-fix): the share table closing a session must
1771
+ // also close the prompts this subscription is showing for it —
1772
+ // synthesize the same cancel frame the gateway would have sent
1773
+ // and drop the registry entry, so the sub-client closes the
1774
+ // prompt and a late answer refuses unknown-event (which the
1775
+ // client turns into the same silent ok DSH gives a stale
1776
+ // result). The relay's OWN delivery is deliberately NOT abstained
1777
+ // away (T32-fix2, see filterEventsFrame's DROPPED DELIVERIES): it
1778
+ // stays pending at the gateway, still answerable by the server
1779
+ // UI or a future subscriber. Events of still-reachable sessions
1780
+ // are untouched; nothing is synthesized on a share (the known
1781
+ // limitation in filterEventsFrame's comment).
1782
+ unsubscribe = store.subscribe((event) => {
1783
+ if (event.type !== 'unshared' || finished || clientGone) return
1784
+ for (const [eventId, agentId] of [...eventsSub.events]) {
1785
+ if (isAccessible(agentId)) continue
1786
+ eventsSub.events.delete(eventId)
1787
+ void writeLine({
1788
+ type: 'frame',
1789
+ frame: { type: 'cancel', eventId: `${eventsSub.token}.${eventId}` },
1790
+ })
1791
+ }
1792
+ })
1793
+ } else {
1794
+ // Session-scoped stream: count its viewers, and die loudly when
1795
+ // a dependency stops being shared — silence would leave the
1796
+ // client reading a session it can no longer reach.
1797
+ for (const id of decision.sessionIds) viewers.set(id, (viewers.get(id) ?? 0) + 1)
1798
+ unsubscribe = store.subscribe((event) => {
1799
+ if (event.type !== 'unshared' || finished || clientGone) return
1800
+ if (decision.sessionIds.every((id) => isAccessible(id))) return
1801
+ closeWith({
1802
+ type: 'error',
1803
+ error: {
1804
+ code: 'unshared',
1805
+ message: `shared session ${event.sessionId} was unshared (${event.reason})`,
1806
+ // T34: the structured close reason beside the message string —
1807
+ // the sub-client's closed-session display keys on this field.
1808
+ reason: event.reason,
1809
+ },
1810
+ })
1811
+ hangUp.abort()
1812
+ })
1813
+ }
1814
+
1815
+ const filterFrame: (frame: unknown) => unknown =
1816
+ eventsSub !== undefined
1817
+ ? (frame) => filterEventsFrame(eventsSub, frame)
1818
+ : decision.filter === 'workspace'
1819
+ ? (frame) => filterWorkspaceFrame(frame, isAccessible)
1820
+ : decision.filter === 'control'
1821
+ ? (frame) => filterControlFrame(frame, isAccessible)
1822
+ : namespace === 'job' && method === 'list'
1823
+ // 4b: ownerless and foreign jobs stay server-side.
1824
+ ? (frame) => filterJobListFrame(frame, ownerSessionIdOf(args))
1825
+ : (frame) => frame
1826
+
1827
+ // closeAll's kill switch for THIS stream: idempotent, safe to call on
1828
+ // an already-finished stream. Registered only while the response is
1829
+ // being served; the pump's finally unregisters it.
1830
+ const killThisStream = (): void => {
1831
+ hangUp.abort()
1832
+ const reason = serverClosedReason
1833
+ closeWith(
1834
+ reason === null || reason === ''
1835
+ ? { type: 'error', error: { code: 'server-restart' } }
1836
+ : { type: 'error', error: { code: 'server-restart', message: reason } },
1837
+ )
1838
+ }
1839
+ openStreamKills.add(killThisStream)
1840
+ // Registered beside the kill switch: closeAll detaches the stream's
1841
+ // share-table listener synchronously, before the pump's own teardown
1842
+ // gets a turn (T43-B).
1843
+ tableUnsubscribers.add(unsubscribe)
1844
+
1845
+ // Last gate before anything is opened (T22b-fix): both checks cover
1846
+ // the window the ownership probe (or any earlier await) opened — an
1847
+ // unshare or a closeAll during that window fires no event this stream
1848
+ // could have observed, so the CURRENT state decides.
1849
+ const firstInaccessible = decision.sessionIds.find((id) => !isAccessible(id))
1850
+ const restartReason = serverClosedReason
1851
+
1852
+ try {
1853
+ if (restartReason !== null) {
1854
+ // The handler was closed while this request was still deciding —
1855
+ // nothing may open anymore.
1856
+ killThisStream()
1857
+ } else if (firstInaccessible !== undefined) {
1858
+ hangUp.abort()
1859
+ closeWith({
1860
+ type: 'error',
1861
+ // No share-table event was observed on this stream (the session
1862
+ // was already unshared while the request was deciding — e.g.
1863
+ // inside the job ownership probe): the close reason degrades to
1864
+ // the manual close, the only fact that is certain here.
1865
+ error: { code: 'unshared', reason: 'manual', message: `shared session ${firstInaccessible} is no longer shared` },
1866
+ })
1867
+ } else {
1868
+ // `$zr/events` opens through the wire adapter (the ONLY door to
1869
+ // the forwarded-event stream); every other route is a typert
1870
+ // stream method.
1871
+ const iterable =
1872
+ eventsSub !== undefined
1873
+ ? await openEventsStream(hangUp.signal)
1874
+ : await gateway.stream({ namespace, method, args, signal: hangUp.signal })
1875
+ for await (const frame of iterable) {
1876
+ if (finished || clientGone) break
1877
+ if (namespace === 'job' && method === 'list') rememberJobs(ownerSessionIdOf(args), frame)
1878
+ applyToState?.(frame)
1879
+ const filtered = filterFrame(frame)
1880
+ if (filtered === null) continue
1881
+ await writeLine({ type: 'frame', frame: filtered })
1882
+ }
1883
+ closeWith({ type: 'end' })
1884
+ }
1885
+ } catch (error) {
1886
+ if (hangUp.signal.aborted || clientGone) {
1887
+ // The abort a hang-up (or an unshare kill) sends into the
1888
+ // iterator surfaces as gateway/cancelled — that is the stream's
1889
+ // DOCUMENTED normal end, swallowed whole.
1890
+ closeWith(null)
1891
+ } else {
1892
+ const { code, message } = errorOf(error)
1893
+ closeWith(message === undefined ? { type: 'error', error: { code } } : { type: 'error', error: { code, message } })
1894
+ }
1895
+ } finally {
1896
+ openStreamKills.delete(killThisStream)
1897
+ clearInterval(heartbeat)
1898
+ unsubscribe()
1899
+ tableUnsubscribers.delete(unsubscribe)
1900
+ if (eventsSub !== undefined) {
1901
+ liveEventSubscriptions.delete(eventsSub)
1902
+ eventsSub.events.clear()
1903
+ }
1904
+ res.off('close', onClientGone)
1905
+ res.off('error', onClientGone)
1906
+ if (decision.filter === undefined) {
1907
+ for (const id of decision.sessionIds) {
1908
+ const remaining = (viewers.get(id) ?? 1) - 1
1909
+ if (remaining <= 0) viewers.delete(id)
1910
+ else viewers.set(id, remaining)
1911
+ }
1912
+ }
1913
+ }
1914
+ } finally {
1915
+ const remaining = (streamsByDevice.get(device) ?? 1) - 1
1916
+ if (remaining <= 0) streamsByDevice.delete(device)
1917
+ else streamsByDevice.set(device, remaining)
1918
+ }
1919
+ return
1920
+ }
1921
+
1922
+ responseJson(res, 404, { ok: false, error: { code: 'not-found' } })
1923
+ }
1924
+
1925
+ handle.viewerCount = (sessionId: string): number => viewers.get(sessionId) ?? 0
1926
+ handle.closeAll = (reason: string): void => {
1927
+ // Detach EVERY share-table listener first (T43-B): after this point the
1928
+ // dead handler must not be reached by any share/unshare — not even by a
1929
+ // stream whose pump is parked on a full socket buffer and whose own
1930
+ // teardown still awaits the 'close' the kills below cause. Calling an
1931
+ // unsubscribe twice is a no-op, so the pumps' finallys stay honest.
1932
+ for (const detach of [...tableUnsubscribers]) detach()
1933
+ tableUnsubscribers.clear()
1934
+ // The event-forwarding registries go with them (T32): after closeAll no
1935
+ // answer may still compose a payload for a dead handler's clientId —
1936
+ // the streams' own finallys would clear theirs, but only after their
1937
+ // teardown unwinds, and an answer could slip into that window.
1938
+ for (const sub of liveEventSubscriptions) {
1939
+ sub.clientId = undefined
1940
+ sub.events.clear()
1941
+ }
1942
+ liveEventSubscriptions.clear()
1943
+ // Remember the reason for requests still inside their ownership probe —
1944
+ // they read it at the pre-open gate. Killing the open streams runs their
1945
+ // normal teardown (error line, res.end, abort), whose finally releases
1946
+ // the viewer/device counters and the share-table listeners.
1947
+ serverClosedReason = typeof reason === 'string' ? reason : ''
1948
+ for (const kill of [...openStreamKills]) kill()
1949
+ openStreamKills.clear()
1950
+ }
1951
+ return handle
1952
+ }
1953
+
1954
+ /** The claimed session of a `job/list` subscription — decideStream has
1955
+ * already verified it is a non-empty shared id; anything else is '' and the
1956
+ * job filter then keeps nothing. */
1957
+ function ownerSessionIdOf(args: unknown): string {
1958
+ const request = isPlainObject(args) ? args.request : undefined
1959
+ return isPlainObject(request) && typeof request.sessionId === 'string' ? request.sessionId : ''
1960
+ }
1961
+
1962
+ // The `dsh-session:` codec and the shared scan rule live in ONE module the
1963
+ // sub-client's interceptor imports too (session-reference.ts, T41a-fix2) —
1964
+ // re-exported here so the server's public surface keeps carrying them.
1965
+ export {
1966
+ SESSION_REFERENCE_URI,
1967
+ collectReferenceTexts,
1968
+ decodeSessionReferenceUri,
1969
+ encodeSessionReferenceUri,
1970
+ mapReferenceTexts,
1971
+ } from './session-reference.js'
1972
+
1973
+ /**
1974
+ * Every session id one call's injectable texts reference through canonical
1975
+ * `dsh-session:` addresses. WHERE those texts live is the shared scan rule
1976
+ * (session-reference.ts: prompt content, a queue EDIT's replacement content,
1977
+ * every string of a commands/execute call). Non-canonical candidates are
1978
+ * skipped: DSH answers them with its own business error, and the relay must
1979
+ * not crash on them.
1980
+ */
1981
+ function referencedSessionIds(namespace: string, method: string, args: unknown): string[] {
1982
+ const ids: string[] = []
1983
+ for (const text of collectReferenceTexts(namespace, method, args)) {
1984
+ for (const match of text.matchAll(SESSION_REFERENCE_URI)) {
1985
+ const id = decodeSessionReferenceUri(match[1] ?? match[2])
1986
+ if (id !== undefined) ids.push(id)
1987
+ }
1988
+ }
1989
+ return ids
1990
+ }
1991
+
1992
+ /**
1993
+ * Filter one `sessionReferenceResolver/candidates` result: keep only rows
1994
+ * whose `sessionId` passes the share table. The host lists EVERY server
1995
+ * session with title, cwd and a ready-made mention, so an unfiltered row
1996
+ * leaks metadata and hands the client a one-keystroke path into an unshared
1997
+ * session's content. Rows that cannot locate a session (malformed) are
1998
+ * dropped — nothing untrusted travels. A non-array result (a shape this
1999
+ * build does not know — a newer host's evolution) yields `[]`: 宁可不给,
2000
+ * never an unfiltered answer.
2001
+ *
2002
+ * Known limitation: the host caps a candidates page at `candidateLimit`
2003
+ * rows (default 50, RT dsh-session-reference config) BEFORE this filter
2004
+ * runs — on a server with many sessions the accessible rows past the cap
2005
+ * are cut off with the inaccessible ones, and the client just sees fewer
2006
+ * @ candidates. The @ resolver stays a convenience surface; the prompt-text
2007
+ * scan below is the access boundary, and it is not affected.
2008
+ */
2009
+ function filterAccessibleCandidateRows(value: unknown, isAccessible: (sessionId: string) => boolean): unknown {
2010
+ if (!Array.isArray(value)) return []
2011
+ return value.filter((row) => isPlainObject(row) && typeof row.sessionId === 'string' && isAccessible(row.sessionId))
2012
+ }
2013
+
2014
+ /**
2015
+ * A throwaway abort signal for checks that run before the stream's own
2016
+ * controller exists (the job ownership probe). Tied to the response: once the
2017
+ * client is gone there is no point fetching anything for it.
2018
+ */
2019
+ function hangUpOf(res: ServerResponse): AbortSignal {
2020
+ const controller = new AbortController()
2021
+ res.once('close', () => {
2022
+ if (!res.writableEnded) controller.abort()
2023
+ })
2024
+ return controller.signal
2025
+ }