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.
- package/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- 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
|
+
}
|