@tapflowio/relay 0.17.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 +313 -2
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +1245 -211
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +124 -21
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +201 -22
- 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 +3 -44
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/package.json +10 -8
- package/public/assets/{AppCenter-BRmG2FEm.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-D1iwg2vL.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-BZV6gt41.js → build-format-CXuQt1X2.js} +3 -3
- package/public/assets/build-format-CXuQt1X2.js.br +0 -0
- package/public/assets/dialog-DuXZpbiF.js +11 -0
- package/public/assets/dialog-DuXZpbiF.js.br +0 -0
- package/public/assets/index-Bk2hqaHZ.css +1 -0
- 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-BGrOEXS6.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-CwlFrbEF.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-BRmG2FEm.js.br +0 -0
- package/public/assets/Default-DTr0wqds.js +0 -1
- package/public/assets/Default-DTr0wqds.js.br +0 -0
- package/public/assets/Invite-DKsNh2GB.js +0 -1
- package/public/assets/Invite-DKsNh2GB.js.br +0 -0
- package/public/assets/MacResources-CLDdcibL.js +0 -5
- package/public/assets/MacResources-CLDdcibL.js.br +0 -0
- package/public/assets/NotFound-BtKq2uv6.js +0 -1
- package/public/assets/NotFound-BtKq2uv6.js.br +0 -0
- package/public/assets/QASession-B_5aJ67l.js +0 -130
- package/public/assets/QASession-B_5aJ67l.js.br +0 -0
- package/public/assets/ResetPassword-gp6hVQrf.js +0 -1
- package/public/assets/ResetPassword-gp6hVQrf.js.br +0 -0
- package/public/assets/Setup-DiG4Nbs7.js +0 -1
- package/public/assets/Setup-DiG4Nbs7.js.br +0 -0
- package/public/assets/Team-D1iwg2vL.js.br +0 -0
- package/public/assets/Tokens-DGtE4Xcy.js +0 -1
- package/public/assets/Tokens-DGtE4Xcy.js.br +0 -0
- package/public/assets/alert-dialog-CXwkFlF3.js +0 -1
- package/public/assets/alert-dialog-CXwkFlF3.js.br +0 -0
- package/public/assets/badge-CavXTq2x.js +0 -1
- package/public/assets/badge-CavXTq2x.js.br +0 -0
- package/public/assets/build-format-BZV6gt41.js.br +0 -0
- package/public/assets/dialog-JbKwqqS0.js +0 -11
- package/public/assets/dialog-JbKwqqS0.js.br +0 -0
- package/public/assets/index-CBnxiD7-.js +0 -274
- package/public/assets/index-CBnxiD7-.js.br +0 -0
- package/public/assets/index-Ctx3_cYz.css +0 -1
- package/public/assets/index-Ctx3_cYz.css.br +0 -0
- package/public/assets/pencil-BGrOEXS6.js.br +0 -0
- package/public/assets/table-CqOA0Bb1.js +0 -1
- package/public/assets/table-CqOA0Bb1.js.br +0 -0
- package/public/assets/tabs-BVBbhCFG.js +0 -1
- package/public/assets/tabs-BVBbhCFG.js.br +0 -0
- package/public/assets/trash-2-CwlFrbEF.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,9 +39,35 @@ 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;
|
|
48
|
+
// How long a session outlives its agent's socket, waiting for that agent to come back (#426).
|
|
49
|
+
// An agent registers ~1s after its process starts, so this covers an automatic respawn several
|
|
50
|
+
// times over and about half of a hand-typed restart. What bounds it is the other direction: the
|
|
51
|
+
// device stays claimed by a session nobody can use until the window closes.
|
|
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
|
+
}
|
|
44
71
|
import { handleVerifyReset, handleDoReset, handleSendMemberReset } from './api/passwordReset.js';
|
|
45
72
|
import { handleListBuilds, handleGetBuild, handleUpdateBuild, handleUploadBuild, handleScheduleBuildDeletion, handleCancelBuildDeletion, purgeExpiredBuilds } from './api/builds.js';
|
|
46
73
|
import { handleListApps, handleCreateApp, handleUpdateApp, handleDeleteApp } from './api/apps.js';
|
|
@@ -73,23 +100,51 @@ const RESOURCE_THRESHOLD = Number.isFinite(_parsedThreshold) ? _parsedThreshold
|
|
|
73
100
|
// Terminal input messages the MCP client awaits an ack for — if the agent is offline
|
|
74
101
|
// the relay replies input:error so the client fails truthfully (non-terminal moves/starts
|
|
75
102
|
// expect no ack and are dropped silently).
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
'
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
}
|
|
93
148
|
export class RelayServer {
|
|
94
149
|
options;
|
|
95
150
|
httpServer;
|
|
@@ -106,8 +161,36 @@ export class RelayServer {
|
|
|
106
161
|
purgeBuildsTimer = null;
|
|
107
162
|
flushResourcesTimer = null;
|
|
108
163
|
heartbeatTimer = null;
|
|
109
|
-
|
|
110
|
-
|
|
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();
|
|
111
194
|
dropHandlers = new Map();
|
|
112
195
|
// Per-session throttled drop-warn for audio (kept separate so audio drops don't mask video drops in logs).
|
|
113
196
|
audioDropHandlers = new Map();
|
|
@@ -116,11 +199,25 @@ export class RelayServer {
|
|
|
116
199
|
// Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
|
|
117
200
|
idrRequesters = new Map();
|
|
118
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();
|
|
205
|
+
// Agent sockets whose sessions are being held open, and the timer that gives up on each.
|
|
206
|
+
// Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
|
|
207
|
+
// expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
|
|
208
|
+
// on the way back is hygiene on top of it, not a second thing holding the property up. The late
|
|
209
|
+
// expiry is not a complete no-op either: it still drops the socket's resource entry.
|
|
210
|
+
agentHolds = new Map();
|
|
211
|
+
// Set by `stop()`. Its own `terminate()` loop fires a close for every socket, and a hold armed
|
|
212
|
+
// from there would outlive the server it belongs to — the exact hazard the clearing above it is
|
|
213
|
+
// for, arriving a few lines later.
|
|
214
|
+
stopping = false;
|
|
119
215
|
// True when the connection's remote IP is public (not loopback / private LAN) — the agent uses
|
|
120
216
|
// this to downscale harder for bandwidth on external viewers.
|
|
121
217
|
wsExternal = new Map();
|
|
122
218
|
backpressureBytes;
|
|
123
219
|
screenshotTimeoutMs;
|
|
220
|
+
agentGraceMs;
|
|
124
221
|
corsAllowed;
|
|
125
222
|
// One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
|
|
126
223
|
warnedProxyMisconfig = false;
|
|
@@ -135,6 +232,24 @@ export class RelayServer {
|
|
|
135
232
|
// itself may take up to 10s before it errors out.
|
|
136
233
|
this.uiTreeTimeoutMs = options.uiTreeTimeoutMs ?? 15_000;
|
|
137
234
|
this.corsAllowed = new Set(options.corsOrigins ?? []);
|
|
235
|
+
// Constructor option first, then env. It cannot be env-only: `IDLE_TIMEOUT_MS` is read at module
|
|
236
|
+
// load, which is why no test can set it, and the tests that need a different window here run in
|
|
237
|
+
// the same process as the default.
|
|
238
|
+
// Validated, not just parsed, and blank treated as unset rather than as a number. Three ways
|
|
239
|
+
// this silently switches the feature off if read naively: `parseInt('15s')` is 15 — and the
|
|
240
|
+
// documented default reads "15000 (15 s)", so that is the typo the docs invite; a non-numeric
|
|
241
|
+
// value is NaN, which `setTimeout` rounds to ~1ms; and `Number('')` is 0, not NaN, so an empty
|
|
242
|
+
// `.env` line would pass a `>= 0` check and give a zero-length window.
|
|
243
|
+
const graceRaw = process.env['TAPFLOW_AGENT_GRACE_MS']?.trim();
|
|
244
|
+
const graceEnv = graceRaw ? Number(graceRaw) : NaN;
|
|
245
|
+
const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
|
|
246
|
+
this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
|
|
247
|
+
// Say so rather than only documenting it. Both times this parsing was wrong the symptom was
|
|
248
|
+
// the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
|
|
249
|
+
// reading their terminal, not the configuration table.
|
|
250
|
+
if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
|
|
251
|
+
logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
|
|
252
|
+
}
|
|
138
253
|
this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
|
|
139
254
|
this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
|
|
140
255
|
this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
|
|
@@ -288,6 +403,12 @@ export class RelayServer {
|
|
|
288
403
|
clearInterval(this.heartbeatTimer);
|
|
289
404
|
this.heartbeatTimer = null;
|
|
290
405
|
}
|
|
406
|
+
// `unref()` would not do: the test runner's process outlives the server, so an un-cleared hold
|
|
407
|
+
// still fires — against a server that has already stopped.
|
|
408
|
+
this.stopping = true;
|
|
409
|
+
for (const timer of this.agentHolds.values())
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
this.agentHolds.clear();
|
|
291
412
|
return new Promise((resolve, reject) => {
|
|
292
413
|
this.wss.clients.forEach((ws) => ws.terminate());
|
|
293
414
|
this.wss.close(() => {
|
|
@@ -301,34 +422,86 @@ export class RelayServer {
|
|
|
301
422
|
// Terminate sockets that missed the previous pong; ping the rest. Covers all roles via wss.clients.
|
|
302
423
|
runHeartbeat(clients = this.wss.clients) {
|
|
303
424
|
for (const ws of clients) {
|
|
304
|
-
if (this.
|
|
425
|
+
if (!this.isAlive(ws)) {
|
|
305
426
|
ws.terminate();
|
|
306
427
|
continue;
|
|
307
428
|
}
|
|
308
|
-
this.wsAlive.set(ws, false);
|
|
309
429
|
if (ws.readyState === WebSocket.OPEN)
|
|
310
430
|
ws.ping();
|
|
311
431
|
}
|
|
312
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
|
+
}
|
|
313
453
|
// 갱신된 cert를 재시작 없이 핫스왑한다(https 종단일 때만 의미 있음).
|
|
314
454
|
updateTlsContext(material) {
|
|
315
455
|
if (this.httpServer instanceof https.Server) {
|
|
316
456
|
this.httpServer.setSecureContext({ cert: material.cert, key: material.key });
|
|
317
457
|
}
|
|
318
458
|
}
|
|
319
|
-
|
|
320
|
-
|
|
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;
|
|
321
492
|
let lastAt = 0;
|
|
322
|
-
|
|
493
|
+
const requester = () => {
|
|
323
494
|
const now = Date.now();
|
|
324
495
|
if (now - lastAt < IDR_REQUEST_THROTTLE_MS)
|
|
325
496
|
return;
|
|
326
497
|
lastAt = now;
|
|
327
498
|
const session = this.sessions.get(sessionId);
|
|
328
499
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
329
|
-
session.agentSocket
|
|
500
|
+
this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId });
|
|
330
501
|
}
|
|
331
502
|
};
|
|
503
|
+
this.idrRequesters.set(sessionId, requester);
|
|
504
|
+
return requester;
|
|
332
505
|
}
|
|
333
506
|
// Extracted so tests can simulate non-loopback origins (all test traffic is loopback).
|
|
334
507
|
remoteAddressOf(request) {
|
|
@@ -371,9 +544,19 @@ export class RelayServer {
|
|
|
371
544
|
return;
|
|
372
545
|
}
|
|
373
546
|
this.wsExternal.set(ws, isExternalAddress(addr));
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
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()));
|
|
377
560
|
if (decision.role === 'browser')
|
|
378
561
|
this.wsRoles.set(ws, 'browser');
|
|
379
562
|
// 'first-message' → role is determined by the first message (agent:register / stream:register)
|
|
@@ -407,11 +590,7 @@ export class RelayServer {
|
|
|
407
590
|
dropper = createKeyframeAwareSender();
|
|
408
591
|
this.droppers.set(session.id, dropper);
|
|
409
592
|
}
|
|
410
|
-
|
|
411
|
-
if (!requestIdr) {
|
|
412
|
-
requestIdr = this.makeIdrRequester(session.id);
|
|
413
|
-
this.idrRequesters.set(session.id, requestIdr);
|
|
414
|
-
}
|
|
593
|
+
const requestIdr = this.idrRequester(session.id);
|
|
415
594
|
// JPEG and H.264 IDRs are resync points; only P-frames must wait for a keyframe after a drop.
|
|
416
595
|
let isKeyframe = true;
|
|
417
596
|
if (hasEnvelope(frameBuf)) {
|
|
@@ -423,62 +602,195 @@ export class RelayServer {
|
|
|
423
602
|
}
|
|
424
603
|
return;
|
|
425
604
|
}
|
|
605
|
+
let parsed;
|
|
426
606
|
try {
|
|
427
|
-
|
|
428
|
-
this.route(ws, msg);
|
|
607
|
+
parsed = JSON.parse(data.toString());
|
|
429
608
|
}
|
|
430
609
|
catch {
|
|
431
|
-
//
|
|
610
|
+
return; // genuinely malformed — there is no type to answer on
|
|
611
|
+
}
|
|
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);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
this.route(ws, inbound.msg, inbound.raw);
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
// A throw inside a handler used to land in the same catch as a parse failure and vanish.
|
|
651
|
+
// Anything reaching here is a bug in routing, not a bad message, and the caller is left
|
|
652
|
+
// waiting either way — so at least say so once instead of dropping it silently.
|
|
653
|
+
logger.error(`route failed for ${inbound.msg.type}:`, e);
|
|
432
654
|
}
|
|
433
655
|
});
|
|
434
656
|
ws.on('close', () => {
|
|
435
657
|
this.wsRoles.delete(ws);
|
|
436
658
|
this.wsExternal.delete(ws);
|
|
437
|
-
// Agent main socket disconnected →
|
|
438
|
-
|
|
659
|
+
// Agent main socket disconnected → hold its sessions open for a moment in case the agent is
|
|
660
|
+
// coming back (#426), rather than ending them where they stand.
|
|
661
|
+
if (this.holdAgentSocket(ws))
|
|
439
662
|
return;
|
|
440
|
-
// 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.
|
|
441
669
|
const streamSession = this.sessions.getByStreamSocket(ws);
|
|
442
|
-
if (streamSession)
|
|
670
|
+
if (streamSession)
|
|
443
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)
|
|
444
683
|
return;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const browserSession = this.sessions.getByBrowserSocket(ws);
|
|
448
|
-
if (browserSession) {
|
|
449
|
-
this.sessions.clearBrowser(browserSession.id, () => {
|
|
450
|
-
const session = this.sessions.get(browserSession.id);
|
|
451
|
-
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
452
|
-
session.agentSocket.send(JSON.stringify({
|
|
453
|
-
type: 'device:shutdown',
|
|
454
|
-
sessionId: session.id,
|
|
455
|
-
payload: { deviceId: session.deviceId },
|
|
456
|
-
}));
|
|
457
|
-
}
|
|
458
|
-
});
|
|
684
|
+
for (const held of this.sessions.getByBrowserSocket(ws)) {
|
|
685
|
+
this.sessions.clearBrowser(held.id, () => this.idleShutdown(held.id));
|
|
459
686
|
}
|
|
460
687
|
});
|
|
461
688
|
}
|
|
462
|
-
|
|
463
|
-
|
|
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';
|
|
464
721
|
if (!this.wsRoles.has(ws)) {
|
|
465
|
-
|
|
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')
|
|
466
728
|
this.wsRoles.set(ws, 'agent');
|
|
467
|
-
|
|
468
|
-
else if (msg.type === 'stream:register') {
|
|
729
|
+
else if (type === 'stream:register')
|
|
469
730
|
this.wsRoles.set(ws, 'stream');
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
474
735
|
this.wsRoles.set(ws, 'browser');
|
|
475
|
-
}
|
|
476
736
|
}
|
|
477
|
-
// Browser sockets must not spoof agent control messages
|
|
478
|
-
|
|
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') {
|
|
479
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++;
|
|
480
780
|
return;
|
|
481
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) {
|
|
482
794
|
switch (msg.type) {
|
|
483
795
|
// ── Agent → Relay ─────────────────────────────────────────────────────
|
|
484
796
|
case 'agent:resources':
|
|
@@ -500,30 +812,44 @@ export class RelayServer {
|
|
|
500
812
|
this.handleUITreeError(msg);
|
|
501
813
|
break;
|
|
502
814
|
case 'agents:list': {
|
|
503
|
-
|
|
815
|
+
this.sendTo(ws, {
|
|
816
|
+
type: 'agents:listed',
|
|
817
|
+
sessions: this.sessions.list(this.ownerOf(ws), (holder) => this.isAlive(holder)),
|
|
818
|
+
});
|
|
504
819
|
break;
|
|
505
820
|
}
|
|
506
821
|
// ── Session / Stream lifecycle ─────────────────────────────────────────
|
|
507
822
|
case 'session:start':
|
|
508
823
|
this.handleSessionStart(ws, msg);
|
|
509
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.
|
|
510
842
|
case 'session:end': {
|
|
511
|
-
if (msg.sessionId) {
|
|
843
|
+
if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
|
|
512
844
|
this.sessions.remove(msg.sessionId);
|
|
513
|
-
this.
|
|
514
|
-
this.audioDropHandlers.delete(msg.sessionId);
|
|
515
|
-
this.droppers.delete(msg.sessionId);
|
|
516
|
-
this.idrRequesters.delete(msg.sessionId);
|
|
845
|
+
this.forgetSessionStreamState(msg.sessionId);
|
|
517
846
|
}
|
|
518
847
|
break;
|
|
519
848
|
}
|
|
520
849
|
case 'session:leave': {
|
|
521
|
-
if (msg.sessionId) {
|
|
850
|
+
if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
|
|
522
851
|
this.sessions.clearBrowser(msg.sessionId);
|
|
523
|
-
this.
|
|
524
|
-
this.audioDropHandlers.delete(msg.sessionId);
|
|
525
|
-
this.droppers.delete(msg.sessionId);
|
|
526
|
-
this.idrRequesters.delete(msg.sessionId);
|
|
852
|
+
this.forgetSessionStreamState(msg.sessionId);
|
|
527
853
|
}
|
|
528
854
|
break;
|
|
529
855
|
}
|
|
@@ -531,7 +857,7 @@ export class RelayServer {
|
|
|
531
857
|
const session = this.sessions.get(msg.sessionId);
|
|
532
858
|
if (session) {
|
|
533
859
|
this.sessions.setStreamSocket(session.id, ws);
|
|
534
|
-
|
|
860
|
+
this.sendTo(ws, { type: 'stream:registered' });
|
|
535
861
|
}
|
|
536
862
|
break;
|
|
537
863
|
}
|
|
@@ -540,9 +866,21 @@ export class RelayServer {
|
|
|
540
866
|
const session = this.sessions.get(msg.sessionId);
|
|
541
867
|
if (!session)
|
|
542
868
|
break;
|
|
543
|
-
|
|
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']);
|
|
544
882
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
545
|
-
session.browserSocket.send(JSON.stringify(
|
|
883
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
546
884
|
}
|
|
547
885
|
break;
|
|
548
886
|
}
|
|
@@ -550,9 +888,10 @@ export class RelayServer {
|
|
|
550
888
|
const session = this.sessions.get(msg.sessionId);
|
|
551
889
|
if (!session)
|
|
552
890
|
break;
|
|
553
|
-
|
|
891
|
+
// See `session:chrome` above for why this one is a cast off `raw` too.
|
|
892
|
+
this.sessions.setDeviceInfo(session.id, raw['payload']);
|
|
554
893
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
555
|
-
session.browserSocket.send(JSON.stringify(
|
|
894
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
556
895
|
}
|
|
557
896
|
break;
|
|
558
897
|
}
|
|
@@ -563,14 +902,14 @@ export class RelayServer {
|
|
|
563
902
|
break;
|
|
564
903
|
this.sessions.clearDeviceCache(session.id);
|
|
565
904
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
566
|
-
session.browserSocket.send(JSON.stringify(
|
|
905
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
567
906
|
}
|
|
568
907
|
break;
|
|
569
908
|
}
|
|
570
909
|
case 'device:boot-error': {
|
|
571
910
|
const session = this.sessions.get(msg.sessionId);
|
|
572
911
|
if (session?.browserSocket?.readyState === WebSocket.OPEN) {
|
|
573
|
-
session.browserSocket.send(JSON.stringify(
|
|
912
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
574
913
|
}
|
|
575
914
|
break;
|
|
576
915
|
}
|
|
@@ -579,18 +918,25 @@ export class RelayServer {
|
|
|
579
918
|
if (!session)
|
|
580
919
|
break;
|
|
581
920
|
this.sessions.updateDeviceStatus(session.id, 'shutdown');
|
|
921
|
+
this.sessions.setReadySent(session.id, false);
|
|
582
922
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
583
|
-
session.browserSocket.send(JSON.stringify(
|
|
923
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
584
924
|
}
|
|
585
925
|
break;
|
|
586
926
|
}
|
|
587
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;
|
|
588
933
|
const session = this.sessions.get(msg.sessionId);
|
|
589
934
|
if (!session)
|
|
590
935
|
break;
|
|
591
936
|
this.sessions.updateDeviceStatus(session.id, 'booted');
|
|
937
|
+
this.sessions.setReadySent(session.id, true);
|
|
592
938
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
593
|
-
session.browserSocket.send(JSON.stringify(
|
|
939
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
594
940
|
}
|
|
595
941
|
break;
|
|
596
942
|
}
|
|
@@ -609,7 +955,7 @@ export class RelayServer {
|
|
|
609
955
|
case 'keyboard:toggled': {
|
|
610
956
|
const session = this.sessions.get(msg.sessionId);
|
|
611
957
|
if (session?.browserSocket?.readyState === WebSocket.OPEN) {
|
|
612
|
-
session.browserSocket.send(JSON.stringify(
|
|
958
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
613
959
|
}
|
|
614
960
|
break;
|
|
615
961
|
}
|
|
@@ -623,24 +969,101 @@ export class RelayServer {
|
|
|
623
969
|
if (session?.agentSocket !== ws)
|
|
624
970
|
break;
|
|
625
971
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
626
|
-
session.browserSocket.send(JSON.stringify(
|
|
972
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
627
973
|
}
|
|
628
974
|
break;
|
|
629
975
|
}
|
|
630
976
|
// ── Browser → Agent ────────────────────────────────────────────────────
|
|
631
|
-
|
|
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
|
+
}
|
|
632
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.
|
|
633
1038
|
const session = this.sessions.get(msg.sessionId);
|
|
634
|
-
if (session
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
msg.
|
|
639
|
-
|
|
640
|
-
|
|
1039
|
+
if (session && !this.mayShutDown(ws, session)) {
|
|
1040
|
+
this.sendTo(ws, {
|
|
1041
|
+
type: 'device:shutdown-error',
|
|
1042
|
+
sessionId: msg.sessionId,
|
|
1043
|
+
...(msg.requestId === undefined ? {} : { requestId: msg.requestId }),
|
|
1044
|
+
message: ownershipRefusal(session),
|
|
1045
|
+
});
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
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;
|
|
641
1061
|
}
|
|
1062
|
+
target.session.agentSocket.send(JSON.stringify(msg));
|
|
642
1063
|
break;
|
|
643
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.
|
|
644
1067
|
case 'app:install':
|
|
645
1068
|
this.handleBrowserAppInstall(ws, msg);
|
|
646
1069
|
break;
|
|
@@ -648,46 +1071,88 @@ export class RelayServer {
|
|
|
648
1071
|
this.handleBrowserAppLaunch(ws, msg);
|
|
649
1072
|
break;
|
|
650
1073
|
case 'open-url': {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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));
|
|
654
1096
|
}
|
|
655
1097
|
else {
|
|
656
|
-
|
|
1098
|
+
this.sendTo(ws, {
|
|
1099
|
+
type: 'open-url:error',
|
|
1100
|
+
sessionId: msg.sessionId,
|
|
1101
|
+
requestId: msg.requestId,
|
|
1102
|
+
message: target.message,
|
|
1103
|
+
});
|
|
657
1104
|
}
|
|
658
1105
|
break;
|
|
659
1106
|
}
|
|
660
1107
|
case 'app:clear-state': {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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));
|
|
664
1113
|
}
|
|
665
1114
|
else {
|
|
666
|
-
|
|
1115
|
+
this.sendTo(ws, {
|
|
1116
|
+
type: 'app:clear-state-error',
|
|
1117
|
+
sessionId: msg.sessionId,
|
|
1118
|
+
requestId: msg.requestId,
|
|
1119
|
+
message: target.message,
|
|
1120
|
+
});
|
|
667
1121
|
}
|
|
668
1122
|
break;
|
|
669
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.
|
|
670
1132
|
case 'input:touch:start':
|
|
671
1133
|
case 'input:touch:move':
|
|
672
|
-
case 'input:touch:end':
|
|
673
1134
|
case 'input:pinch:start':
|
|
674
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':
|
|
675
1149
|
case 'input:pinch:end':
|
|
676
1150
|
case 'input:key':
|
|
677
|
-
case 'input:type':
|
|
678
1151
|
case 'input:button':
|
|
679
|
-
case 'input:
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
session.agentSocket.send(JSON.stringify(msg));
|
|
684
|
-
}
|
|
685
|
-
else if (TERMINAL_INPUT_TYPES.has(msg.type) && ws.readyState === WebSocket.OPEN) {
|
|
686
|
-
// Agent offline or session evicted: a terminal input can't be dispatched.
|
|
687
|
-
// Reply to the sender (the MCP/browser socket) so it fails truthfully
|
|
688
|
-
// instead of falling through to its optimistic 2s timeout.
|
|
689
|
-
ws.send(JSON.stringify({ type: 'input:error', sessionId: msg.sessionId, message: 'agent offline' }));
|
|
690
|
-
}
|
|
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);
|
|
691
1156
|
break;
|
|
692
1157
|
}
|
|
693
1158
|
// Kept out of the input:* chain above: these need their own error type, and the caller
|
|
@@ -695,22 +1160,32 @@ export class RelayServer {
|
|
|
695
1160
|
// hanging until the browser gives up.
|
|
696
1161
|
case 'clipboard:read':
|
|
697
1162
|
case 'clipboard:write': {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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));
|
|
701
1178
|
}
|
|
702
1179
|
else if (ws.readyState === WebSocket.OPEN) {
|
|
703
|
-
|
|
704
|
-
type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message:
|
|
705
|
-
})
|
|
1180
|
+
this.sendTo(ws, {
|
|
1181
|
+
type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message: clip.message,
|
|
1182
|
+
});
|
|
706
1183
|
}
|
|
707
1184
|
break;
|
|
708
1185
|
}
|
|
709
1186
|
}
|
|
710
1187
|
}
|
|
711
1188
|
handleAgentResources(ws, msg) {
|
|
712
|
-
if (!msg.resources)
|
|
713
|
-
return;
|
|
714
1189
|
this.sessions.setResources(ws, msg.resources);
|
|
715
1190
|
const agentName = this.sessions.getAllByAgentSocket(ws)[0]?.agentName;
|
|
716
1191
|
if (agentName) {
|
|
@@ -723,28 +1198,127 @@ export class RelayServer {
|
|
|
723
1198
|
// Removes an agent socket's sessions + resources and rejects its in-flight
|
|
724
1199
|
// screenshot / ui-tree requests. Shared by socket close and re-register
|
|
725
1200
|
// eviction. Returns true if `ws` had agent sessions.
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1201
|
+
/**
|
|
1202
|
+
* Every message the relay *originates* goes through here. The union checks the shape where the
|
|
1203
|
+
* literal is written — a mistyped `type`, a missing field or one that does not belong are all
|
|
1204
|
+
* compile errors — and the readyState guard replaces the check that was repeated (and sometimes
|
|
1205
|
+
* omitted) at each call site.
|
|
1206
|
+
*
|
|
1207
|
+
* Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
|
|
1208
|
+
* they keep their inbound type and there is nothing new to check.
|
|
1209
|
+
*/
|
|
1210
|
+
sendTo(socket, msg) {
|
|
1211
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
1212
|
+
return;
|
|
1213
|
+
socket.send(JSON.stringify(msg));
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Settle every in-flight screenshot / UI-tree request belonging to these sessions. Both callers
|
|
1217
|
+
* are moments where the agent that was going to answer them no longer exists — it disconnected,
|
|
1218
|
+
* or it restarted and its session moved to the new process. Left alone these reject on their own
|
|
1219
|
+
* timeout, minutes later, having told the caller nothing in between.
|
|
1220
|
+
*/
|
|
1221
|
+
rejectPending(sessionIds, reason) {
|
|
1222
|
+
for (const pendings of [this.pendingScreenshots, this.pendingUITrees]) {
|
|
1223
|
+
for (const [reqId, pending] of pendings.entries()) {
|
|
1224
|
+
if (!sessionIds.has(pending.sessionId))
|
|
1225
|
+
continue;
|
|
733
1226
|
clearTimeout(pending.timer);
|
|
734
|
-
|
|
735
|
-
pending.reject(new Error(
|
|
1227
|
+
pendings.delete(reqId);
|
|
1228
|
+
pending.reject(new Error(reason));
|
|
736
1229
|
}
|
|
737
1230
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
|
|
1234
|
+
* of ending them here — a restarting agent registers about a second later, and until #426 stage 3
|
|
1235
|
+
* the close always won that race, so a restart cost the tester their place.
|
|
1236
|
+
*
|
|
1237
|
+
* The sessions stay exactly where they are. They are NOT moved to a holding structure of their
|
|
1238
|
+
* own: `getAgentSocketsByIdentity` finds the returning agent's previous socket by walking
|
|
1239
|
+
* `sessions` and reading `agentSocket`, so a session parked anywhere else is a session that can
|
|
1240
|
+
* never be reclaimed.
|
|
1241
|
+
*
|
|
1242
|
+
* @returns whether this was an agent socket, matching `evictAgentSocket`'s contract — the close
|
|
1243
|
+
* handler uses it to decide whether to keep looking.
|
|
1244
|
+
*/
|
|
1245
|
+
holdAgentSocket(ws) {
|
|
1246
|
+
// Shutting down: nothing is coming back, and arming a timer here would leave it running after
|
|
1247
|
+
// `stop()` resolved.
|
|
1248
|
+
if (this.stopping)
|
|
1249
|
+
return this.evictAgentSocket(ws);
|
|
1250
|
+
const sessions = this.sessions.getAllByAgentSocket(ws);
|
|
1251
|
+
if (sessions.length === 0) {
|
|
1252
|
+
// No sessions to hold, but the socket may still own a resource entry — `evictAgentSocket`
|
|
1253
|
+
// returns before dropping it in exactly this case.
|
|
1254
|
+
this.sessions.removeResources(ws);
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
// Not held for the window: these were addressed to a process that is gone, and a returning
|
|
1258
|
+
// agent is a new one that never saw them. Waiting would only delay the same failure.
|
|
1259
|
+
this.rejectPending(new Set(sessions.map((s) => s.id)), 'Agent disconnected');
|
|
1260
|
+
for (const s of sessions) {
|
|
1261
|
+
if (s.browserSocket)
|
|
1262
|
+
this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
|
|
1263
|
+
}
|
|
1264
|
+
const timer = setTimeout(() => {
|
|
1265
|
+
this.agentHolds.delete(ws);
|
|
1266
|
+
// Whatever is still on this socket never got reclaimed. A rebind moves sessions off it, so
|
|
1267
|
+
// after a successful one this evicts nothing.
|
|
1268
|
+
if (this.evictAgentSocket(ws))
|
|
1269
|
+
logger.info(`agent did not come back within ${this.agentGraceMs}ms — session(s) ended`);
|
|
1270
|
+
// Not inside that branch: `evictAgentSocket` returns before dropping resources when the
|
|
1271
|
+
// socket has no sessions left, and the sessions can leave by routes other than a rebind —
|
|
1272
|
+
// `session:end` among them. This is the last moment anything holds a reference to the dead
|
|
1273
|
+
// socket, so it is the last chance to drop its entry.
|
|
1274
|
+
this.sessions.removeResources(ws);
|
|
1275
|
+
}, this.agentGraceMs);
|
|
1276
|
+
this.agentHolds.set(ws, timer);
|
|
1277
|
+
logger.info(`agent socket lost — holding ${sessions.length} session(s) for ${this.agentGraceMs}ms`);
|
|
1278
|
+
return true;
|
|
1279
|
+
}
|
|
1280
|
+
/** Stop waiting on a socket, whether or not anything was reclaimed from it. */
|
|
1281
|
+
releaseHold(ws) {
|
|
1282
|
+
const timer = this.agentHolds.get(ws);
|
|
1283
|
+
if (!timer)
|
|
1284
|
+
return;
|
|
1285
|
+
clearTimeout(timer);
|
|
1286
|
+
this.agentHolds.delete(ws);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* @param cause `'disconnect'` — the socket closed. `'replaced'` — the same agent re-registered
|
|
1290
|
+
* and this is its previous socket. Only affects the log line: a restart otherwise reads as a
|
|
1291
|
+
* crash followed by a recovery, which is not what happened.
|
|
1292
|
+
*/
|
|
1293
|
+
evictAgentSocket(ws, cause = 'disconnect') {
|
|
1294
|
+
const agentSessions = this.sessions.getAllByAgentSocket(ws);
|
|
1295
|
+
if (agentSessions.length === 0)
|
|
1296
|
+
return false;
|
|
1297
|
+
this.rejectPending(new Set(agentSessions.map((s) => s.id)), 'Agent disconnected');
|
|
1298
|
+
// Tell whoever is attached before the session stops existing — after `remove()` the socket
|
|
1299
|
+
// reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
|
|
1300
|
+
// relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
|
|
1301
|
+
// the tab sits on "Waiting for first frame..." with no explanation (#426).
|
|
1302
|
+
//
|
|
1303
|
+
// `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
|
|
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.
|
|
1307
|
+
for (const s of agentSessions) {
|
|
1308
|
+
if (s.browserSocket) {
|
|
1309
|
+
this.sendTo(s.browserSocket, {
|
|
1310
|
+
type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected',
|
|
1311
|
+
});
|
|
743
1312
|
}
|
|
744
1313
|
}
|
|
745
|
-
for (const s of agentSessions)
|
|
1314
|
+
for (const s of agentSessions) {
|
|
746
1315
|
this.sessions.remove(s.id);
|
|
1316
|
+
this.forgetSessionStreamState(s.id);
|
|
1317
|
+
}
|
|
747
1318
|
this.sessions.removeResources(ws);
|
|
1319
|
+
logger.info(cause === 'replaced'
|
|
1320
|
+
? `agent re-registered — ${agentSessions.length} previous session(s) replaced`
|
|
1321
|
+
: `agent disconnected — ${agentSessions.length} session(s) ended`);
|
|
748
1322
|
return true;
|
|
749
1323
|
}
|
|
750
1324
|
handleAgentRegister(ws, msg) {
|
|
@@ -754,111 +1328,562 @@ export class RelayServer {
|
|
|
754
1328
|
// its socket before creating the new ones. Identity is agentId (unique per Mac) when present,
|
|
755
1329
|
// else agentName. (Heartbeat backstop for never-reconnecting agents: #313.)
|
|
756
1330
|
const identity = msg.agentId ?? msg.agentName;
|
|
1331
|
+
// Deduplicate first. Everything below is keyed by device id, so a payload naming one device
|
|
1332
|
+
// twice would collapse to a single entry in `registeredSessions` while `create()` had already
|
|
1333
|
+
// made two sessions — leaving one the agent is never told about. That is the same orphan the
|
|
1334
|
+
// rebind exists to prevent, arriving by a different door.
|
|
1335
|
+
const devices = [...new Map((msg.devices ?? []).map((d) => [d.id, d])).values()];
|
|
1336
|
+
const agent = {
|
|
1337
|
+
agentId: msg.agentId, agentName: msg.agentName,
|
|
1338
|
+
agentPlatform: msg.platform, agentCapabilities: msg.capabilities,
|
|
1339
|
+
};
|
|
1340
|
+
// deviceId → the session id kept across the restart. Also the guard that a device is rebound at
|
|
1341
|
+
// most once (#426): before rebinding existed, `create()` ran after every old session had been
|
|
1342
|
+
// evicted, so one device could not be behind two sessions. It can now, and `list()` does not
|
|
1343
|
+
// deduplicate — the second card would name a session the agent has never heard of, which is the
|
|
1344
|
+
// symptom this whole change is fixing.
|
|
1345
|
+
const rebound = new Map();
|
|
757
1346
|
if (identity) {
|
|
758
1347
|
for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
|
|
759
1348
|
if (old === ws)
|
|
760
1349
|
continue;
|
|
1350
|
+
// Before any decision about what to rebind: the eviction below settles this socket either
|
|
1351
|
+
// way, so the timer has nothing left to do. Releasing here rather than after a successful
|
|
1352
|
+
// rebind also covers the case where nothing is rebound at all — a restart that reports a
|
|
1353
|
+
// completely different device list still leaves a timer keyed to a dead socket.
|
|
1354
|
+
this.releaseHold(old);
|
|
1355
|
+
for (const s of this.sessions.getAllByAgentSocket(old)) {
|
|
1356
|
+
if (rebound.has(s.deviceId))
|
|
1357
|
+
continue;
|
|
1358
|
+
const device = devices.find((d) => d.id === s.deviceId);
|
|
1359
|
+
// The device is gone from this agent's list — unplugged, deleted, renamed away. Leave it
|
|
1360
|
+
// for the eviction below, which tells the browser the session ended.
|
|
1361
|
+
if (!device)
|
|
1362
|
+
continue;
|
|
1363
|
+
this.sessions.rebind(s.id, ws, device, agent);
|
|
1364
|
+
rebound.set(s.deviceId, s.id);
|
|
1365
|
+
}
|
|
761
1366
|
// Evict before terminate: the old socket's close fires async, by which point its sessions are
|
|
762
1367
|
// gone and its in-flight screenshots would be undiscoverable — reject them here instead.
|
|
763
|
-
this.
|
|
1368
|
+
// The rebound sessions have already moved off `old`, so this no longer covers them.
|
|
1369
|
+
this.evictAgentSocket(old, 'replaced');
|
|
764
1370
|
old.terminate();
|
|
765
1371
|
}
|
|
766
1372
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1373
|
+
// Their in-flight requests are addressed to a process that is gone, and the eviction above can
|
|
1374
|
+
// no longer see them. Nothing else would ever settle these.
|
|
1375
|
+
if (rebound.size > 0)
|
|
1376
|
+
this.rejectPending(new Set(rebound.values()), 'Agent restarted');
|
|
1377
|
+
// A device can be back under an agent this session cannot be rebound to — identity is
|
|
1378
|
+
// `agentId ?? agentName`, and the upgrade that prompted the restart is often the one that
|
|
1379
|
+
// starts sending an agentId. The device is demonstrably present, so holding its old session
|
|
1380
|
+
// any longer strands that viewer while someone else picks the very same simulator. End it now
|
|
1381
|
+
// and say so, which is what would have happened before the hold existed.
|
|
1382
|
+
for (const d of devices) {
|
|
1383
|
+
if (rebound.has(d.id))
|
|
1384
|
+
continue;
|
|
1385
|
+
for (const s of this.sessions.getAllByDeviceId(d.id)) {
|
|
1386
|
+
if (s.agentSocket.readyState === WebSocket.OPEN)
|
|
1387
|
+
continue;
|
|
1388
|
+
if (s.browserSocket) {
|
|
1389
|
+
this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
|
|
1390
|
+
}
|
|
1391
|
+
this.sessions.remove(s.id);
|
|
1392
|
+
this.forgetSessionStreamState(s.id);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
// Only devices without a surviving session get a new one. Passing all of them here is what
|
|
1396
|
+
// would produce the duplicate card described above.
|
|
1397
|
+
const fresh = devices.filter((d) => !rebound.has(d.id));
|
|
1398
|
+
const freshIds = this.sessions.create(ws, fresh, msg.agentName, msg.platform, msg.agentId, msg.capabilities);
|
|
1399
|
+
const byDeviceId = new Map(rebound);
|
|
1400
|
+
fresh.forEach((d, i) => byDeviceId.set(d.id, freshIds[i]));
|
|
1401
|
+
// Keyed by deviceId, not by position. The old form paired `msg.devices[i]` with `sessionIds[i]`,
|
|
1402
|
+
// which only held while every device got a session — now that some are rebound, the arrays have
|
|
1403
|
+
// different lengths and index alignment would hand the agent someone else's session id.
|
|
1404
|
+
const registeredSessions = devices.map((d) => ({ deviceId: d.id, sessionId: byDeviceId.get(d.id) }));
|
|
1405
|
+
this.sendTo(ws, { type: 'agent:registered', registeredSessions });
|
|
1406
|
+
// After the state is final: the browser answers this with `device:boot` on the same session id.
|
|
1407
|
+
for (const sessionId of rebound.values()) {
|
|
1408
|
+
const s = this.sessions.get(sessionId);
|
|
1409
|
+
if (s?.browserSocket) {
|
|
1410
|
+
this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (rebound.size > 0)
|
|
1414
|
+
logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
|
|
1415
|
+
// The startup banner prints "Waiting for agents..." once and then the relay says nothing either
|
|
1416
|
+
// way, so a terminal gives no signal about whether an agent is attached. One line per
|
|
1417
|
+
// transition, matching the disconnect line in evictAgentSocket.
|
|
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)`);
|
|
773
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. */
|
|
774
1429
|
handleSessionStart(ws, msg) {
|
|
775
1430
|
const session = this.sessions.get(msg.sessionId);
|
|
776
1431
|
if (!session) {
|
|
777
|
-
|
|
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' });
|
|
778
1450
|
return;
|
|
779
1451
|
}
|
|
780
|
-
|
|
1452
|
+
// Read before the resource gate, and answered before it too. The gate would otherwise read the
|
|
1453
|
+
// dead socket's last sample — and an overloaded Mac is a common reason to restart an agent, so
|
|
1454
|
+
// the tester would be told the Mac is exhausted at the exact moment it is recovering.
|
|
1455
|
+
const agentAway = session.agentSocket.readyState !== WebSocket.OPEN;
|
|
1456
|
+
const resources = agentAway ? undefined : this.sessions.getResources(session.agentSocket);
|
|
781
1457
|
if (resources) {
|
|
782
1458
|
const memPercent = (resources.memUsedMB / resources.memTotalMB) * 100;
|
|
783
1459
|
if (resources.cpuPercent > RESOURCE_THRESHOLD || memPercent > RESOURCE_THRESHOLD) {
|
|
784
|
-
|
|
1460
|
+
this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Agent resources exhausted', reason: 'agent-resources-exhausted' });
|
|
785
1461
|
return;
|
|
786
1462
|
}
|
|
787
1463
|
}
|
|
788
1464
|
try {
|
|
789
|
-
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
|
+
}
|
|
790
1477
|
}
|
|
791
|
-
catch {
|
|
792
|
-
|
|
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' });
|
|
793
1489
|
return;
|
|
794
1490
|
}
|
|
795
1491
|
// Include the agent's capabilities so the viewer knows up front what is implemented on
|
|
796
1492
|
// the other end — an agent that predates a feature omits it, and the dashboard degrades
|
|
797
1493
|
// deliberately instead of inferring anything from a timeout.
|
|
798
|
-
|
|
1494
|
+
this.sendTo(ws, {
|
|
799
1495
|
type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
|
|
800
|
-
})
|
|
1496
|
+
});
|
|
1497
|
+
if (agentAway) {
|
|
1498
|
+
// Joining into a held session. Refusing instead would be worse than it sounds: the viewer
|
|
1499
|
+
// sends `session:start` exactly once per reconnect and ignores a plain `error`, so a browser
|
|
1500
|
+
// blip inside the window would leave a tab that no later `session:rebound` can reach — it is
|
|
1501
|
+
// addressed to `browserSocket`, which the refusal never set. Saying what is happening keeps
|
|
1502
|
+
// the tab inside the contract instead.
|
|
1503
|
+
//
|
|
1504
|
+
// Everything below describes the agent that went away, so it stops here. The viewer will get
|
|
1505
|
+
// it all again from the boot it sends on `session:rebound`.
|
|
1506
|
+
this.sendTo(ws, { type: 'session:agent-away', sessionId: msg.sessionId });
|
|
1507
|
+
return;
|
|
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.
|
|
801
1513
|
if (session.chromeData) {
|
|
802
|
-
|
|
1514
|
+
this.sendTo(ws, { type: 'session:chrome', sessionId: session.id, payload: session.chromeData });
|
|
803
1515
|
}
|
|
804
1516
|
if (session.deviceInfo) {
|
|
805
|
-
|
|
1517
|
+
this.sendTo(ws, { type: 'session:deviceInfo', sessionId: session.id, payload: session.deviceInfo });
|
|
806
1518
|
}
|
|
807
|
-
// Replay device:ready if
|
|
808
|
-
|
|
809
|
-
|
|
1519
|
+
// Replay device:ready only if this session actually announced one (browser WS blip reconnect).
|
|
1520
|
+
// Not `deviceStatus`: that starts from the agent's `simctl list` snapshot, so a session for a
|
|
1521
|
+
// simulator that was already running would fire this before the agent had done anything —
|
|
1522
|
+
// telling the viewer a stream exists when none does (#440).
|
|
1523
|
+
if (session.readySent) {
|
|
1524
|
+
this.sendTo(ws, { type: 'device:ready', payload: { deviceId: session.deviceId } });
|
|
810
1525
|
// (Re)joining a live stream: ask the agent for an IDR so this viewer gets a decodable
|
|
811
1526
|
// keyframe immediately, instead of waiting for the next periodic one — and so it isn't
|
|
812
1527
|
// left blank when the encoder is static-skipping an unchanged screen. Agents that don't
|
|
813
1528
|
// support on-demand IDR ignore the message.
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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)();
|
|
817
1538
|
}
|
|
818
1539
|
}
|
|
819
|
-
|
|
820
|
-
|
|
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) {
|
|
821
1679
|
const session = this.sessions.get(msg.sessionId);
|
|
822
|
-
if (!session) {
|
|
823
|
-
|
|
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.
|
|
824
1685
|
return;
|
|
825
1686
|
}
|
|
826
|
-
|
|
827
|
-
.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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');
|
|
831
1712
|
return;
|
|
832
1713
|
}
|
|
833
|
-
if (session
|
|
834
|
-
session.agentSocket.send(JSON.stringify(
|
|
835
|
-
|
|
836
|
-
sessionId: msg.sessionId,
|
|
837
|
-
payload: { filePath: build.file_path, bundleId: build.bundle_id },
|
|
838
|
-
}));
|
|
1714
|
+
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
1715
|
+
session.agentSocket.send(JSON.stringify(msg));
|
|
1716
|
+
return;
|
|
839
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');
|
|
840
1728
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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)
|
|
846
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' });
|
|
847
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
|
+
}
|
|
1800
|
+
/** Relay looks up file_path from DB and enriches it for the agent.
|
|
1801
|
+
*
|
|
1802
|
+
* Every exit carries the request's `sessionId`, including the failures. A dashboard viewer holds
|
|
1803
|
+
* one session per socket, so an uncorrelated error still lands somewhere sensible — but an MCP
|
|
1804
|
+
* caller waits for the reply matching its own sessionId, so anything else is indistinguishable
|
|
1805
|
+
* from silence and it waits out the deadline (#445). `Session not found` is app-specific for the
|
|
1806
|
+
* same reason: a generic `error` cannot be correlated by construction. */
|
|
1807
|
+
handleBrowserAppInstall(ws, msg) {
|
|
1808
|
+
const sessionId = msg.sessionId;
|
|
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 });
|
|
1815
|
+
const session = this.sessions.get(sessionId);
|
|
1816
|
+
if (!session)
|
|
1817
|
+
return fail('Session not found');
|
|
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.
|
|
1832
|
+
const build = getDb()
|
|
1833
|
+
.prepare('SELECT file_path, bundle_id FROM builds WHERE id = ?')
|
|
1834
|
+
.get(msg.buildId);
|
|
1835
|
+
if (!build)
|
|
1836
|
+
return fail('Build not found');
|
|
1837
|
+
// Answer now rather than letting the caller time out — the same shape as `open-url` above.
|
|
1838
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
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.
|
|
1845
|
+
this.sendTo(session.agentSocket, {
|
|
1846
|
+
type: 'app:install',
|
|
1847
|
+
sessionId,
|
|
1848
|
+
requestId,
|
|
1849
|
+
payload: { filePath: build.file_path, bundleId: build.bundle_id },
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
/** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
|
|
1853
|
+
handleBrowserAppLaunch(ws, msg) {
|
|
1854
|
+
const sessionId = msg.sessionId;
|
|
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 });
|
|
1861
|
+
const session = this.sessions.get(sessionId);
|
|
1862
|
+
if (!session)
|
|
1863
|
+
return fail('Session not found');
|
|
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.
|
|
848
1872
|
const build = getDb()
|
|
849
1873
|
.prepare('SELECT bundle_id FROM builds WHERE id = ?')
|
|
850
1874
|
.get(msg.buildId);
|
|
851
|
-
if (!build?.bundle_id)
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1875
|
+
if (!build?.bundle_id)
|
|
1876
|
+
return fail('Bundle ID not available for this build');
|
|
1877
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
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.
|
|
1881
|
+
this.sendTo(session.agentSocket, {
|
|
1882
|
+
type: 'app:launch',
|
|
1883
|
+
sessionId,
|
|
1884
|
+
requestId,
|
|
1885
|
+
payload: { bundleId: build.bundle_id },
|
|
1886
|
+
});
|
|
862
1887
|
}
|
|
863
1888
|
async handleGetScreenshot(req, res, params) {
|
|
864
1889
|
if (!requireViewAuth(req, res))
|
|
@@ -904,7 +1929,7 @@ export class RelayServer {
|
|
|
904
1929
|
},
|
|
905
1930
|
timer,
|
|
906
1931
|
});
|
|
907
|
-
session.agentSocket
|
|
1932
|
+
this.sendTo(session.agentSocket, { type: 'screenshot:request', sessionId, requestId, format });
|
|
908
1933
|
});
|
|
909
1934
|
}
|
|
910
1935
|
async handleGetUITree(req, res, params) {
|
|
@@ -947,41 +1972,50 @@ export class RelayServer {
|
|
|
947
1972
|
},
|
|
948
1973
|
timer,
|
|
949
1974
|
});
|
|
950
|
-
session.agentSocket
|
|
1975
|
+
this.sendTo(session.agentSocket, { type: 'ui:tree:request', sessionId, requestId });
|
|
951
1976
|
});
|
|
952
1977
|
}
|
|
953
1978
|
handleUITreeResponse(msg) {
|
|
954
|
-
if (!msg.requestId)
|
|
955
|
-
return;
|
|
956
1979
|
const pending = this.pendingUITrees.get(msg.requestId);
|
|
957
1980
|
if (!pending)
|
|
958
1981
|
return;
|
|
959
|
-
pending.resolve(msg.elements
|
|
1982
|
+
pending.resolve(msg.elements);
|
|
960
1983
|
}
|
|
961
1984
|
handleUITreeError(msg) {
|
|
962
|
-
if (!msg.requestId)
|
|
963
|
-
return;
|
|
964
1985
|
const pending = this.pendingUITrees.get(msg.requestId);
|
|
965
1986
|
if (!pending)
|
|
966
1987
|
return;
|
|
967
|
-
|
|
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'));
|
|
968
1991
|
}
|
|
969
1992
|
handleScreenshotDone(msg) {
|
|
970
|
-
if (!msg.requestId)
|
|
971
|
-
return;
|
|
972
1993
|
const pending = this.pendingScreenshots.get(msg.requestId);
|
|
973
1994
|
if (!pending)
|
|
974
1995
|
return;
|
|
975
|
-
|
|
976
|
-
|
|
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);
|
|
977
2012
|
}
|
|
978
2013
|
handleScreenshotError(msg) {
|
|
979
|
-
if (!msg.requestId)
|
|
980
|
-
return;
|
|
981
2014
|
const pending = this.pendingScreenshots.get(msg.requestId);
|
|
982
2015
|
if (!pending)
|
|
983
2016
|
return;
|
|
984
|
-
|
|
2017
|
+
// `||` for the reason `handleUITreeError` gives.
|
|
2018
|
+
pending.reject(new Error(msg.message || 'Screenshot failed'));
|
|
985
2019
|
}
|
|
986
2020
|
flushResourceBuffers() {
|
|
987
2021
|
if (this.resourceBuffers.size === 0)
|