@tapflowio/relay 0.18.0 → 0.19.0

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