@tapflowio/relay 0.18.0 → 0.20.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/README.md +1 -1
- package/dist/RelayServer.d.ts +299 -2
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +1050 -177
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +83 -17
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +111 -14
- package/dist/SessionManager.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/cert/index.d.ts +1 -1
- package/dist/lib/cert/index.d.ts.map +1 -1
- package/dist/lib/cert/index.js +1 -1
- package/dist/lib/cert/index.js.map +1 -1
- package/dist/lib/cert/parseCert.d.ts +4 -0
- package/dist/lib/cert/parseCert.d.ts.map +1 -1
- package/dist/lib/cert/parseCert.js +68 -0
- package/dist/lib/cert/parseCert.js.map +1 -1
- package/dist/lib/trailingRequester.d.ts +23 -0
- package/dist/lib/trailingRequester.d.ts.map +1 -0
- package/dist/lib/trailingRequester.js +56 -0
- package/dist/lib/trailingRequester.js.map +1 -0
- package/dist/server.js +4 -2
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +2 -38
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/public/assets/{AppCenter-B3FO67aw.js → AppCenter-Dg4dscW-.js} +6 -6
- package/public/assets/AppCenter-Dg4dscW-.js.br +0 -0
- package/public/assets/Default-Dxi8aZ1t.js +1 -0
- package/public/assets/Default-Dxi8aZ1t.js.br +0 -0
- package/public/assets/Invite-0EwoClX6.js +1 -0
- package/public/assets/Invite-0EwoClX6.js.br +0 -0
- package/public/assets/MacResources-Dy1M3EqK.js +1 -0
- package/public/assets/MacResources-Dy1M3EqK.js.br +0 -0
- package/public/assets/NotFound-BoDDivK5.js +1 -0
- package/public/assets/NotFound-BoDDivK5.js.br +0 -0
- package/public/assets/QASession-DqMj8JL1.js +140 -0
- package/public/assets/QASession-DqMj8JL1.js.br +0 -0
- package/public/assets/ResetPassword-CXU2bRry.js +1 -0
- package/public/assets/ResetPassword-CXU2bRry.js.br +0 -0
- package/public/assets/Setup-XXChRxeL.js +1 -0
- package/public/assets/Setup-XXChRxeL.js.br +0 -0
- package/public/assets/{Team-CBsjuUwh.js → Team-D0bXpmEY.js} +3 -3
- package/public/assets/Team-D0bXpmEY.js.br +0 -0
- package/public/assets/Tokens-D0YNHS5e.js +1 -0
- package/public/assets/Tokens-D0YNHS5e.js.br +0 -0
- package/public/assets/alert-dialog-CgatMXjB.js +1 -0
- package/public/assets/alert-dialog-CgatMXjB.js.br +0 -0
- package/public/assets/badge-B6Nf3A0Y.js +1 -0
- package/public/assets/badge-B6Nf3A0Y.js.br +0 -0
- package/public/assets/{build-format-B78hbKY9.js → build-format-BFC8gLoh.js} +5 -5
- package/public/assets/build-format-BFC8gLoh.js.br +0 -0
- package/public/assets/{dialog-BxC9v2BL.js → dialog-DK9a3b4z.js} +5 -5
- package/public/assets/dialog-DK9a3b4z.js.br +0 -0
- package/public/assets/index-9vdYIm7s.js +210 -0
- package/public/assets/index-9vdYIm7s.js.br +0 -0
- package/public/assets/index-ClHUQm-F.css +1 -0
- package/public/assets/index-ClHUQm-F.css.br +0 -0
- package/public/assets/{pencil-Cq64I9LV.js → pencil-BeLNLEKe.js} +2 -2
- package/public/assets/pencil-BeLNLEKe.js.br +0 -0
- package/public/assets/table-DW57-o1p.js +1 -0
- package/public/assets/table-DW57-o1p.js.br +0 -0
- package/public/assets/tabs-Xlttuqxh.js +1 -0
- package/public/assets/tabs-Xlttuqxh.js.br +0 -0
- package/public/assets/{trash-2-CszwJHEh.js → trash-2-CGxISX8x.js} +3 -3
- package/public/assets/trash-2-CGxISX8x.js.br +0 -0
- package/public/assets/vendor-charts-DyX5CBcf.js +5 -0
- package/public/assets/vendor-charts-DyX5CBcf.js.br +0 -0
- package/public/assets/vendor-react-AJpf4YZ0.js +49 -0
- package/public/assets/vendor-react-AJpf4YZ0.js.br +0 -0
- package/public/assets/vendor-router-ChFy8CZ1.js +12 -0
- package/public/assets/vendor-router-ChFy8CZ1.js.br +0 -0
- package/public/index.html +4 -2
- package/public/index.html.br +0 -0
- package/public/assets/AppCenter-B3FO67aw.js.br +0 -0
- package/public/assets/Default-BlNUv5dw.js +0 -1
- package/public/assets/Default-BlNUv5dw.js.br +0 -0
- package/public/assets/Invite-CKYFmy7S.js +0 -1
- package/public/assets/Invite-CKYFmy7S.js.br +0 -0
- package/public/assets/MacResources-BK4VQnFc.js +0 -5
- package/public/assets/MacResources-BK4VQnFc.js.br +0 -0
- package/public/assets/NotFound-BMmi1paB.js +0 -1
- package/public/assets/NotFound-BMmi1paB.js.br +0 -0
- package/public/assets/QASession-CVTlqW3d.js +0 -125
- package/public/assets/QASession-CVTlqW3d.js.br +0 -0
- package/public/assets/ResetPassword-VPPBWzSI.js +0 -1
- package/public/assets/ResetPassword-VPPBWzSI.js.br +0 -0
- package/public/assets/Setup-CRdnCl41.js +0 -1
- package/public/assets/Setup-CRdnCl41.js.br +0 -0
- package/public/assets/Team-CBsjuUwh.js.br +0 -0
- package/public/assets/Tokens-CU9HB9WD.js +0 -1
- package/public/assets/Tokens-CU9HB9WD.js.br +0 -0
- package/public/assets/alert-dialog-DWqGDQTB.js +0 -1
- package/public/assets/alert-dialog-DWqGDQTB.js.br +0 -0
- package/public/assets/badge-OToVq1dK.js +0 -1
- package/public/assets/badge-OToVq1dK.js.br +0 -0
- package/public/assets/build-format-B78hbKY9.js.br +0 -3
- package/public/assets/dialog-BxC9v2BL.js.br +0 -0
- package/public/assets/index-DXjMx9hQ.js +0 -274
- package/public/assets/index-DXjMx9hQ.js.br +0 -0
- package/public/assets/index-uGJGo6ac.css +0 -1
- package/public/assets/index-uGJGo6ac.css.br +0 -0
- package/public/assets/pencil-Cq64I9LV.js.br +0 -0
- package/public/assets/table-CbATN5Pi.js +0 -1
- package/public/assets/table-CbATN5Pi.js.br +0 -0
- package/public/assets/tabs-BVumgDA4.js +0 -1
- package/public/assets/tabs-BVumgDA4.js.br +0 -0
- package/public/assets/trash-2-CszwJHEh.js.br +0 -0
package/dist/RelayServer.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'path';
|
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
7
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
8
8
|
import { SessionManager } from './SessionManager.js';
|
|
9
|
+
import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
|
|
9
10
|
import { Router, json } from './router.js';
|
|
10
11
|
import { requireViewAuth, requireAuth, getAuth, verifyPat } from './middleware/auth.js';
|
|
11
12
|
import { classifyConnection } from './lib/connectionAuth.js';
|
|
@@ -13,6 +14,7 @@ import { resolveClientAddress } from './lib/clientAddress.js';
|
|
|
13
14
|
import { resolveCorsHeaders } from './lib/cors.js';
|
|
14
15
|
import { isCsrfBlocked } from './lib/csrf.js';
|
|
15
16
|
import { pickLanAddress } from './lib/lanAddress.js';
|
|
17
|
+
import { createTrailingRequester, systemTimerScheduler } from './lib/trailingRequester.js';
|
|
16
18
|
import { getDb } from './db.js';
|
|
17
19
|
import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit, handleAuthStatus } from './api/auth.js';
|
|
18
20
|
import { handleVerify, handleAccept } from './api/invitations.js';
|
|
@@ -38,7 +40,14 @@ export function isExternalAddress(addr) {
|
|
|
38
40
|
}
|
|
39
41
|
// Min gap between IDR requests per session — one IDR resyncs the stream, so avoid
|
|
40
42
|
// spamming the agent in the frames between request and the IDR arriving.
|
|
41
|
-
|
|
43
|
+
/** Exported for the re-join test, which pins both edges of this window rather than hardcoding 500 —
|
|
44
|
+
* a test carrying its own copy of the number passes when the constant moves, which is the drift the
|
|
45
|
+
* wire-contract work keeps finding. Not re-exported from the package index. */
|
|
46
|
+
export const IDR_REQUEST_THROTTLE_MS = 500;
|
|
47
|
+
/** The same window for `network:request-state`, and a **separate constant on purpose**: the two share
|
|
48
|
+
* a number today and not a policy. IDR drops inside the window; this one coalesces onto its trailing
|
|
49
|
+
* edge (see `networkStateRequester`). Sharing the constant would mean tuning one tunes the other. */
|
|
50
|
+
export const NETWORK_STATE_REQUEST_THROTTLE_MS = 500;
|
|
42
51
|
// Ping every socket each interval; a missed pong window (~2× this) terminates the dead socket.
|
|
43
52
|
const HEARTBEAT_MS = 30_000;
|
|
44
53
|
// How long a session outlives its agent's socket, waiting for that agent to come back (#426).
|
|
@@ -46,6 +55,24 @@ const HEARTBEAT_MS = 30_000;
|
|
|
46
55
|
// times over and about half of a hand-typed restart. What bounds it is the other direction: the
|
|
47
56
|
// device stays claimed by a session nobody can use until the window closes.
|
|
48
57
|
const DEFAULT_AGENT_GRACE_MS = 15_000;
|
|
58
|
+
/**
|
|
59
|
+
* What a buffer actually is, from its magic bytes, or `null` for anything else.
|
|
60
|
+
*
|
|
61
|
+
* Duplicated in `mcp-server` rather than shared: `protocol`'s entry must erase under `import type`
|
|
62
|
+
* so it cannot hold a runtime value, and `mcp-server` does not depend on `agent-core`. The same
|
|
63
|
+
* situation as `correlatesWith` across the two clients, and the same answer — each copy has tests.
|
|
64
|
+
* These two signatures have not changed since 1992 and 1996, so there is little for them to drift to.
|
|
65
|
+
*/
|
|
66
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
67
|
+
function sniffImageFormat(buf) {
|
|
68
|
+
// All eight signature bytes — see the note on the `mcp-server` copy for why the trailing four earn
|
|
69
|
+
// their place. Here a false `png` would report a mismatch that is not one.
|
|
70
|
+
if (buf.length >= PNG_SIGNATURE.length && PNG_SIGNATURE.every((b, i) => buf[i] === b))
|
|
71
|
+
return 'png';
|
|
72
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xd8)
|
|
73
|
+
return 'jpeg';
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
49
76
|
import { handleVerifyReset, handleDoReset, handleSendMemberReset } from './api/passwordReset.js';
|
|
50
77
|
import { handleListBuilds, handleGetBuild, handleUpdateBuild, handleUploadBuild, handleScheduleBuildDeletion, handleCancelBuildDeletion, purgeExpiredBuilds } from './api/builds.js';
|
|
51
78
|
import { handleListApps, handleCreateApp, handleUpdateApp, handleDeleteApp } from './api/apps.js';
|
|
@@ -78,23 +105,51 @@ const RESOURCE_THRESHOLD = Number.isFinite(_parsedThreshold) ? _parsedThreshold
|
|
|
78
105
|
// Terminal input messages the MCP client awaits an ack for — if the agent is offline
|
|
79
106
|
// the relay replies input:error so the client fails truthfully (non-terminal moves/starts
|
|
80
107
|
// expect no ack and are dropped silently).
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
/** Why a session is not this socket's to command. One reason, two prose strings — the treatment `#492`
|
|
109
|
+
* settled for `agent offline` / `Session not found`: telling a caller the session is in use when it is
|
|
110
|
+
* idle steers it off a device it could have had. Shared so the two never drift. */
|
|
111
|
+
function ownershipRefusal(session) {
|
|
112
|
+
return session.browserSocket ? 'session held by another client' : 'session not joined';
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The owner key a connection speaks for, from the authenticated user and the client id it claimed.
|
|
116
|
+
*
|
|
117
|
+
* **Both halves, and a pure function so both can be tested.** The user id is what keeps a leaked client id
|
|
118
|
+
* useless to anyone else — without it this would be the relay's first ownership claim a caller can forge,
|
|
119
|
+
* where socket identity could not be. It comes from the cookie *or* the PAT: reading only the cookie would
|
|
120
|
+
* have made every agent and every non-browser client `anon:`, which is the population most likely to have
|
|
121
|
+
* its handshake URL end up in a reverse proxy's access log. The mint is what removes the fallback branch: a connection that
|
|
122
|
+
* claims nothing gets an identity of its own, which is per-socket, which is the behaviour ownership had
|
|
123
|
+
* before it moved off the socket.
|
|
124
|
+
*
|
|
125
|
+
* Exported for the test that holds the pairing. Inline in the handler it was unreachable without standing
|
|
126
|
+
* up an authenticated handshake, and a mutation dropping the user id survived the whole suite.
|
|
127
|
+
*/
|
|
128
|
+
export function ownerKeyFor(userId, claimed) {
|
|
129
|
+
const user = String(userId ?? 'anon');
|
|
130
|
+
// **Mintedness is a separate field, not a marker inside the key.** A first version prefixed the mint
|
|
131
|
+
// with `minted-` and had `mayShutDown` recover the fact with `includes(':minted-')` — which the client
|
|
132
|
+
// controls, because `claimed` comes straight off the handshake query and is never validated. Connecting
|
|
133
|
+
// as `?client=minted-1` produced `anon:minted-1` and silently disabled the shutdown gate for that
|
|
134
|
+
// holder; `?client=x:minted-y` did it while keeping a real user id, so a *different* user could then
|
|
135
|
+
// power the device off. Encoding a security-relevant fact in a caller-supplied string is the forgery
|
|
136
|
+
// this whole key was introduced to avoid, reintroduced one layer down.
|
|
137
|
+
return { key: `${user}:${claimed || randomUUID()}`, user, minted: !claimed };
|
|
138
|
+
}
|
|
139
|
+
/** One line per rejecting socket per second, at most. See `RelayServer.logInboundRejection`. */
|
|
140
|
+
const REJECT_LOG_INTERVAL_MS = 1_000;
|
|
141
|
+
/** What the door refused, as one line. The throttling that decides whether to write it is the
|
|
142
|
+
* caller's, because it is per socket and this function has no state. */
|
|
143
|
+
function describeRejection(failure, suppressed) {
|
|
144
|
+
if (failure.reason === 'not-an-object')
|
|
145
|
+
return null;
|
|
146
|
+
const also = suppressed > 0 ? ` (+${suppressed} more from this socket in the last second)` : '';
|
|
147
|
+
if (failure.reason === 'unknown-type') {
|
|
148
|
+
return `[tapflow] inbound frame of unknown type ${failure.type} — dropped${also}`;
|
|
149
|
+
}
|
|
150
|
+
const outcome = failure.reason === 'bad-payload' ? 'refused, and the sender told' : 'dropped';
|
|
151
|
+
return `[tapflow] inbound ${failure.type} does not match the contract — ${outcome}${also}:\n${failure.detail}`;
|
|
152
|
+
}
|
|
98
153
|
export class RelayServer {
|
|
99
154
|
options;
|
|
100
155
|
httpServer;
|
|
@@ -111,8 +166,36 @@ export class RelayServer {
|
|
|
111
166
|
purgeBuildsTimer = null;
|
|
112
167
|
flushResourcesTimer = null;
|
|
113
168
|
heartbeatTimer = null;
|
|
114
|
-
|
|
115
|
-
|
|
169
|
+
/**
|
|
170
|
+
* When each socket last answered a ping. WeakMap → no manual cleanup on close (GC handles it).
|
|
171
|
+
*
|
|
172
|
+
* **A timestamp, not the swept boolean it replaced.** That flag was set `false` for *every* socket at the
|
|
173
|
+
* start of each sweep and back to `true` only when the pong returned, so a perfectly healthy socket read
|
|
174
|
+
* as dead for the whole round trip — a normal state on a 30s cycle rather than an edge case. And the
|
|
175
|
+
* window is longest for exactly the socket that matters: video goes out on `browserSocket`, and the ping
|
|
176
|
+
* is written behind whatever is already queued there, so an external viewer's pong can take a second or
|
|
177
|
+
* two.
|
|
178
|
+
*
|
|
179
|
+
* That was invisible while the flag only decided termination, because a sweep that finds `false` has by
|
|
180
|
+
* definition already waited a full interval. Reading it to answer *"is this holder alive right now"* is
|
|
181
|
+
* what exposes it: occupancy would hand a live tester's session away, and `busy` would flash a device as
|
|
182
|
+
* free every 30 seconds — the dashboard polls every 5s and `disabled={isBusy}`, so the UI would open the
|
|
183
|
+
* button rather than a race being needed.
|
|
184
|
+
*/
|
|
185
|
+
lastPongAt = new WeakMap();
|
|
186
|
+
/**
|
|
187
|
+
* Who each socket speaks for — `<userId>:<clientId>`, the key a session's ownership is compared against.
|
|
188
|
+
*
|
|
189
|
+
* **Two parts, and both are load-bearing.** The client id is supplied by the caller (`?client=`), so on
|
|
190
|
+
* its own it is the relay's first *forgeable* ownership claim: today's ownership is socket identity,
|
|
191
|
+
* which nothing can spoof. Binding it to the authenticated user — which `getAuth` already resolves at
|
|
192
|
+
* the handshake — keeps a leaked id useless to anyone else's account.
|
|
193
|
+
*
|
|
194
|
+
* **A connection that supplies no client id is given one**, rather than falling back to socket identity.
|
|
195
|
+
* There is deliberately no fallback branch: one model, and a client that does not identify itself gets an
|
|
196
|
+
* identity that happens to be per-socket, which is exactly today's behaviour.
|
|
197
|
+
*/
|
|
198
|
+
ownerKey = new WeakMap();
|
|
116
199
|
dropHandlers = new Map();
|
|
117
200
|
// Per-session throttled drop-warn for audio (kept separate so audio drops don't mask video drops in logs).
|
|
118
201
|
audioDropHandlers = new Map();
|
|
@@ -120,7 +203,13 @@ export class RelayServer {
|
|
|
120
203
|
droppers = new Map();
|
|
121
204
|
// Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
|
|
122
205
|
idrRequesters = new Map();
|
|
206
|
+
/** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
|
|
207
|
+
* `forgetSessionState`. */
|
|
208
|
+
networkStateRequesters = new Map();
|
|
123
209
|
wsRoles = new Map();
|
|
210
|
+
/** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
|
|
211
|
+
* with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
|
|
212
|
+
rejectionLog = new WeakMap();
|
|
124
213
|
// Agent sockets whose sessions are being held open, and the timer that gives up on each.
|
|
125
214
|
// Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
|
|
126
215
|
// expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
|
|
@@ -328,6 +417,14 @@ export class RelayServer {
|
|
|
328
417
|
for (const timer of this.agentHolds.values())
|
|
329
418
|
clearTimeout(timer);
|
|
330
419
|
this.agentHolds.clear();
|
|
420
|
+
// Same argument one line up. **Not** because the send would land — `sendTo` drops a socket that is
|
|
421
|
+
// not OPEN, which is why `fire` carries no check of its own — but because `unref()` only excuses a
|
|
422
|
+
// timer from keeping the process alive, and the test runner's process outlives the server anyway.
|
|
423
|
+
// An armed edge therefore still runs, holding a closure over the session map of a relay that has
|
|
424
|
+
// already stopped.
|
|
425
|
+
for (const requester of this.networkStateRequesters.values())
|
|
426
|
+
requester.dispose();
|
|
427
|
+
this.networkStateRequesters.clear();
|
|
331
428
|
return new Promise((resolve, reject) => {
|
|
332
429
|
this.wss.clients.forEach((ws) => ws.terminate());
|
|
333
430
|
this.wss.close(() => {
|
|
@@ -341,25 +438,86 @@ export class RelayServer {
|
|
|
341
438
|
// Terminate sockets that missed the previous pong; ping the rest. Covers all roles via wss.clients.
|
|
342
439
|
runHeartbeat(clients = this.wss.clients) {
|
|
343
440
|
for (const ws of clients) {
|
|
344
|
-
if (this.
|
|
441
|
+
if (!this.isAlive(ws)) {
|
|
345
442
|
ws.terminate();
|
|
346
443
|
continue;
|
|
347
444
|
}
|
|
348
|
-
this.wsAlive.set(ws, false);
|
|
349
445
|
if (ws.readyState === WebSocket.OPEN)
|
|
350
446
|
ws.ping();
|
|
351
447
|
}
|
|
352
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Has this socket answered recently enough to be treated as present?
|
|
451
|
+
*
|
|
452
|
+
* `1.5` intervals rather than one: a pong that arrives *during* a sweep must not be read as late, and a
|
|
453
|
+
* socket pinged at the end of one interval has until halfway through the next. The cost is that a
|
|
454
|
+
* genuinely dead socket is recognised in up to 45s instead of 30 — **and 45 seconds with no false
|
|
455
|
+
* negative beats 30 with one every cycle**, because a false negative here does not merely delay: it
|
|
456
|
+
* hands a live tester's session to whoever asked next.
|
|
457
|
+
*/
|
|
458
|
+
isAlive(ws) {
|
|
459
|
+
const at = this.lastPongAt.get(ws);
|
|
460
|
+
// **No entry means just connected, not dead.** `handleConnection` seeds one, but a socket is in
|
|
461
|
+
// `wss.clients` from the upgrade, so there is a tick where a sweep could see it first — and the flag
|
|
462
|
+
// this replaced was explicitly safe there ("alive until proven otherwise"). Reading absence as dead
|
|
463
|
+
// would terminate a connection that had done nothing wrong, and occupancy would read the same absence
|
|
464
|
+
// as a free session.
|
|
465
|
+
if (at === undefined)
|
|
466
|
+
return true;
|
|
467
|
+
return Date.now() - at <= HEARTBEAT_MS * 1.5;
|
|
468
|
+
}
|
|
353
469
|
// 갱신된 cert를 재시작 없이 핫스왑한다(https 종단일 때만 의미 있음).
|
|
354
470
|
updateTlsContext(material) {
|
|
355
471
|
if (this.httpServer instanceof https.Server) {
|
|
356
472
|
this.httpServer.setSecureContext({ cert: material.cert, key: material.key });
|
|
357
473
|
}
|
|
358
474
|
}
|
|
359
|
-
|
|
360
|
-
|
|
475
|
+
/**
|
|
476
|
+
* Drop every per-session stream record keyed by this id.
|
|
477
|
+
*
|
|
478
|
+
* **Four maps that have to move together, called from four places that did not.** They were deleted
|
|
479
|
+
* inline in `session:end` and `session:leave` and nowhere else, so a session ending any other way —
|
|
480
|
+
* the agent going away, the idle timer, a device disappearing from a re-register — left all four
|
|
481
|
+
* behind for the life of the process.
|
|
482
|
+
*
|
|
483
|
+
* Pre-existing, and this slice is what makes it worth fixing here rather than filing: `idrRequesters`
|
|
484
|
+
* used to be populated only by the binary drop path, i.e. only under backpressure, and a re-join now
|
|
485
|
+
* creates an entry for any streaming session (#515). The leak went from rare to ordinary.
|
|
486
|
+
*
|
|
487
|
+
* **Now five maps, and the fifth is not dropped the same way.** `networkStateRequesters` holds a
|
|
488
|
+
* *coalescing* requester, so its closure owns a pending `setTimeout` — and deleting a map entry does
|
|
489
|
+
* not cancel a timer. Leaving it armed is not a leak but a double send: `session:leave` followed by a
|
|
490
|
+
* re-join inside the window fires the orphaned trailing edge *and* the new requester's leading edge,
|
|
491
|
+
* which is the second budget for one session that this map exists to prevent. Hence `dispose()`
|
|
492
|
+
* rather than `delete` alone. It was named `forgetSessionStreamState` while all four were stream
|
|
493
|
+
* records; the network requester is not one, so the name lost the word rather than the entry
|
|
494
|
+
* lost the function.
|
|
495
|
+
*/
|
|
496
|
+
forgetSessionState(sessionId) {
|
|
497
|
+
this.dropHandlers.delete(sessionId);
|
|
498
|
+
this.audioDropHandlers.delete(sessionId);
|
|
499
|
+
this.droppers.delete(sessionId);
|
|
500
|
+
this.idrRequesters.delete(sessionId);
|
|
501
|
+
this.networkStateRequesters.get(sessionId)?.dispose();
|
|
502
|
+
this.networkStateRequesters.delete(sessionId);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
|
|
506
|
+
* agents that don't support it.
|
|
507
|
+
*
|
|
508
|
+
* **One per session, and the construction is folded in here on purpose.** The throttle is `lastAt` in
|
|
509
|
+
* the closure, so a second requester for the same session is a second budget — which is what the
|
|
510
|
+
* `idrRequesters` map exists to prevent, and it prevented it only as long as every caller remembered
|
|
511
|
+
* to look the session up first. There are two callers now (the drop path and the re-join in
|
|
512
|
+
* `handleSessionStart`), and a separate `makeIdrRequester` was a factory either of them could reach
|
|
513
|
+
* past the map. Measured: calling it directly from one caller left every test green.
|
|
514
|
+
*/
|
|
515
|
+
idrRequester(sessionId) {
|
|
516
|
+
const existing = this.idrRequesters.get(sessionId);
|
|
517
|
+
if (existing)
|
|
518
|
+
return existing;
|
|
361
519
|
let lastAt = 0;
|
|
362
|
-
|
|
520
|
+
const requester = () => {
|
|
363
521
|
const now = Date.now();
|
|
364
522
|
if (now - lastAt < IDR_REQUEST_THROTTLE_MS)
|
|
365
523
|
return;
|
|
@@ -369,6 +527,50 @@ export class RelayServer {
|
|
|
369
527
|
this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId });
|
|
370
528
|
}
|
|
371
529
|
};
|
|
530
|
+
this.idrRequesters.set(sessionId, requester);
|
|
531
|
+
return requester;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Throttled callback asking the session's agent to re-read and report the device's network
|
|
535
|
+
* condition (#614); ignored by agents that don't support it.
|
|
536
|
+
*
|
|
537
|
+
* **Coalescing, where `idrRequester` above drops — and the difference is not a preference.** A
|
|
538
|
+
* dropped IDR request costs nothing: the next periodic keyframe repairs it. Nothing re-produces a
|
|
539
|
+
* `network:state`, so a request dropped inside the window leaves that viewer rendering "unknown"
|
|
540
|
+
* for the life of the session. The trailing edge fires after the last join in a burst, and the
|
|
541
|
+
* relay addresses the reply to whichever socket holds the session *when it arrives* — so the
|
|
542
|
+
* viewer that ends up watching is the one served, which a leading edge cannot promise.
|
|
543
|
+
*
|
|
544
|
+
* The burst this is for is the dirty blip, not two people: `join()` refuses a live holder, and a
|
|
545
|
+
* clean `session:leave` runs `forgetSessionState` and builds a fresh requester. A browser socket
|
|
546
|
+
* that dies in its close handler does neither, so `lastAt` survives into the re-join — which is
|
|
547
|
+
* the very path this slice exists to serve.
|
|
548
|
+
*
|
|
549
|
+
* One per session, constructed here, for the reason written on `idrRequester`: the throttle lives
|
|
550
|
+
* in the closure, so a second requester is a second budget.
|
|
551
|
+
*/
|
|
552
|
+
networkStateRequester(sessionId) {
|
|
553
|
+
const existing = this.networkStateRequesters.get(sessionId);
|
|
554
|
+
if (existing)
|
|
555
|
+
return existing;
|
|
556
|
+
const requester = createTrailingRequester({
|
|
557
|
+
scheduler: systemTimerScheduler,
|
|
558
|
+
windowMs: NETWORK_STATE_REQUEST_THROTTLE_MS,
|
|
559
|
+
fire: () => {
|
|
560
|
+
const session = this.sessions.get(sessionId);
|
|
561
|
+
// `sendTo` already drops a socket that is not OPEN, so a closed agent needs no check here —
|
|
562
|
+
// an earlier draft duplicated it and no mutation could tell the copy from the original.
|
|
563
|
+
//
|
|
564
|
+
// The lookup is the guard that remains, and it should be unreachable: **every path that removes
|
|
565
|
+
// a session runs `forgetSessionState`, which disposes this timer.** It is kept as the cheap half
|
|
566
|
+
// of that pair rather than as a live case — a trailing edge is the one call here that outlives
|
|
567
|
+
// the statement scheduling it, so the invariant holding is worth not assuming.
|
|
568
|
+
if (session)
|
|
569
|
+
this.sendTo(session.agentSocket, { type: 'network:request-state', sessionId });
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
this.networkStateRequesters.set(sessionId, requester);
|
|
573
|
+
return requester;
|
|
372
574
|
}
|
|
373
575
|
// Extracted so tests can simulate non-loopback origins (all test traffic is loopback).
|
|
374
576
|
remoteAddressOf(request) {
|
|
@@ -411,9 +613,19 @@ export class RelayServer {
|
|
|
411
613
|
return;
|
|
412
614
|
}
|
|
413
615
|
this.wsExternal.set(ws, isExternalAddress(addr));
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
616
|
+
// Read from the upgrade URL because a browser cannot set WebSocket headers, and because it has to be
|
|
617
|
+
// known **per connection** rather than per join: the dashboard socket that sends the unmount teardown
|
|
618
|
+
// never joins anything. `new URL` against a dummy base — `request.url` is path-relative.
|
|
619
|
+
const claimed = new URL(request.url ?? '/', 'http://relay').searchParams.get('client');
|
|
620
|
+
// **The PAT counts as a principal, and a first version dropped it.** `getAuth` reads the cookie only,
|
|
621
|
+
// so every agent, `mcp-server` and `flow-runner` connection would have been `anon:` — and the whole
|
|
622
|
+
// point of pairing the claim with a user is that a leaked client id is useless to anyone else. Two
|
|
623
|
+
// processes on the same PAT are the same principal, which is right; two on different PATs are not,
|
|
624
|
+
// which is what this restores. `pat` is already resolved above and was being discarded.
|
|
625
|
+
this.ownerKey.set(ws, ownerKeyFor(getAuth(request)?.userId ?? pat?.userId, claimed));
|
|
626
|
+
// Heartbeat liveness: a fresh socket counts as having just answered, and each pong renews it.
|
|
627
|
+
this.lastPongAt.set(ws, Date.now());
|
|
628
|
+
ws.on('pong', () => this.lastPongAt.set(ws, Date.now()));
|
|
417
629
|
if (decision.role === 'browser')
|
|
418
630
|
this.wsRoles.set(ws, 'browser');
|
|
419
631
|
// 'first-message' → role is determined by the first message (agent:register / stream:register)
|
|
@@ -447,11 +659,7 @@ export class RelayServer {
|
|
|
447
659
|
dropper = createKeyframeAwareSender();
|
|
448
660
|
this.droppers.set(session.id, dropper);
|
|
449
661
|
}
|
|
450
|
-
|
|
451
|
-
if (!requestIdr) {
|
|
452
|
-
requestIdr = this.makeIdrRequester(session.id);
|
|
453
|
-
this.idrRequesters.set(session.id, requestIdr);
|
|
454
|
-
}
|
|
662
|
+
const requestIdr = this.idrRequester(session.id);
|
|
455
663
|
// JPEG and H.264 IDRs are resync points; only P-frames must wait for a keyframe after a drop.
|
|
456
664
|
let isKeyframe = true;
|
|
457
665
|
if (hasEnvelope(frameBuf)) {
|
|
@@ -470,20 +678,48 @@ export class RelayServer {
|
|
|
470
678
|
catch {
|
|
471
679
|
return; // genuinely malformed — there is no type to answer on
|
|
472
680
|
}
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
|
|
681
|
+
// **The frame becomes a union here, and not by a cast.** `#550` asked for `RelayMessage` — a flat
|
|
682
|
+
// interface where `type` is the only required member — to become a discriminated union, and doing
|
|
683
|
+
// that with an `as` at this line would have been a downgrade: the visible cast at the
|
|
684
|
+
// `session:chrome` handler would have turned into an invisible `msg.payload`, with the compiler
|
|
685
|
+
// vouching for JSON that arrived over a socket. So the union is the *product* of a parse (#444),
|
|
686
|
+
// and what the parse could not prove is not in the type. Non-objects, unknown types and shape
|
|
687
|
+
// failures are all refused in there — including the bare `null` / number / string that
|
|
688
|
+
// `JSON.parse` returns without throwing.
|
|
689
|
+
const inbound = parseInbound(parsed);
|
|
690
|
+
// **Role and direction are settled before the shape is, and the order is load-bearing.** A first
|
|
691
|
+
// draft rejected a malformed frame and returned, which silently dropped an agent-only type a
|
|
692
|
+
// browser socket had spoofed *badly* — the 1008 that closes such a socket never fired, so the
|
|
693
|
+
// spoofer kept its connection. The direction is a fact about the `type` alone, and the type is
|
|
694
|
+
// known on a shape failure too, so nothing about that check needs the payload to be valid.
|
|
695
|
+
// **Logged before the gate, not after it.** A first draft called `settleRole` first and logged in
|
|
696
|
+
// the `!ok` branch below it — which never ran for two of the three failure reasons, because
|
|
697
|
+
// `settleRole` returns `false` for them. The case that mattered was a malformed handshake on a
|
|
698
|
+
// role-less socket: dropped in silence, which is precisely the agent-registration skew this log
|
|
699
|
+
// exists to make visible.
|
|
700
|
+
if (!inbound.ok)
|
|
701
|
+
this.logInboundRejection(ws, inbound);
|
|
702
|
+
if (!this.settleRole(ws, inbound))
|
|
477
703
|
return;
|
|
478
|
-
|
|
704
|
+
if (!inbound.ok) {
|
|
705
|
+
// **After the role gate, never before it**, so a browser spoofing an agent-only type gets 1008
|
|
706
|
+
// and no reply. That is the whole of the claim: an agent- or stream-role socket sending a
|
|
707
|
+
// malformed browser request passes the gate and *is* answered, because the gate only refuses a
|
|
708
|
+
// `browser` role sending a non-browser type. Harmless, and worth stating rather than implying
|
|
709
|
+
// otherwise — such a socket already gets an answer on the well-formed path (`refuseInput` tells
|
|
710
|
+
// it `not-session-owner`), and this reply says strictly less than that one.
|
|
711
|
+
if (inbound.reason === 'bad-payload')
|
|
712
|
+
this.refuseMalformed(ws, inbound);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
479
715
|
try {
|
|
480
|
-
this.route(ws, msg);
|
|
716
|
+
this.route(ws, inbound.msg, inbound.raw);
|
|
481
717
|
}
|
|
482
718
|
catch (e) {
|
|
483
719
|
// A throw inside a handler used to land in the same catch as a parse failure and vanish.
|
|
484
720
|
// Anything reaching here is a bug in routing, not a bad message, and the caller is left
|
|
485
721
|
// waiting either way — so at least say so once instead of dropping it silently.
|
|
486
|
-
logger.error(`route failed for ${
|
|
722
|
+
logger.error(`route failed for ${inbound.msg.type}:`, e);
|
|
487
723
|
}
|
|
488
724
|
});
|
|
489
725
|
ws.on('close', () => {
|
|
@@ -493,48 +729,137 @@ export class RelayServer {
|
|
|
493
729
|
// coming back (#426), rather than ending them where they stand.
|
|
494
730
|
if (this.holdAgentSocket(ws))
|
|
495
731
|
return;
|
|
496
|
-
// Stream socket disconnected → clear the streamSocket reference
|
|
732
|
+
// Stream socket disconnected → clear the streamSocket reference.
|
|
733
|
+
//
|
|
734
|
+
// **No `return`.** One socket can be both: the role gate refuses a `browser`-role socket sending a
|
|
735
|
+
// non-browser type, and says nothing about a `stream`-role one sending `session:start` — so a
|
|
736
|
+
// socket that registered a stream first can go on to hold sessions. Returning here skipped the
|
|
737
|
+
// release below for exactly those, which is the state the loop exists to end.
|
|
497
738
|
const streamSession = this.sessions.getByStreamSocket(ws);
|
|
498
|
-
if (streamSession)
|
|
739
|
+
if (streamSession)
|
|
499
740
|
this.sessions.clearStreamSocket(streamSession.id);
|
|
741
|
+
// Browser socket disconnected → release **every** session it held, each with its own idle timer.
|
|
742
|
+
//
|
|
743
|
+
// `getByBrowserSocket` used to answer with one session because the index held one, and a socket
|
|
744
|
+
// holding several is not exotic: `mcp-server` runs one socket for the whole process and joins a
|
|
745
|
+
// session per device. So closing it released the last join and left the rest bound to a dead
|
|
746
|
+
// socket — `busy: true` forever, no timer, and their devices booted with nobody watching (#507).
|
|
747
|
+
//
|
|
748
|
+
// The `stopping` guard is `holdAgentSocket`'s, for its stated reason: `stop()` terminates every
|
|
749
|
+
// client, and a timer armed on the way out survives the promise `stop()` resolves. That was one
|
|
750
|
+
// stray timer per relay before this loop and is one per held session after it.
|
|
751
|
+
if (this.stopping)
|
|
500
752
|
return;
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const browserSession = this.sessions.getByBrowserSocket(ws);
|
|
504
|
-
if (browserSession) {
|
|
505
|
-
this.sessions.clearBrowser(browserSession.id, () => {
|
|
506
|
-
const session = this.sessions.get(browserSession.id);
|
|
507
|
-
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
508
|
-
this.sendTo(session.agentSocket, {
|
|
509
|
-
type: 'device:shutdown',
|
|
510
|
-
sessionId: session.id,
|
|
511
|
-
payload: { deviceId: session.deviceId },
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
});
|
|
753
|
+
for (const held of this.sessions.getByBrowserSocket(ws)) {
|
|
754
|
+
this.sessions.clearBrowser(held.id, () => this.idleShutdown(held.id));
|
|
515
755
|
}
|
|
516
756
|
});
|
|
517
757
|
}
|
|
518
|
-
|
|
519
|
-
|
|
758
|
+
/**
|
|
759
|
+
* @param msg what the door proved — see `parseInbound`. An Envelope-tier member arrives carrying
|
|
760
|
+
* `type` and its correlators and nothing else, so a payload the parser did not check
|
|
761
|
+
* cannot be read off it.
|
|
762
|
+
* @param raw the frame as it arrived. Agent-origin messages are **forwarded as this**, so a field
|
|
763
|
+
* a newer agent added survives a relay that does not know it — `z.object` strips, and
|
|
764
|
+
* stripping is wrong in the one direction where the sender is the more recently
|
|
765
|
+
* updated side. Browser-origin messages are forwarded as `msg` instead, so a key an attacker
|
|
766
|
+
* appended does not survive. It is also where the two stored Envelope payloads come
|
|
767
|
+
* from, which is the point: a value read off `raw` is one the parser did not vouch for.
|
|
768
|
+
*/
|
|
769
|
+
/**
|
|
770
|
+
* Assign this socket's role if it does not have one, then refuse a message its role may not send.
|
|
771
|
+
*
|
|
772
|
+
* @returns `false` when the caller must stop — the socket was closed, or the frame is a failed
|
|
773
|
+
* handshake that must not confer a role.
|
|
774
|
+
*/
|
|
775
|
+
settleRole(ws, inbound) {
|
|
776
|
+
// Every reason that carries a type, which is all of them but the two that have none to carry.
|
|
777
|
+
// Missing `bad-payload` here returned `false` before the caller could answer, so the thirteen
|
|
778
|
+
// answerable requests were classified correctly and then dropped anyway — the regression this
|
|
779
|
+
// whole path exists to prevent, reintroduced one line above it.
|
|
780
|
+
const type = inbound.ok ? inbound.msg.type
|
|
781
|
+
: inbound.reason === 'bad-shape' || inbound.reason === 'bad-payload' ? inbound.type
|
|
782
|
+
: undefined;
|
|
783
|
+
if (type === undefined)
|
|
784
|
+
return false;
|
|
785
|
+
// **The role comes from the two handshake literals, deliberately not from `directionOf`.** Reading
|
|
786
|
+
// it from the message's own direction is the obvious simplification now that one exists, and it
|
|
787
|
+
// inverts the check below: a socket whose first frame is `screenshot:done` would be handed the
|
|
788
|
+
// `agent` role and then waved through the gate that exists to refuse exactly that.
|
|
789
|
+
const handshake = type === 'agent:register' || type === 'stream:register';
|
|
520
790
|
if (!this.wsRoles.has(ws)) {
|
|
521
|
-
|
|
791
|
+
// A handshake that did not parse confers nothing. Returning here rather than falling through to
|
|
792
|
+
// `browser` is what keeps an agent whose register is malformed from being closed with
|
|
793
|
+
// `Forbidden` — the caller has already logged it, and the next frame still gets to introduce it.
|
|
794
|
+
if (handshake && !inbound.ok)
|
|
795
|
+
return false;
|
|
796
|
+
if (type === 'agent:register')
|
|
522
797
|
this.wsRoles.set(ws, 'agent');
|
|
523
|
-
|
|
524
|
-
else if (msg.type === 'stream:register') {
|
|
798
|
+
else if (type === 'stream:register')
|
|
525
799
|
this.wsRoles.set(ws, 'stream');
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
800
|
+
// Local connection whose first message is not an agent/stream handshake — treat it as a browser
|
|
801
|
+
// (e.g. dashboard opened on the same machine). This fallback is what puts such a socket under
|
|
802
|
+
// the gate below.
|
|
803
|
+
else
|
|
530
804
|
this.wsRoles.set(ws, 'browser');
|
|
531
|
-
}
|
|
532
805
|
}
|
|
533
|
-
// Browser sockets must not spoof agent control messages
|
|
534
|
-
|
|
806
|
+
// Browser sockets must not spoof agent control messages.
|
|
807
|
+
//
|
|
808
|
+
// This was a hand-copied array of 29 literals with two type assertions holding it against the
|
|
809
|
+
// protocol. `directionOf` derives the same set from the schema map — verified member for member
|
|
810
|
+
// against the array it replaces, no literal added and none lost.
|
|
811
|
+
if (this.wsRoles.get(ws) === 'browser' && directionOf(type) !== 'browser') {
|
|
535
812
|
ws.close(1008, 'Forbidden');
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* What the door refused, said once and in a form an operator can act on.
|
|
819
|
+
*
|
|
820
|
+
* This is where `isAddressed` and `isCorrelated` ended up. Both were predicates whose whole
|
|
821
|
+
* observable output was a `console.warn` — an id-less request resolved no session and was dropped by
|
|
822
|
+
* the miss anyway — and the schemas now reject the same frames earlier, including the empty-string
|
|
823
|
+
* case a bare `z.string()` would have let through. What is new is that the log names the *field*:
|
|
824
|
+
* "requestId: Too small" instead of "dropped, cannot correlate a reply".
|
|
825
|
+
*
|
|
826
|
+
* Worth logging at all for the reason `isCorrelated` gave: the three places a bad frame can be
|
|
827
|
+
* dropped are otherwise silent, and an operator who upgrades the relay but not an independently
|
|
828
|
+
* installed `mcp-server` would watch commands do nothing with no trace.
|
|
829
|
+
*
|
|
830
|
+
* **Throttled per socket, and the "per socket" is the whole design.** A first draft wrote one line
|
|
831
|
+
* per rejected frame, on the direction a viewer with devtools controls and at the rate a gesture
|
|
832
|
+
* produces — the unbounded, attacker-driven log volume this file already refuses at
|
|
833
|
+
* `forwardUnacked`, with the reason written beside it. A single module-level timestamp would fix the
|
|
834
|
+
* volume and break the diagnostic: one noisy socket would silence the *other* socket's first bad
|
|
835
|
+
* frame, which is the skewed-client case the log exists for. So the state is keyed by socket, in a
|
|
836
|
+
* `WeakMap` that needs no cleanup, and **the first rejection from any socket is always written** —
|
|
837
|
+
* the one that names the skew is never the hundredth. What is dropped is only repetition, and the
|
|
838
|
+
* next line says how much.
|
|
839
|
+
*
|
|
840
|
+
* `unknown-type` stays at debug: eleven relay-produced literals land here whenever a client echoes
|
|
841
|
+
* one back, and none of them is a defect. It shares the throttle so turning debug on cannot
|
|
842
|
+
* reintroduce the volume.
|
|
843
|
+
*/
|
|
844
|
+
logInboundRejection(ws, failure) {
|
|
845
|
+
const now = Date.now();
|
|
846
|
+
const state = this.rejectionLog.get(ws);
|
|
847
|
+
if (state && now - state.at < REJECT_LOG_INTERVAL_MS) {
|
|
848
|
+
state.suppressed++;
|
|
536
849
|
return;
|
|
537
850
|
}
|
|
851
|
+
const line = describeRejection(failure, state?.suppressed ?? 0);
|
|
852
|
+
// Recorded even when there is nothing to write, so a burst of `not-an-object` frames cannot reset
|
|
853
|
+
// the window for the reasons that do write.
|
|
854
|
+
this.rejectionLog.set(ws, { at: now, suppressed: 0 });
|
|
855
|
+
if (line === null)
|
|
856
|
+
return;
|
|
857
|
+
if (failure.reason === 'unknown-type')
|
|
858
|
+
logger.debug(line);
|
|
859
|
+
else
|
|
860
|
+
logger.warn(line);
|
|
861
|
+
}
|
|
862
|
+
route(ws, msg, raw) {
|
|
538
863
|
switch (msg.type) {
|
|
539
864
|
// ── Agent → Relay ─────────────────────────────────────────────────────
|
|
540
865
|
case 'agent:resources':
|
|
@@ -556,30 +881,44 @@ export class RelayServer {
|
|
|
556
881
|
this.handleUITreeError(msg);
|
|
557
882
|
break;
|
|
558
883
|
case 'agents:list': {
|
|
559
|
-
this.sendTo(ws, {
|
|
884
|
+
this.sendTo(ws, {
|
|
885
|
+
type: 'agents:listed',
|
|
886
|
+
sessions: this.sessions.list(this.ownerOf(ws), (holder) => this.isAlive(holder)),
|
|
887
|
+
});
|
|
560
888
|
break;
|
|
561
889
|
}
|
|
562
890
|
// ── Session / Stream lifecycle ─────────────────────────────────────────
|
|
563
891
|
case 'session:start':
|
|
564
892
|
this.handleSessionStart(ws, msg);
|
|
565
893
|
break;
|
|
894
|
+
// ── the two session commands, gated but not answered ───────────────────────────────────────
|
|
895
|
+
//
|
|
896
|
+
// Both destroy state a viewer depends on, and until L5c both acted on the strength of the session
|
|
897
|
+
// existing. `session:leave` is the sharper of the two: it nulls `browserSocket`, so an unguarded one
|
|
898
|
+
// strips ownership out from under a mounted viewer — and then that tester's own input is refused,
|
|
899
|
+
// which is why `not-session-owner` needed real copy in the dashboard rather than the `null` a first
|
|
900
|
+
// draft gave it.
|
|
901
|
+
//
|
|
902
|
+
// No address check here at all any more: the schema declares `sessionId` with `.min(1)`, so an absent
|
|
903
|
+
// id, an empty one and a non-string are all refused before this case is reached. It used to be a bare
|
|
904
|
+
// `msg.sessionId &&` rather than the `isAddressed` predicate, because a falsy check already covered
|
|
905
|
+
// both halves and the predicate would have added only its log.
|
|
906
|
+
//
|
|
907
|
+
// **Dropped rather than refused, and that is the contract**: neither has a reply, so there is no
|
|
908
|
+
// waiter to tell. The same asymmetry as the input frames nothing acks. Inventing a
|
|
909
|
+
// `session:leave-error` would grow the wire for a message no consumer reads — and `session:end` has
|
|
910
|
+
// no in-repo sender at all, so it would be a reply to nobody.
|
|
566
911
|
case 'session:end': {
|
|
567
|
-
if (msg.sessionId) {
|
|
912
|
+
if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
|
|
568
913
|
this.sessions.remove(msg.sessionId);
|
|
569
|
-
this.
|
|
570
|
-
this.audioDropHandlers.delete(msg.sessionId);
|
|
571
|
-
this.droppers.delete(msg.sessionId);
|
|
572
|
-
this.idrRequesters.delete(msg.sessionId);
|
|
914
|
+
this.forgetSessionState(msg.sessionId);
|
|
573
915
|
}
|
|
574
916
|
break;
|
|
575
917
|
}
|
|
576
918
|
case 'session:leave': {
|
|
577
|
-
if (msg.sessionId) {
|
|
919
|
+
if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
|
|
578
920
|
this.sessions.clearBrowser(msg.sessionId);
|
|
579
|
-
this.
|
|
580
|
-
this.audioDropHandlers.delete(msg.sessionId);
|
|
581
|
-
this.droppers.delete(msg.sessionId);
|
|
582
|
-
this.idrRequesters.delete(msg.sessionId);
|
|
921
|
+
this.forgetSessionState(msg.sessionId);
|
|
583
922
|
}
|
|
584
923
|
break;
|
|
585
924
|
}
|
|
@@ -596,13 +935,21 @@ export class RelayServer {
|
|
|
596
935
|
const session = this.sessions.get(msg.sessionId);
|
|
597
936
|
if (!session)
|
|
598
937
|
break;
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
938
|
+
// **Off `raw`, and still a cast — deliberately both.**
|
|
939
|
+
//
|
|
940
|
+
// `session:chrome` is Envelope tier, so the parser proved `type` and `sessionId` and nothing
|
|
941
|
+
// about this value; reading it from `raw` is what makes that visible at the line rather than
|
|
942
|
+
// in a header. The old comment here promised a validator would replace the cast. It did not,
|
|
943
|
+
// and the reason is worth having instead of the promise: `ChromePayload` is a **closed
|
|
944
|
+
// two-member union** while `AgentRegister.platform` is `string`, open so that a third-party
|
|
945
|
+
// platform can register through `AgentRegistry.register()` (root AGENTS.md, OCP). Validating
|
|
946
|
+
// this would refuse a platform the repo promises to support — and refusing costs more than a
|
|
947
|
+
// dropped frame, because the message arrives once per boot and skipping `setChromeData` also
|
|
948
|
+
// empties what the re-join replay reads. The relay never looks inside; the viewer does, and it
|
|
949
|
+
// is where the variants are already told apart.
|
|
950
|
+
this.sessions.setChromeData(session.id, raw['payload']);
|
|
604
951
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
605
|
-
session.browserSocket.send(JSON.stringify(
|
|
952
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
606
953
|
}
|
|
607
954
|
break;
|
|
608
955
|
}
|
|
@@ -610,9 +957,10 @@ export class RelayServer {
|
|
|
610
957
|
const session = this.sessions.get(msg.sessionId);
|
|
611
958
|
if (!session)
|
|
612
959
|
break;
|
|
613
|
-
|
|
960
|
+
// See `session:chrome` above for why this one is a cast off `raw` too.
|
|
961
|
+
this.sessions.setDeviceInfo(session.id, raw['payload']);
|
|
614
962
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
615
|
-
session.browserSocket.send(JSON.stringify(
|
|
963
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
616
964
|
}
|
|
617
965
|
break;
|
|
618
966
|
}
|
|
@@ -623,14 +971,14 @@ export class RelayServer {
|
|
|
623
971
|
break;
|
|
624
972
|
this.sessions.clearDeviceCache(session.id);
|
|
625
973
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
626
|
-
session.browserSocket.send(JSON.stringify(
|
|
974
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
627
975
|
}
|
|
628
976
|
break;
|
|
629
977
|
}
|
|
630
978
|
case 'device:boot-error': {
|
|
631
979
|
const session = this.sessions.get(msg.sessionId);
|
|
632
980
|
if (session?.browserSocket?.readyState === WebSocket.OPEN) {
|
|
633
|
-
session.browserSocket.send(JSON.stringify(
|
|
981
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
634
982
|
}
|
|
635
983
|
break;
|
|
636
984
|
}
|
|
@@ -641,18 +989,23 @@ export class RelayServer {
|
|
|
641
989
|
this.sessions.updateDeviceStatus(session.id, 'shutdown');
|
|
642
990
|
this.sessions.setReadySent(session.id, false);
|
|
643
991
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
644
|
-
session.browserSocket.send(JSON.stringify(
|
|
992
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
645
993
|
}
|
|
646
994
|
break;
|
|
647
995
|
}
|
|
648
996
|
case 'device:ready': {
|
|
997
|
+
// The one inbound member whose `sessionId` is declared optional, and it is a reasoned deferral
|
|
998
|
+
// rather than an oversight — see `DeviceReady`. So this is a real guard, not a dropped `!`:
|
|
999
|
+
// an agent that omits it resolves no session here, exactly as before.
|
|
1000
|
+
if (msg.sessionId === undefined)
|
|
1001
|
+
break;
|
|
649
1002
|
const session = this.sessions.get(msg.sessionId);
|
|
650
1003
|
if (!session)
|
|
651
1004
|
break;
|
|
652
1005
|
this.sessions.updateDeviceStatus(session.id, 'booted');
|
|
653
1006
|
this.sessions.setReadySent(session.id, true);
|
|
654
1007
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
655
|
-
session.browserSocket.send(JSON.stringify(
|
|
1008
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
656
1009
|
}
|
|
657
1010
|
break;
|
|
658
1011
|
}
|
|
@@ -671,13 +1024,18 @@ export class RelayServer {
|
|
|
671
1024
|
case 'keyboard:toggled': {
|
|
672
1025
|
const session = this.sessions.get(msg.sessionId);
|
|
673
1026
|
if (session?.browserSocket?.readyState === WebSocket.OPEN) {
|
|
674
|
-
session.browserSocket.send(JSON.stringify(
|
|
1027
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
675
1028
|
}
|
|
676
1029
|
break;
|
|
677
1030
|
}
|
|
678
1031
|
// Bound to the session's own agent socket, unlike the replies above: this payload
|
|
679
1032
|
// lands on the viewer's host OS clipboard, so a second agent (another Mac on the
|
|
680
1033
|
// same relay) must not be able to address someone else's session.
|
|
1034
|
+
// Bound to the session's own agent socket for the same reason clipboard is: this reply says
|
|
1035
|
+
// whether a **specific tester's** device is on the network, and a second agent must not be
|
|
1036
|
+
// able to tell that tester's viewer something about it.
|
|
1037
|
+
case 'network:state':
|
|
1038
|
+
case 'network:error':
|
|
681
1039
|
case 'clipboard:data':
|
|
682
1040
|
case 'clipboard:write-done':
|
|
683
1041
|
case 'clipboard:error': {
|
|
@@ -685,38 +1043,101 @@ export class RelayServer {
|
|
|
685
1043
|
if (session?.agentSocket !== ws)
|
|
686
1044
|
break;
|
|
687
1045
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
688
|
-
session.browserSocket.send(JSON.stringify(
|
|
1046
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
689
1047
|
}
|
|
690
1048
|
break;
|
|
691
1049
|
}
|
|
692
1050
|
// ── Browser → Agent ────────────────────────────────────────────────────
|
|
693
|
-
|
|
1051
|
+
//
|
|
1052
|
+
// These two shared one fall-through clause. Separated because the sharing is the trap for
|
|
1053
|
+
// whoever adds the door gate: a correlator check written into a shared body would gate
|
|
1054
|
+
// `device:shutdown` too, and the relay originates that message with no id — so the dashboard's
|
|
1055
|
+
// four senders and the relay's own idle timer would stop reaching the agent, silently, in the
|
|
1056
|
+
// one direction no reply reports. That gate is a schema now and cannot be written into a case at
|
|
1057
|
+
// all, which is what makes the trap unreachable rather than merely avoided.
|
|
1058
|
+
case 'device:boot': {
|
|
1059
|
+
// The door gate that stood here is now the schema: `device:boot` declares `sessionId` and
|
|
1060
|
+
// `requestId` required, and the parser rejects an absent **or empty** one before this case is
|
|
1061
|
+
// reached. The policy it enforced is unchanged and its reason still holds — both things the
|
|
1062
|
+
// relay could do with an id-less boot are downstream of here (forward it, or answer it with a
|
|
1063
|
+
// `device:boot-error`), so gating only one of them would leave the guarantee resting on
|
|
1064
|
+
// whichever branch was not gated.
|
|
1065
|
+
//
|
|
1066
|
+
// A boot the agent never receives leaves the viewer on "Waiting for first frame…" with nothing
|
|
1067
|
+
// said, and the reasons it might not arrive are worth telling apart: `bootDevice` is the first
|
|
1068
|
+
// call an MCP caller makes, so reporting a stale session id as a dead Mac sends the reader after
|
|
1069
|
+
// the wrong problem. `dispatchTarget` decides which of the four it is.
|
|
1070
|
+
//
|
|
1071
|
+
// **The relay is a producer of this reply, so it echoes the correlator itself.** An MCP caller
|
|
1072
|
+
// that receives this diagnosis uncorrelated reads it as unsolicited and waits out its deadline —
|
|
1073
|
+
// the diagnosis arrives and is discarded. That defect shipped twice from agent code
|
|
1074
|
+
// (`open-url:error`, then `clipboard:error`); here the producer is the relay, where
|
|
1075
|
+
// `correlatedRequestsGated` cannot see it because the field is optional.
|
|
1076
|
+
const boot = this.dispatchTarget(ws, msg.sessionId);
|
|
1077
|
+
if (!boot.ok) {
|
|
1078
|
+
this.sendTo(ws, {
|
|
1079
|
+
type: 'device:boot-error',
|
|
1080
|
+
sessionId: msg.sessionId,
|
|
1081
|
+
requestId: msg.requestId,
|
|
1082
|
+
message: boot.message,
|
|
1083
|
+
});
|
|
1084
|
+
break;
|
|
1085
|
+
}
|
|
1086
|
+
// Tag the boot with whether the viewer is external (public IP) so the agent can pick the
|
|
1087
|
+
// downscale tier. The browser already reports secureContext in the payload.
|
|
1088
|
+
//
|
|
1089
|
+
// **Mutating the parse product, not the frame that arrived**, which is what makes this safe as
|
|
1090
|
+
// well as correct: `payload` is a fresh object the parser built, so `raw` is untouched. The
|
|
1091
|
+
// presence check the old line carried is gone because the schema requires the payload — and
|
|
1092
|
+
// `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
|
|
1093
|
+
msg.payload['external'] = this.wsExternal.get(ws) ?? false;
|
|
1094
|
+
// The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
|
|
1095
|
+
boot.session.agentSocket.send(JSON.stringify(msg));
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
694
1098
|
case 'device:shutdown': {
|
|
1099
|
+
// Deliberately **un**correlated: the relay sends this itself from the idle timer, with no browser and
|
|
1100
|
+
// no id behind it, so a correlator cannot be required and an absent one is not an error.
|
|
1101
|
+
//
|
|
1102
|
+
// Addressing used to have no gate either, on the grounds that an unaddressed shutdown resolves no
|
|
1103
|
+
// session and is dropped by the miss, so a gate would buy only its log. That reasoning is now moot
|
|
1104
|
+
// rather than wrong: the schema declares `sessionId` required, so the parser refuses the frame and
|
|
1105
|
+
// the log it would have bought is the one `logInboundRejection` writes.
|
|
1106
|
+
//
|
|
1107
|
+
// **`reachableTargetWithoutOwnership`, not `dispatchTarget`** — that clause is #527, and its blocker is in
|
|
1108
|
+
// the dashboard, not here. What this fixes is the other half: until #542 this case resolved the
|
|
1109
|
+
// session inline and dropped the frame when it could not, making it the only browser-originated
|
|
1110
|
+
// command the relay never answers. `mcp-server`'s `shutdownDevice` waits 30s on `device:shutdown-done`
|
|
1111
|
+
// and then reports `Request timed out` with no cause, which is the silence, not a diagnosis.
|
|
695
1112
|
const session = this.sessions.get(msg.sessionId);
|
|
696
|
-
if (session
|
|
697
|
-
// A boot the agent never receives leaves the viewer on "Waiting for first frame…" with
|
|
698
|
-
// nothing said. `device:shutdown` gets no such reply: nothing waits on it, and inventing
|
|
699
|
-
// a `device:shutdown-error` would grow the contract for a message no one reads.
|
|
700
|
-
//
|
|
701
|
-
// The two are worth telling apart: `bootDevice` is the first call an MCP caller makes, so
|
|
702
|
-
// reporting a stale session id as a dead Mac sends the reader after the wrong problem.
|
|
1113
|
+
if (session && !this.mayShutDown(ws, session)) {
|
|
703
1114
|
this.sendTo(ws, {
|
|
704
|
-
type: 'device:
|
|
1115
|
+
type: 'device:shutdown-error',
|
|
705
1116
|
sessionId: msg.sessionId,
|
|
706
|
-
|
|
1117
|
+
...(msg.requestId === undefined ? {} : { requestId: msg.requestId }),
|
|
1118
|
+
message: ownershipRefusal(session),
|
|
707
1119
|
});
|
|
708
1120
|
break;
|
|
709
1121
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1122
|
+
const target = this.reachableTargetWithoutOwnership(msg.sessionId);
|
|
1123
|
+
if (!target.ok) {
|
|
1124
|
+
// Echoed, and the relay is the producer — the same obligation `device:boot-error` carries and for
|
|
1125
|
+
// the same reason: an MCP caller that receives a diagnosis uncorrelated reads it as unsolicited
|
|
1126
|
+
// and waits out the deadline anyway, so the answer arrives and is discarded. `requestId` is
|
|
1127
|
+
// optional on both sides here, so an absent one stays absent rather than being invented.
|
|
1128
|
+
this.sendTo(ws, {
|
|
1129
|
+
type: 'device:shutdown-error',
|
|
1130
|
+
sessionId: msg.sessionId,
|
|
1131
|
+
...(msg.requestId === undefined ? {} : { requestId: msg.requestId }),
|
|
1132
|
+
message: target.message,
|
|
1133
|
+
});
|
|
1134
|
+
break;
|
|
717
1135
|
}
|
|
1136
|
+
target.session.agentSocket.send(JSON.stringify(msg));
|
|
718
1137
|
break;
|
|
719
1138
|
}
|
|
1139
|
+
// Door checks, one policy per request: an uncorrelatable request is not forwarded, not rebuilt and
|
|
1140
|
+
// not answered, because every reply these produce declares `requestId` as required.
|
|
720
1141
|
case 'app:install':
|
|
721
1142
|
this.handleBrowserAppInstall(ws, msg);
|
|
722
1143
|
break;
|
|
@@ -724,46 +1145,88 @@ export class RelayServer {
|
|
|
724
1145
|
this.handleBrowserAppLaunch(ws, msg);
|
|
725
1146
|
break;
|
|
726
1147
|
case 'open-url': {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1148
|
+
// **At the door, before either branch.** A correlator is required on this request, and the
|
|
1149
|
+
// relay has two things it could do with one that lacks it — forward it, or answer it — so
|
|
1150
|
+
// putting the check inside one branch means having two policies. A first draft did exactly
|
|
1151
|
+
// that: it refused to *answer* without an id while still *forwarding* without one, which
|
|
1152
|
+
// leaves the whole guarantee resting on the receiving agent. Both in-repo agents drop it, but
|
|
1153
|
+
// an agent that predates this field would execute the request and reply uncorrelated, and
|
|
1154
|
+
// then nothing downstream can attribute the reply.
|
|
1155
|
+
//
|
|
1156
|
+
// **The check is the validator now**, and the argument for it is unchanged. It read "here rather
|
|
1157
|
+
// than in a validator because this is one required field on one message"; #444 built the
|
|
1158
|
+
// validator, and the schema for `open-url` demands the correlator before this case is reached.
|
|
1159
|
+
//
|
|
1160
|
+
// Dropping is still the only honest answer: `open-url:error` requires the correlator too, so
|
|
1161
|
+
// answering would mean shipping a frame that violates its own declaration — `JSON.stringify`
|
|
1162
|
+
// erases the absent key, every correlating consumer discards the result, and "agent offline"
|
|
1163
|
+
// becomes a caller waiting out its full deadline. That was the first draft's other half,
|
|
1164
|
+
// `requestId: msg.requestId!` — an assertion in an *outbound* frame, unlike the inbound
|
|
1165
|
+
// `sessionId!` this file used to carry, which fed a read whose miss still produced a visible
|
|
1166
|
+
// error. Neither exists any more.
|
|
1167
|
+
const target = this.dispatchTarget(ws, msg.sessionId);
|
|
1168
|
+
if (target.ok) {
|
|
1169
|
+
target.session.agentSocket.send(JSON.stringify(msg));
|
|
730
1170
|
}
|
|
731
1171
|
else {
|
|
732
|
-
this.sendTo(ws, {
|
|
1172
|
+
this.sendTo(ws, {
|
|
1173
|
+
type: 'open-url:error',
|
|
1174
|
+
sessionId: msg.sessionId,
|
|
1175
|
+
requestId: msg.requestId,
|
|
1176
|
+
message: target.message,
|
|
1177
|
+
});
|
|
733
1178
|
}
|
|
734
1179
|
break;
|
|
735
1180
|
}
|
|
736
1181
|
case 'app:clear-state': {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1182
|
+
// Verbatim forward like `open-url`, so the correlator rides for free; the door check and the echo
|
|
1183
|
+
// are the same two lines.
|
|
1184
|
+
const target = this.dispatchTarget(ws, msg.sessionId);
|
|
1185
|
+
if (target.ok) {
|
|
1186
|
+
target.session.agentSocket.send(JSON.stringify(msg));
|
|
740
1187
|
}
|
|
741
1188
|
else {
|
|
742
|
-
this.sendTo(ws, {
|
|
1189
|
+
this.sendTo(ws, {
|
|
1190
|
+
type: 'app:clear-state-error',
|
|
1191
|
+
sessionId: msg.sessionId,
|
|
1192
|
+
requestId: msg.requestId,
|
|
1193
|
+
message: target.message,
|
|
1194
|
+
});
|
|
743
1195
|
}
|
|
744
1196
|
break;
|
|
745
1197
|
}
|
|
1198
|
+
// ── input, split by whether an ack answers it ──────────────────────────────────────────────
|
|
1199
|
+
//
|
|
1200
|
+
// These eleven shared one clause until L5c, and the sharing was the trap. Only five of them are
|
|
1201
|
+
// answered — the four terminal frames plus `input:type` — so only those five carry a
|
|
1202
|
+
// required `requestId`, and a gate written into a shared body would have dropped every opening
|
|
1203
|
+
// and move frame with it: no swipe, no pinch, no rotation, and nothing said. Splitting the clause
|
|
1204
|
+
// is what makes the gate unable to reach them. `correlatedRequestsGated` resolves fall-through by
|
|
1205
|
+
// sharing the next non-empty body, so it would have read one gate as covering all eleven.
|
|
746
1206
|
case 'input:touch:start':
|
|
747
1207
|
case 'input:touch:move':
|
|
748
|
-
case 'input:touch:end':
|
|
749
1208
|
case 'input:pinch:start':
|
|
750
1209
|
case 'input:pinch:move':
|
|
1210
|
+
case 'input:rotate':
|
|
1211
|
+
case 'input:keyboard:toggle': {
|
|
1212
|
+
// No ack, so no correlator to check and nothing to answer. An unowned frame is dropped rather
|
|
1213
|
+
// than refused for the same reason: there is no waiter to tell.
|
|
1214
|
+
//
|
|
1215
|
+
// These carry no correlator by declaration, so nothing was ever gated on one here — and the
|
|
1216
|
+
// address is now the schema's business rather than this clause's. What the split still buys is
|
|
1217
|
+
// unchanged and is the reason it exists: a correlator gate written into a shared body would have
|
|
1218
|
+
// reached these six as well, dropping every opening and move frame with the answered five.
|
|
1219
|
+
this.forwardUnacked(ws, msg);
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1222
|
+
case 'input:touch:end':
|
|
751
1223
|
case 'input:pinch:end':
|
|
752
1224
|
case 'input:key':
|
|
753
|
-
case 'input:type':
|
|
754
1225
|
case 'input:button':
|
|
755
|
-
case 'input:
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
session.agentSocket.send(JSON.stringify(msg));
|
|
760
|
-
}
|
|
761
|
-
else if (TERMINAL_INPUT_TYPES.has(msg.type) && ws.readyState === WebSocket.OPEN) {
|
|
762
|
-
// Agent offline or session evicted: a terminal input can't be dispatched.
|
|
763
|
-
// Reply to the sender (the MCP/browser socket) so it fails truthfully
|
|
764
|
-
// instead of falling through to its optimistic 2s timeout.
|
|
765
|
-
this.sendTo(ws, { type: 'input:error', sessionId: msg.sessionId, message: 'agent offline' });
|
|
766
|
-
}
|
|
1226
|
+
case 'input:type': {
|
|
1227
|
+
// One policy at the door, as for the app commands: an uncorrelatable request is not forwarded and
|
|
1228
|
+
// not answered, because every reply it could produce declares `requestId` required.
|
|
1229
|
+
this.handleAckedInput(ws, msg);
|
|
767
1230
|
break;
|
|
768
1231
|
}
|
|
769
1232
|
// Kept out of the input:* chain above: these need their own error type, and the caller
|
|
@@ -771,13 +1234,41 @@ export class RelayServer {
|
|
|
771
1234
|
// hanging until the browser gives up.
|
|
772
1235
|
case 'clipboard:read':
|
|
773
1236
|
case 'clipboard:write': {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1237
|
+
// Not part of this layer's pair set — clipboard has carried a required `requestId` since it was
|
|
1238
|
+
// written — but it had the identical defect, and leaving it would make this layer's claim of one
|
|
1239
|
+
// policy at the door false the moment it landed. It answered with `requestId: msg.requestId!`,
|
|
1240
|
+
// which is a **write into an outbound frame**: `JSON.stringify` erases the absent key and ships a
|
|
1241
|
+
// `clipboard:error` whose required correlator is missing, which `useClipboardBridge` discards on
|
|
1242
|
+
// `if (!msg.requestId) return` — so "agent offline" became the caller waiting out its budget.
|
|
1243
|
+
// Removed once already in `e98abd4`, for `open-url`, and still here.
|
|
1244
|
+
// Ownership matters most here of all of them: a `clipboard:write` from a socket that does not hold
|
|
1245
|
+
// the session pastes its text into someone else's device, and a `clipboard:read` presses the copy
|
|
1246
|
+
// or cut chord on it — and the reply routes to the session's own browser, so the payload lands on
|
|
1247
|
+
// **that** tester's host OS clipboard. The agent-side mirror of this check has been here since the
|
|
1248
|
+
// bridge was written, with the reason beside it; the browser side had none.
|
|
1249
|
+
const clip = this.dispatchTarget(ws, msg.sessionId);
|
|
1250
|
+
if (clip.ok) {
|
|
1251
|
+
clip.session.agentSocket.send(JSON.stringify(msg));
|
|
777
1252
|
}
|
|
778
1253
|
else if (ws.readyState === WebSocket.OPEN) {
|
|
779
1254
|
this.sendTo(ws, {
|
|
780
|
-
type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message:
|
|
1255
|
+
type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message: clip.message,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
// Ownership is not decoration here: taking a device off the network is the most disruptive
|
|
1261
|
+
// thing a non-holder could do to someone else's session short of shutting it down, and unlike
|
|
1262
|
+
// a shutdown it leaves the device *looking* fine. `dispatchTarget` rather than the weaker
|
|
1263
|
+
// `reachableTarget` `device:shutdown` uses — there is no teardown race to accommodate.
|
|
1264
|
+
case 'network:set': {
|
|
1265
|
+
const net = this.dispatchTarget(ws, msg.sessionId);
|
|
1266
|
+
if (net.ok) {
|
|
1267
|
+
net.session.agentSocket.send(JSON.stringify(msg));
|
|
1268
|
+
}
|
|
1269
|
+
else if (ws.readyState === WebSocket.OPEN) {
|
|
1270
|
+
this.sendTo(ws, {
|
|
1271
|
+
type: 'network:error', sessionId: msg.sessionId, requestId: msg.requestId, message: net.message,
|
|
781
1272
|
});
|
|
782
1273
|
}
|
|
783
1274
|
break;
|
|
@@ -785,8 +1276,6 @@ export class RelayServer {
|
|
|
785
1276
|
}
|
|
786
1277
|
}
|
|
787
1278
|
handleAgentResources(ws, msg) {
|
|
788
|
-
if (!msg.resources)
|
|
789
|
-
return;
|
|
790
1279
|
this.sessions.setResources(ws, msg.resources);
|
|
791
1280
|
const agentName = this.sessions.getAllByAgentSocket(ws)[0]?.agentName;
|
|
792
1281
|
if (agentName) {
|
|
@@ -902,8 +1391,9 @@ export class RelayServer {
|
|
|
902
1391
|
// the tab sits on "Waiting for first frame..." with no explanation (#426).
|
|
903
1392
|
//
|
|
904
1393
|
// `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
|
|
905
|
-
// `browserSocket` at all. An attached MCP client does have one and will receive this
|
|
906
|
-
//
|
|
1394
|
+
// `browserSocket` at all. An attached MCP client does have one and will receive this — and as of
|
|
1395
|
+
// #512 it **acts** on it, settling that session's pending requests instead of waiting them out. The
|
|
1396
|
+
// clause here used to say the client drops it as unmatched, which was true and was the problem.
|
|
907
1397
|
for (const s of agentSessions) {
|
|
908
1398
|
if (s.browserSocket) {
|
|
909
1399
|
this.sendTo(s.browserSocket, {
|
|
@@ -911,8 +1401,10 @@ export class RelayServer {
|
|
|
911
1401
|
});
|
|
912
1402
|
}
|
|
913
1403
|
}
|
|
914
|
-
for (const s of agentSessions)
|
|
1404
|
+
for (const s of agentSessions) {
|
|
915
1405
|
this.sessions.remove(s.id);
|
|
1406
|
+
this.forgetSessionState(s.id);
|
|
1407
|
+
}
|
|
916
1408
|
this.sessions.removeResources(ws);
|
|
917
1409
|
logger.info(cause === 'replaced'
|
|
918
1410
|
? `agent re-registered — ${agentSessions.length} previous session(s) replaced`
|
|
@@ -987,6 +1479,7 @@ export class RelayServer {
|
|
|
987
1479
|
this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
|
|
988
1480
|
}
|
|
989
1481
|
this.sessions.remove(s.id);
|
|
1482
|
+
this.forgetSessionState(s.id);
|
|
990
1483
|
}
|
|
991
1484
|
}
|
|
992
1485
|
// Only devices without a surviving session get a new one. Passing all of them here is what
|
|
@@ -1012,12 +1505,38 @@ export class RelayServer {
|
|
|
1012
1505
|
// The startup banner prints "Waiting for agents..." once and then the relay says nothing either
|
|
1013
1506
|
// way, so a terminal gives no signal about whether an agent is attached. One line per
|
|
1014
1507
|
// transition, matching the disconnect line in evictAgentSocket.
|
|
1015
|
-
|
|
1508
|
+
// `||`, not `??`: the schema defaults both of these to `''` for an agent that omits them, so `??`
|
|
1509
|
+
// would print an empty name and an empty platform where this used to print `unknown`.
|
|
1510
|
+
logger.info(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
|
|
1016
1511
|
}
|
|
1512
|
+
/** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
|
|
1513
|
+
*
|
|
1514
|
+
* That is what makes the address possible rather than aspirational: `msg.sessionId` is narrowed to a
|
|
1515
|
+
* non-empty `string` by the door — the inbound schema, `isAddressed` before it — so every refusal can
|
|
1516
|
+
* name the join it refuses. Before
|
|
1517
|
+
* L5d they carried none, and the clients' join waiters matched `sessionId === undefined || sessionId ===
|
|
1518
|
+
* mine` — with no such key the left half was always true, so any refusal resolved any pending join. */
|
|
1017
1519
|
handleSessionStart(ws, msg) {
|
|
1018
1520
|
const session = this.sessions.get(msg.sessionId);
|
|
1019
1521
|
if (!session) {
|
|
1020
|
-
this.sendTo(ws, { type: 'error', message: 'Session not found' });
|
|
1522
|
+
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Session not found', reason: 'session-not-found' });
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
// Occupancy first, because it is the more specific answer and both can be true at once. A tester
|
|
1526
|
+
// joining a device someone else has open on a loaded Mac was being told "this Mac is overloaded,
|
|
1527
|
+
// pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
|
|
1528
|
+
// It is a map read the relay has already done, so ordering it first costs nothing.
|
|
1529
|
+
//
|
|
1530
|
+
// `!== ws` because a socket re-joining the session it already holds is not contending with anyone:
|
|
1531
|
+
// `SessionList` sends `session:start` before a shutdown, so pressing shutdown twice hit this.
|
|
1532
|
+
// The same three conditions `join()` applies, and they have to agree: this one answers the caller and
|
|
1533
|
+
// that one performs the bind, so a disagreement is a refusal for a session the bind would have allowed.
|
|
1534
|
+
if (session.browserSocket &&
|
|
1535
|
+
session.owner !== null &&
|
|
1536
|
+
session.owner !== this.ownerOf(ws) &&
|
|
1537
|
+
session.browserSocket.readyState === WebSocket.OPEN &&
|
|
1538
|
+
this.isAlive(session.browserSocket)) {
|
|
1539
|
+
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Session busy', reason: 'session-busy' });
|
|
1021
1540
|
return;
|
|
1022
1541
|
}
|
|
1023
1542
|
// Read before the resource gate, and answered before it too. The gate would otherwise read the
|
|
@@ -1028,15 +1547,35 @@ export class RelayServer {
|
|
|
1028
1547
|
if (resources) {
|
|
1029
1548
|
const memPercent = (resources.memUsedMB / resources.memTotalMB) * 100;
|
|
1030
1549
|
if (resources.cpuPercent > RESOURCE_THRESHOLD || memPercent > RESOURCE_THRESHOLD) {
|
|
1031
|
-
this.sendTo(ws, { type: 'error', message: 'Agent resources exhausted' });
|
|
1550
|
+
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Agent resources exhausted', reason: 'agent-resources-exhausted' });
|
|
1032
1551
|
return;
|
|
1033
1552
|
}
|
|
1034
1553
|
}
|
|
1035
1554
|
try {
|
|
1036
|
-
this.sessions.join(msg.sessionId, ws);
|
|
1555
|
+
const joined = this.sessions.join(msg.sessionId, ws, this.ownerRecord(ws), (h) => this.isAlive(h));
|
|
1556
|
+
if (!joined.ok) {
|
|
1557
|
+
// Both of these are answered above, so arriving here means the state moved between that check and
|
|
1558
|
+
// this call. They are **values now rather than throws** (#515), and that is what fixes the defect:
|
|
1559
|
+
// the most common way into the old `catch` was a socket re-joining the session it already holds —
|
|
1560
|
+
// exempted by the `!== ws` check above, then refused by `join()`, then reported as
|
|
1561
|
+
// `session-not-found` for a live session the caller was holding.
|
|
1562
|
+
const reason = joined.failure === 'held-by-another' ? 'session-busy' : 'session-not-found';
|
|
1563
|
+
const message = joined.failure === 'held-by-another' ? 'Session busy' : 'Session not found';
|
|
1564
|
+
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message, reason });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1037
1567
|
}
|
|
1038
|
-
catch {
|
|
1039
|
-
|
|
1568
|
+
catch (e) {
|
|
1569
|
+
// Only a bug reaches here now — the two expected failures return above instead of throwing, which is
|
|
1570
|
+
// what stops this arm from having to guess. It still answers rather than dropping: both clients await
|
|
1571
|
+
// `session:joined | error` on this request, so silence costs them a full deadline.
|
|
1572
|
+
//
|
|
1573
|
+
// `session-not-found` is chosen for **the action it names**, not as a diagnosis. Its own declaration
|
|
1574
|
+
// says "nothing else is ever coming for it", and that half is true of any failed join — no
|
|
1575
|
+
// `session:joined` will follow. `session-busy` would be the wrong action: it tells a viewer the
|
|
1576
|
+
// session is alive and someone else has it. The prose carries what is actually known.
|
|
1577
|
+
logger.error(`session:start could not join ${msg.sessionId}:`, e);
|
|
1578
|
+
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Session could not be joined', reason: 'session-not-found' });
|
|
1040
1579
|
return;
|
|
1041
1580
|
}
|
|
1042
1581
|
// Include the agent's capabilities so the viewer knows up front what is implemented on
|
|
@@ -1057,11 +1596,15 @@ export class RelayServer {
|
|
|
1057
1596
|
this.sendTo(ws, { type: 'session:agent-away', sessionId: msg.sessionId });
|
|
1058
1597
|
return;
|
|
1059
1598
|
}
|
|
1599
|
+
// These three replay a session's cached state to a re-joining viewer. They carry `sessionId`
|
|
1600
|
+
// because both agents stamp it on every copy they send, and the relay was the only producer that
|
|
1601
|
+
// did not — so the shared declaration had to be `sessionId?` to stay honest about the two of us.
|
|
1602
|
+
// Stamping it here is what let that be tightened to required.
|
|
1060
1603
|
if (session.chromeData) {
|
|
1061
|
-
this.sendTo(ws, { type: 'session:chrome', payload: session.chromeData });
|
|
1604
|
+
this.sendTo(ws, { type: 'session:chrome', sessionId: session.id, payload: session.chromeData });
|
|
1062
1605
|
}
|
|
1063
1606
|
if (session.deviceInfo) {
|
|
1064
|
-
this.sendTo(ws, { type: 'session:deviceInfo', payload: session.deviceInfo });
|
|
1607
|
+
this.sendTo(ws, { type: 'session:deviceInfo', sessionId: session.id, payload: session.deviceInfo });
|
|
1065
1608
|
}
|
|
1066
1609
|
// Replay device:ready only if this session actually announced one (browser WS blip reconnect).
|
|
1067
1610
|
// Not `deviceStatus`: that starts from the agent's `simctl list` snapshot, so a session for a
|
|
@@ -1073,11 +1616,301 @@ export class RelayServer {
|
|
|
1073
1616
|
// keyframe immediately, instead of waiting for the next periodic one — and so it isn't
|
|
1074
1617
|
// left blank when the encoder is static-skipping an unchanged screen. Agents that don't
|
|
1075
1618
|
// support on-demand IDR ignore the message.
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1619
|
+
//
|
|
1620
|
+
// **Through the session's throttled requester, not a bare `sendTo`.** This was unreachable for a
|
|
1621
|
+
// socket that already held the session — `join()` threw and the handler answered above this line —
|
|
1622
|
+
// and #515 made a re-join run the whole body. A client re-sending `session:start` therefore drives
|
|
1623
|
+
// one forced keyframe per frame it sends, and a 1080p IDR is two orders of magnitude larger than
|
|
1624
|
+
// the P-frames around it, so the amplification lands on the tester who is actually watching. The
|
|
1625
|
+
// backpressure path already throttles this exact message per session; sharing that requester is
|
|
1626
|
+
// what keeps the two callers from having two policies.
|
|
1627
|
+
this.idrRequester(session.id)();
|
|
1628
|
+
// And ask what the device's network is doing (#614). `NetworkState` is agent-produced and is not
|
|
1629
|
+
// among the three replayed above, so without this a viewer that reconnects has no way to learn
|
|
1630
|
+
// whether the device is offline and its control renders in a guessed position.
|
|
1631
|
+
//
|
|
1632
|
+
// **Gated on the capability, unlike the IDR request beside it.** That one is safe to send blind
|
|
1633
|
+
// because an agent without it loses nothing — the next periodic keyframe arrives anyway. This one
|
|
1634
|
+
// has no such repair, so an agent that never answers is indistinguishable from one that failed to
|
|
1635
|
+
// read; a viewer arming a deadline would then render "could not read" on every re-join, for an
|
|
1636
|
+
// agent that already said it cannot do this at all. iOS is exactly that agent until #607's last
|
|
1637
|
+
// slice. Fifty lines up, `session:joined` carries these capabilities for the stated reason that a
|
|
1638
|
+
// consumer must not infer support from a timeout — asking anyway would make the relay do it.
|
|
1639
|
+
//
|
|
1640
|
+
// **Inside `readySent` too, and there as an optimisation rather than a correctness condition.**
|
|
1641
|
+
// `readySent` is the *stream's* readiness: `clearStreamSocket` lowers it while `deviceStatus`
|
|
1642
|
+
// stays `'booted'`, so a booted, genuinely offline device with a dead stream socket is not asked
|
|
1643
|
+
// about here — and the agent would have answered, since it still holds that serial. What makes
|
|
1644
|
+
// skipping safe is not the agent's silence but the viewer's: with `readySent` false the
|
|
1645
|
+
// `device:ready` above is not replayed either, so nothing is rendering a live device to be wrong
|
|
1646
|
+
// about. `deviceStatus` is not the fix — the comment on `device:ready` says why (#440).
|
|
1647
|
+
if (session.agentCapabilities?.includes('network-control'))
|
|
1648
|
+
this.networkStateRequester(session.id)();
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
|
|
1652
|
+
* of the close handler so the release loop there reads as one line per session. */
|
|
1653
|
+
idleShutdown(sessionId) {
|
|
1654
|
+
const session = this.sessions.get(sessionId);
|
|
1655
|
+
if (session?.agentSocket.readyState !== WebSocket.OPEN)
|
|
1656
|
+
return;
|
|
1657
|
+
this.sendTo(session.agentSocket, {
|
|
1658
|
+
type: 'device:shutdown',
|
|
1659
|
+
sessionId: session.id,
|
|
1660
|
+
payload: { deviceId: session.deviceId },
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
/** Where a browser-role command can be dispatched, or what its sender needs to be told instead.
|
|
1664
|
+
*
|
|
1665
|
+
* Three refusals used to be spread across seven `case` bodies as two conditions each, and the ownership
|
|
1666
|
+
* one was in none of them. Collapsed here so the set is decided once: a command reaches an agent only if
|
|
1667
|
+
* the session exists, **this socket holds it**, and the agent is connected. Anything else has prose the
|
|
1668
|
+
* caller can act on, and each case wraps that prose in the reply type its own waiter reads.
|
|
1669
|
+
*
|
|
1670
|
+
* The two ownership strings are one reason with different prose, the treatment `#492` settled: telling a
|
|
1671
|
+
* caller the session is in use when it is idle steers it off a device it could have had.
|
|
1672
|
+
*/
|
|
1673
|
+
dispatchTarget(ws, sessionId) {
|
|
1674
|
+
// Ownership first and liveness second, which is the order the refusals were written in and worth
|
|
1675
|
+
// keeping: a non-owner is told it does not hold the session rather than being handed the agent's
|
|
1676
|
+
// state. Composed rather than inlined so `device:shutdown` can take the other two checks alone.
|
|
1677
|
+
const session = this.sessions.get(sessionId);
|
|
1678
|
+
if (session && !this.ownsSession(ws, session))
|
|
1679
|
+
return { ok: false, message: ownershipRefusal(session) };
|
|
1680
|
+
return this.reachableTargetWithoutOwnership(sessionId);
|
|
1681
|
+
}
|
|
1682
|
+
/** `dispatchTarget` without the ownership clause: is there a session here, and is its agent listening?
|
|
1683
|
+
*
|
|
1684
|
+
* **The name carries the omission because a doc block does not reach an autocomplete.** This is the
|
|
1685
|
+
* resolver a future handler must not pick by accident — it is the general-looking one and the unsafe
|
|
1686
|
+
* one at the same time, which is the pairing worth spelling out.
|
|
1687
|
+
*
|
|
1688
|
+
* **Still here after #527, and this paragraph used to say it would not be.** It predicted that
|
|
1689
|
+
* answering "who may shut a device down" would move `device:shutdown` up to `dispatchTarget`. The
|
|
1690
|
+
* answer turned out to be a *different* gate rather than that one: `mayShutDown` refuses a session
|
|
1691
|
+
* another client holds and permits an unheld one, because the dashboard's unmount teardown races its
|
|
1692
|
+
* own viewer socket's close and an owns-it gate refuses the teardown whenever the close lands first —
|
|
1693
|
+
* leaving the device booted until the idle timer, which is the cost #527 was filed to avoid.
|
|
1694
|
+
*
|
|
1695
|
+
* So the two are ordered rather than merged: `mayShutDown` decides *who*, this decides *whether it can
|
|
1696
|
+
* be delivered at all*, and without the second a shutdown addressed to a missing session or a closed
|
|
1697
|
+
* agent socket is dropped in silence and `mcp-server`'s caller burns a 30s deadline (#542).
|
|
1698
|
+
*
|
|
1699
|
+
* The disclosure it was written to record stands: a caller that is not the holder still learns whether
|
|
1700
|
+
* a session id exists and whether its agent is up.
|
|
1701
|
+
*/
|
|
1702
|
+
reachableTargetWithoutOwnership(sessionId) {
|
|
1703
|
+
const session = this.sessions.get(sessionId);
|
|
1704
|
+
if (!session)
|
|
1705
|
+
return { ok: false, message: 'Session not found' };
|
|
1706
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
1707
|
+
return { ok: false, message: 'agent offline' };
|
|
1708
|
+
return { ok: true, session };
|
|
1709
|
+
}
|
|
1710
|
+
/** Is `ws` the socket this session is bound to?
|
|
1711
|
+
*
|
|
1712
|
+
* Until L5c the input path resolved the session and forwarded without asking. `clipboard:data` asks the
|
|
1713
|
+
* mirror-image question one branch up (`session?.agentSocket !== ws`) with the reason written beside it —
|
|
1714
|
+
* a second agent on the same relay must not address someone else's session — and the browser direction
|
|
1715
|
+
* had no equivalent. So any authenticated client that knew a session id could drive a device another
|
|
1716
|
+
* tester was looking at, and the agent's ack went to the session holder rather than to whoever asked.
|
|
1717
|
+
*
|
|
1718
|
+
* An **unheld** session (no `browserSocket`, or one that has gone) is also not owned by the sender. That
|
|
1719
|
+
* is deliberate rather than incidental: it makes an input arriving inside the reconnect grace fail fast
|
|
1720
|
+
* instead of being applied to a device with nobody watching the result. */
|
|
1721
|
+
ownsSession(ws, session) {
|
|
1722
|
+
return session?.owner !== null && session?.owner === this.ownerOf(ws);
|
|
1723
|
+
}
|
|
1724
|
+
/** Which owner this socket speaks for. Every connection has one — supplied or minted — so this never
|
|
1725
|
+
* answers `undefined` and two anonymous sockets can never match by both lacking an identity. */
|
|
1726
|
+
ownerOf(ws) {
|
|
1727
|
+
return this.ownerRecord(ws).key;
|
|
1728
|
+
}
|
|
1729
|
+
/** The principal behind a socket — a user id, or `anon` for a connection carrying no cookie and no PAT. */
|
|
1730
|
+
userOf(ws) {
|
|
1731
|
+
return this.ownerRecord(ws).user;
|
|
1732
|
+
}
|
|
1733
|
+
/** Everything the bind needs to record about who is joining.
|
|
1734
|
+
*
|
|
1735
|
+
* **A socket with no record gets one of its own rather than a shared sentinel.** `handleConnection`
|
|
1736
|
+
* seeds every accepted connection before a message can arrive, so the fallback is unreachable today —
|
|
1737
|
+
* but a literal would make any two unseeded sockets *equal*, and equal keys pass `ownsSession` while
|
|
1738
|
+
* equal users pass `mayShutDown`'s same-principal exemption. That rests the whole ownership invariant
|
|
1739
|
+
* on one call site staying total, which is the shape of dependency the sentinel is supposed to remove.
|
|
1740
|
+
* Minting keeps the answer "this socket and no other"; storing it keeps repeat calls consistent, since
|
|
1741
|
+
* a fresh value each time would fail to recognise the very socket that bound the session. `minted` is
|
|
1742
|
+
* `false` deliberately — the legacy exemption exists for a client that identified itself with nothing,
|
|
1743
|
+
* not for one the relay failed to record. */
|
|
1744
|
+
ownerRecord(ws) {
|
|
1745
|
+
const known = this.ownerKey.get(ws);
|
|
1746
|
+
if (known)
|
|
1747
|
+
return known;
|
|
1748
|
+
const unseeded = `unidentified:${randomUUID()}`;
|
|
1749
|
+
const record = { key: unseeded, user: unseeded, minted: false };
|
|
1750
|
+
this.ownerKey.set(ws, record);
|
|
1751
|
+
return record;
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* May this socket shut the session's device down?
|
|
1755
|
+
*
|
|
1756
|
+
* **"Not someone else's" rather than "mine", and the difference is the reason #527 stayed open.** The
|
|
1757
|
+
* dashboard's unmount teardown sends `device:shutdown` from one socket while another socket's `close`
|
|
1758
|
+
* races it to the relay — two connections, no ordering — and if the close lands first the session is
|
|
1759
|
+
* unheld. An owns-it gate refuses the teardown there, and the device stays booted until the five-minute
|
|
1760
|
+
* idle timer: the path #527 named as the reason a gate could not be added at all.
|
|
1761
|
+
*
|
|
1762
|
+
* An unheld session therefore stays shutdownable by anyone authenticated, which is unchanged from today
|
|
1763
|
+
* — this command has never had a gate — and is written down rather than implied. What changes is the
|
|
1764
|
+
* case the issue is about: a device another tester is *holding* can no longer be powered off.
|
|
1765
|
+
*/
|
|
1766
|
+
mayShutDown(ws, session) {
|
|
1767
|
+
if (session?.owner == null)
|
|
1768
|
+
return true;
|
|
1769
|
+
if (session.owner === this.ownerOf(ws))
|
|
1770
|
+
return true;
|
|
1771
|
+
// **A holder that never identified itself is not gated**, because it cannot pass a gate: every one of
|
|
1772
|
+
// its sockets was minted a separate identity, so its own teardown socket is a stranger to the socket
|
|
1773
|
+
// holding the session — deterministically, not as a race. An older dashboard bundle re-attaching to an
|
|
1774
|
+
// upgraded relay is exactly that, and refusing it would leave a device booted until the idle timer on
|
|
1775
|
+
// every Back press: the cost #527 was filed to avoid, reintroduced by #527's fix.
|
|
1776
|
+
//
|
|
1777
|
+
// **Scoped to the same principal, though.** The exemption exists so a legacy client's own teardown
|
|
1778
|
+
// works, and its own teardown is by definition the same signed-in user — so extending it across users
|
|
1779
|
+
// buys nothing and would leave one tester able to power off another's device for as long as anyone is
|
|
1780
|
+
// running an older build. That is unchanged from before this gate existed, but "unchanged" is not a
|
|
1781
|
+
// reason to carry it forward when the narrower rule costs one comparison.
|
|
1782
|
+
return session.ownerMinted && session.ownerUser === this.userOf(ws);
|
|
1783
|
+
}
|
|
1784
|
+
/** Input that no ack answers: opening and move frames, rotation, the keyboard-forwarding toggle.
|
|
1785
|
+
*
|
|
1786
|
+
* Dropped rather than refused when unowned, and that asymmetry with `handleAckedInput` is the whole
|
|
1787
|
+
* reason these have their own clause: refusing means answering, and there is no waiter here to answer.
|
|
1788
|
+
* `clipboard:data`'s silent `break` is the precedent that fits — a frame nobody is waiting on. */
|
|
1789
|
+
forwardUnacked(ws, msg) {
|
|
1790
|
+
const session = this.sessions.get(msg.sessionId);
|
|
1791
|
+
if (session && !this.ownsSession(ws, session)) {
|
|
1792
|
+
// **Deliberately silent.** A first draft logged here, which is one line per `input:touch:move` — the
|
|
1793
|
+
// dashboard sends those per `pointermove`, so ~60/s for as long as a finger is down, unbounded and
|
|
1794
|
+
// outside `logger`. It says nothing new either: a gesture's terminal frame goes through
|
|
1795
|
+
// `handleAckedInput`, which answers, so a non-owner attempting real input is already visible there.
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
1799
|
+
session.agentSocket.send(JSON.stringify(msg));
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
/** The five inputs an ack answers, so the five that carry a required correlator.
|
|
1803
|
+
*
|
|
1804
|
+
* Every exit answers the sender. That is the rule this method exists to hold: a request with a waiter on
|
|
1805
|
+
* a 2s deadline must not be dropped silently, because the caller's fallback reports silence from a
|
|
1806
|
+
* session that has never acked as **success** (#457) — so a silent drop here would report an input that
|
|
1807
|
+
* never left the relay as landed, which is worse than the misrouting it replaced. */
|
|
1808
|
+
handleAckedInput(ws, msg) {
|
|
1809
|
+
// No `!`: the parameter is narrowed by the door, which is the parse now rather than a predicate.
|
|
1810
|
+
const session = this.sessions.get(msg.sessionId);
|
|
1811
|
+
if (session && !this.ownsSession(ws, session)) {
|
|
1812
|
+
// `not-session-owner` rather than `channel-unavailable`, on that set's own rule — a reason exists per
|
|
1813
|
+
// thing a consumer must do differently. That one means reconnect or re-join *this* session; this one
|
|
1814
|
+
// means the caller does not hold it, so the move is to join first. It is also the only reason that can
|
|
1815
|
+
// promise nothing reached the device, because the refusal happens here, before any agent saw the frame.
|
|
1816
|
+
//
|
|
1817
|
+
// **Two situations, one reason, different prose** — the treatment `#492` settled for the pair below.
|
|
1818
|
+
// Someone else holding the session and nobody holding it want the same *action* (join, and if that is
|
|
1819
|
+
// refused pick another device), so splitting the reason would grow the vocabulary for nothing. But one
|
|
1820
|
+
// string cannot be true of both: telling a caller the session is in use when it is idle steers it off
|
|
1821
|
+
// a device it could have had.
|
|
1822
|
+
this.refuseInput(ws, msg, ownershipRefusal(session), 'not-session-owner');
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
1826
|
+
session.agentSocket.send(JSON.stringify(msg));
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
// A request that cannot be dispatched. Two situations reach here and only one is the agent's fault:
|
|
1830
|
+
// the session may be held with a socket that is no longer open, or there may be no such session —
|
|
1831
|
+
// evicted after the reconnect grace, or never valid. In the second the agent can be perfectly healthy,
|
|
1832
|
+
// so `agent offline` is a wrong diagnosis, and it is the same pair `device:boot` tells apart above for
|
|
1833
|
+
// the same reason. Same two strings as there.
|
|
1834
|
+
//
|
|
1835
|
+
// The reason is `channel-unavailable` either way, and that is the point rather than an approximation:
|
|
1836
|
+
// the set is derived from what a consumer must do differently, and both of these want a reconnect or a
|
|
1837
|
+
// re-join. The machine field was right for both while the prose was wrong for one (#492).
|
|
1838
|
+
this.refuseInput(ws, msg, session ? 'agent offline' : 'Session not found', 'channel-unavailable');
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Tells the sender its payload was refused, in the shape that request's own waiter reads.
|
|
1842
|
+
*
|
|
1843
|
+
* **This is what keeps the door from turning an answered failure into silence.** Before it, a
|
|
1844
|
+
* malformed `open-url` reached the agent and the agent's own guard answered `open-url:error`;
|
|
1845
|
+
* `IOSAgent.ts` says so beside that guard, and names this validation as what would take it over.
|
|
1846
|
+
* Taking the responsibility without taking the answer would have been a regression — worst on the
|
|
1847
|
+
* inputs, and not obviously: `awaitInputAck` reports silence from a session that has never acked as
|
|
1848
|
+
* **success** (#457), so a dropped `input:key` reads to an MCP caller as an input that landed.
|
|
1849
|
+
*
|
|
1850
|
+
* The address and the correlator come from the envelope, which `parseInbound` judges separately from
|
|
1851
|
+
* the payload for exactly this reason — a frame with a good envelope carries everything a reply
|
|
1852
|
+
* needs. `reason: 'malformed'` is not a new member; the input vocabulary already had it, and until
|
|
1853
|
+
* now only agents produced it.
|
|
1854
|
+
*
|
|
1855
|
+
* No ownership check, deliberately: the reply goes to the socket that sent the frame and says only
|
|
1856
|
+
* that its own message was malformed, so it discloses nothing about the session. Every other refusal
|
|
1857
|
+
* in this file answers a question about the session's state and is gated for that reason.
|
|
1858
|
+
*/
|
|
1859
|
+
refuseMalformed(ws, f) {
|
|
1860
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1861
|
+
return;
|
|
1862
|
+
const { sessionId, requestId } = f;
|
|
1863
|
+
const message = `malformed ${f.type} payload`;
|
|
1864
|
+
switch (f.type) {
|
|
1865
|
+
case 'device:boot':
|
|
1866
|
+
this.sendTo(ws, { type: 'device:boot-error', sessionId, requestId, message });
|
|
1867
|
+
break;
|
|
1868
|
+
case 'app:install':
|
|
1869
|
+
this.sendTo(ws, { type: 'app:install-error', sessionId, requestId, message });
|
|
1870
|
+
break;
|
|
1871
|
+
case 'app:launch':
|
|
1872
|
+
this.sendTo(ws, { type: 'app:launch-error', sessionId, requestId, message });
|
|
1873
|
+
break;
|
|
1874
|
+
case 'app:clear-state':
|
|
1875
|
+
this.sendTo(ws, { type: 'app:clear-state-error', sessionId, requestId, message });
|
|
1876
|
+
break;
|
|
1877
|
+
case 'open-url':
|
|
1878
|
+
this.sendTo(ws, { type: 'open-url:error', sessionId, requestId, message });
|
|
1879
|
+
break;
|
|
1880
|
+
// Its waiters key on the `input:type-*` pair and ignore an `input:error` entirely — the same
|
|
1881
|
+
// reason `refuseInput` below splits these two.
|
|
1882
|
+
case 'input:type':
|
|
1883
|
+
this.sendTo(ws, { type: 'input:type-error', sessionId, requestId, message, reason: 'malformed' });
|
|
1884
|
+
break;
|
|
1885
|
+
case 'clipboard:read':
|
|
1886
|
+
case 'clipboard:write':
|
|
1887
|
+
this.sendTo(ws, { type: 'clipboard:error', sessionId, requestId, message });
|
|
1888
|
+
break;
|
|
1889
|
+
case 'network:set':
|
|
1890
|
+
this.sendTo(ws, { type: 'network:error', sessionId, requestId, message });
|
|
1891
|
+
break;
|
|
1892
|
+
// The four remaining acked inputs. A `default` rather than four labels because the union is
|
|
1893
|
+
// closed and exhaustive: adding a fourteenth answerable request without a case here would land
|
|
1894
|
+
// it on `input:error`, which `answerableRequestsAnswered` is what stops.
|
|
1895
|
+
default:
|
|
1896
|
+
this.sendTo(ws, { type: 'input:error', sessionId, requestId, message, reason: 'malformed' });
|
|
1079
1897
|
}
|
|
1080
1898
|
}
|
|
1899
|
+
/** Answers the sender in the shape its waiter is keyed on.
|
|
1900
|
+
*
|
|
1901
|
+
* `input:type` needs `input:type-error`, not `input:error` — its waiters in `mcp-server` and
|
|
1902
|
+
* `flow-runner` match on the `input:type-*` pair and ignore an `input:error` entirely. That is why
|
|
1903
|
+
* adding `input:type` to the terminal set was never the fix for it, and answering it properly here is
|
|
1904
|
+
* closes the gap `relay/AGENTS.md` recorded: before L5c an `input:type` on an offline agent got nothing
|
|
1905
|
+
* and burned its caller's full deadline. */
|
|
1906
|
+
refuseInput(ws, msg, message, reason) {
|
|
1907
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1908
|
+
return;
|
|
1909
|
+
const { sessionId, requestId } = { sessionId: msg.sessionId, requestId: msg.requestId };
|
|
1910
|
+
this.sendTo(ws, msg.type === 'input:type'
|
|
1911
|
+
? { type: 'input:type-error', sessionId, requestId, message, reason }
|
|
1912
|
+
: { type: 'input:error', sessionId, requestId, message, reason });
|
|
1913
|
+
}
|
|
1081
1914
|
/** Relay looks up file_path from DB and enriches it for the agent.
|
|
1082
1915
|
*
|
|
1083
1916
|
* Every exit carries the request's `sessionId`, including the failures. A dashboard viewer holds
|
|
@@ -1087,16 +1920,29 @@ export class RelayServer {
|
|
|
1087
1920
|
* same reason: a generic `error` cannot be correlated by construction. */
|
|
1088
1921
|
handleBrowserAppInstall(ws, msg) {
|
|
1089
1922
|
const sessionId = msg.sessionId;
|
|
1090
|
-
const
|
|
1923
|
+
const { requestId } = msg;
|
|
1924
|
+
// Closing over the narrowed correlator covers all four failure exits at once. It does **not** cover a
|
|
1925
|
+
// fifth: a throw out of `getDb().prepare(…).get(…)` — SQLITE_BUSY, a closed db, I/O — unwinds to the
|
|
1926
|
+
// message-loop catch and answers nothing at all. Pre-existing and unchanged here, but "every exit
|
|
1927
|
+
// carries the request's id" would be false.
|
|
1928
|
+
const fail = (message) => this.sendTo(ws, { type: 'app:install-error', sessionId, requestId, message });
|
|
1091
1929
|
const session = this.sessions.get(sessionId);
|
|
1092
1930
|
if (!session)
|
|
1093
1931
|
return fail('Session not found');
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1099
|
-
|
|
1932
|
+
// Ownership only, and **not** `dispatchTarget`: that resolver also decides agent liveness, and using it
|
|
1933
|
+
// here would move the `agent offline` check ahead of the build lookup below — changing which of two
|
|
1934
|
+
// simultaneous problems the caller is told about. Until L5c this branch asked whether the session
|
|
1935
|
+
// existed and not who was asking, so a socket that never joined could install a build onto a device
|
|
1936
|
+
// someone else was testing, with the reply going to that session's browser rather than to it.
|
|
1937
|
+
if (!this.ownsSession(ws, session))
|
|
1938
|
+
return fail(ownershipRefusal(session));
|
|
1939
|
+
// The schema already refused a non-integer `buildId`, so this is now belt-and-braces rather than
|
|
1940
|
+
// the only guard. Kept because it is also the *answer*: the parser drops a bad frame silently and
|
|
1941
|
+
// No `Number.isInteger` guard here any more: `buildId` is `z.number().int()` at the door, and a bad
|
|
1942
|
+
// one is answered there by `refuseMalformed` with a diagnosis this branch could not give — "not
|
|
1943
|
+
// found" describes a lookup, and for a malformed id no lookup ran. The guard existed because
|
|
1944
|
+
// better-sqlite3 binds a missing value as NULL but **throws** on an object or array, and that
|
|
1945
|
+
// exception was swallowed by the message-loop catch; the door makes both unreachable.
|
|
1100
1946
|
const build = getDb()
|
|
1101
1947
|
.prepare('SELECT file_path, bundle_id FROM builds WHERE id = ?')
|
|
1102
1948
|
.get(msg.buildId);
|
|
@@ -1105,23 +1951,38 @@ export class RelayServer {
|
|
|
1105
1951
|
// Answer now rather than letting the caller time out — the same shape as `open-url` above.
|
|
1106
1952
|
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
1107
1953
|
return fail('agent offline');
|
|
1954
|
+
// The correlator rides across the rebuild. `open-url` got this for free — the relay re-serialises
|
|
1955
|
+
// that message whole — but this is a *different* message from the one the browser sent, and the
|
|
1956
|
+
// agent's reply is forwarded back generically without the relay looking at it. So if the id does not
|
|
1957
|
+
// reach the agent, nothing downstream can attribute the reply. Nothing type-checks that the value is
|
|
1958
|
+
// the *request's*: a brand cannot express provenance, so a test carries it.
|
|
1108
1959
|
this.sendTo(session.agentSocket, {
|
|
1109
1960
|
type: 'app:install',
|
|
1110
1961
|
sessionId,
|
|
1962
|
+
requestId,
|
|
1111
1963
|
payload: { filePath: build.file_path, bundleId: build.bundle_id },
|
|
1112
1964
|
});
|
|
1113
1965
|
}
|
|
1114
1966
|
/** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
|
|
1115
1967
|
handleBrowserAppLaunch(ws, msg) {
|
|
1116
1968
|
const sessionId = msg.sessionId;
|
|
1117
|
-
const
|
|
1969
|
+
const { requestId } = msg;
|
|
1970
|
+
// Closing over the narrowed correlator covers all four failure exits at once. It does **not** cover a
|
|
1971
|
+
// fifth: a throw out of `getDb().prepare(…).get(…)` — SQLITE_BUSY, a closed db, I/O — unwinds to the
|
|
1972
|
+
// message-loop catch and answers nothing at all. Pre-existing and unchanged here, but "every exit
|
|
1973
|
+
// carries the request's id" would be false.
|
|
1974
|
+
const fail = (message) => this.sendTo(ws, { type: 'app:launch-error', sessionId, requestId, message });
|
|
1118
1975
|
const session = this.sessions.get(sessionId);
|
|
1119
1976
|
if (!session)
|
|
1120
1977
|
return fail('Session not found');
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1124
|
-
|
|
1978
|
+
// Ownership only, and **not** `dispatchTarget`: that resolver also decides agent liveness, and using it
|
|
1979
|
+
// here would move the `agent offline` check ahead of the build lookup below — changing which of two
|
|
1980
|
+
// simultaneous problems the caller is told about. Until L5c this branch asked whether the session
|
|
1981
|
+
// existed and not who was asking, so a socket that never joined could install a build onto a device
|
|
1982
|
+
// someone else was testing, with the reply going to that session's browser rather than to it.
|
|
1983
|
+
if (!this.ownsSession(ws, session))
|
|
1984
|
+
return fail(ownershipRefusal(session));
|
|
1985
|
+
// See `handleBrowserAppInstall` — the door refuses a malformed id and answers it.
|
|
1125
1986
|
const build = getDb()
|
|
1126
1987
|
.prepare('SELECT bundle_id FROM builds WHERE id = ?')
|
|
1127
1988
|
.get(msg.buildId);
|
|
@@ -1129,9 +1990,12 @@ export class RelayServer {
|
|
|
1129
1990
|
return fail('Bundle ID not available for this build');
|
|
1130
1991
|
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
1131
1992
|
return fail('agent offline');
|
|
1993
|
+
// See `handleBrowserAppInstall` — the correlator rides across the rebuild, and only a test says the
|
|
1994
|
+
// value is the request's.
|
|
1132
1995
|
this.sendTo(session.agentSocket, {
|
|
1133
1996
|
type: 'app:launch',
|
|
1134
1997
|
sessionId,
|
|
1998
|
+
requestId,
|
|
1135
1999
|
payload: { bundleId: build.bundle_id },
|
|
1136
2000
|
});
|
|
1137
2001
|
}
|
|
@@ -1226,37 +2090,46 @@ export class RelayServer {
|
|
|
1226
2090
|
});
|
|
1227
2091
|
}
|
|
1228
2092
|
handleUITreeResponse(msg) {
|
|
1229
|
-
if (!msg.requestId)
|
|
1230
|
-
return;
|
|
1231
2093
|
const pending = this.pendingUITrees.get(msg.requestId);
|
|
1232
2094
|
if (!pending)
|
|
1233
2095
|
return;
|
|
1234
|
-
pending.resolve(msg.elements
|
|
2096
|
+
pending.resolve(msg.elements);
|
|
1235
2097
|
}
|
|
1236
2098
|
handleUITreeError(msg) {
|
|
1237
|
-
if (!msg.requestId)
|
|
1238
|
-
return;
|
|
1239
2099
|
const pending = this.pendingUITrees.get(msg.requestId);
|
|
1240
2100
|
if (!pending)
|
|
1241
2101
|
return;
|
|
1242
|
-
|
|
2102
|
+
// `||`, not `??`: the schema defaults an absent message to `''` rather than leaving it undefined,
|
|
2103
|
+
// so the fallback has to treat empty as absent or an older agent's error would read as blank.
|
|
2104
|
+
pending.reject(new Error(msg.message || 'UI tree query failed'));
|
|
1243
2105
|
}
|
|
1244
2106
|
handleScreenshotDone(msg) {
|
|
1245
|
-
if (!msg.requestId)
|
|
1246
|
-
return;
|
|
1247
2107
|
const pending = this.pendingScreenshots.get(msg.requestId);
|
|
1248
2108
|
if (!pending)
|
|
1249
2109
|
return;
|
|
1250
|
-
|
|
1251
|
-
|
|
2110
|
+
// The `?? ''` / `?? 'png'` these two used to carry are now `.default()`s in the schema, where a
|
|
2111
|
+
// reader can see that the tolerance is for an older agent rather than for any absent field.
|
|
2112
|
+
const buf = Buffer.from(msg.data, 'base64');
|
|
2113
|
+
const claimed = msg.format;
|
|
2114
|
+
// Logged, **not** overwritten. The field means what the agent says it produced, and correcting it
|
|
2115
|
+
// here would make the relay the authority on something only the agent can know — a contract
|
|
2116
|
+
// change, where this is a drift detector. It costs four bytes of an already-decoded buffer.
|
|
2117
|
+
//
|
|
2118
|
+
// Worth having because the consumer-side fix for #508 sniffs the bytes and would otherwise hide a
|
|
2119
|
+
// lying agent forever: #508 was found by a person noticing, and nothing would have reported it.
|
|
2120
|
+
const actual = sniffImageFormat(buf);
|
|
2121
|
+
if (actual !== null && actual !== claimed) {
|
|
2122
|
+
logger.warn(`[relay] screenshot from session ${msg.sessionId ?? '?'} is ${actual} but the agent called it ` +
|
|
2123
|
+
`${claimed} — the Content-Type will be wrong. Upgrade the agent (#508).`);
|
|
2124
|
+
}
|
|
2125
|
+
pending.resolve(buf, claimed);
|
|
1252
2126
|
}
|
|
1253
2127
|
handleScreenshotError(msg) {
|
|
1254
|
-
if (!msg.requestId)
|
|
1255
|
-
return;
|
|
1256
2128
|
const pending = this.pendingScreenshots.get(msg.requestId);
|
|
1257
2129
|
if (!pending)
|
|
1258
2130
|
return;
|
|
1259
|
-
|
|
2131
|
+
// `||` for the reason `handleUITreeError` gives.
|
|
2132
|
+
pending.reject(new Error(msg.message || 'Screenshot failed'));
|
|
1260
2133
|
}
|
|
1261
2134
|
flushResourceBuffers() {
|
|
1262
2135
|
if (this.resourceBuffers.size === 0)
|