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