@tapflowio/relay 0.18.0 → 0.20.0

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