@tapflowio/relay 0.26.0 → 0.27.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 (155) hide show
  1. package/dist/RelayServer.d.ts +81 -3
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +278 -17
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts +5 -3
  6. package/dist/SessionManager.d.ts.map +1 -1
  7. package/dist/SessionManager.js +3 -1
  8. package/dist/SessionManager.js.map +1 -1
  9. package/dist/api/auth.d.ts +2 -1
  10. package/dist/api/auth.d.ts.map +1 -1
  11. package/dist/api/auth.js +1 -1
  12. package/dist/api/auth.js.map +1 -1
  13. package/dist/api/passwordReset.d.ts +2 -1
  14. package/dist/api/passwordReset.d.ts.map +1 -1
  15. package/dist/api/passwordReset.js +1 -1
  16. package/dist/api/passwordReset.js.map +1 -1
  17. package/dist/api/team.d.ts +3 -2
  18. package/dist/api/team.d.ts.map +1 -1
  19. package/dist/api/team.js +2 -2
  20. package/dist/api/team.js.map +1 -1
  21. package/dist/api/tokens.d.ts +2 -1
  22. package/dist/api/tokens.d.ts.map +1 -1
  23. package/dist/api/tokens.js +1 -1
  24. package/dist/api/tokens.js.map +1 -1
  25. package/dist/lib/connectionAuth.d.ts +29 -0
  26. package/dist/lib/connectionAuth.d.ts.map +1 -1
  27. package/dist/lib/connectionAuth.js +15 -0
  28. package/dist/lib/connectionAuth.js.map +1 -1
  29. package/dist/lib/webhookTransport.d.ts +29 -0
  30. package/dist/lib/webhookTransport.d.ts.map +1 -0
  31. package/dist/lib/webhookTransport.js +86 -0
  32. package/dist/lib/webhookTransport.js.map +1 -0
  33. package/dist/lib/webhookUrl.d.ts +6 -0
  34. package/dist/lib/webhookUrl.d.ts.map +1 -1
  35. package/dist/lib/webhookUrl.js +50 -14
  36. package/dist/lib/webhookUrl.js.map +1 -1
  37. package/dist/lib/webhooks.d.ts +2 -10
  38. package/dist/lib/webhooks.d.ts.map +1 -1
  39. package/dist/lib/webhooks.js +8 -3
  40. package/dist/lib/webhooks.js.map +1 -1
  41. package/package.json +6 -6
  42. package/public/assets/{AppCenter-DZCsC2wa.js → AppCenter-BMdYtQbV.js} +7 -7
  43. package/public/assets/AppCenter-BMdYtQbV.js.br +0 -0
  44. package/public/assets/AppCenter-BMdYtQbV.js.gz +0 -0
  45. package/public/assets/Default-0DHHVGaN.js +1 -0
  46. package/public/assets/Default-0DHHVGaN.js.br +0 -0
  47. package/public/assets/Default-0DHHVGaN.js.gz +0 -0
  48. package/public/assets/Invite-C0ZB_HiU.js +1 -0
  49. package/public/assets/Invite-C0ZB_HiU.js.br +0 -0
  50. package/public/assets/Invite-C0ZB_HiU.js.gz +0 -0
  51. package/public/assets/{MacResources-B0DQ9ISE.js → MacResources-DmY5pRV2.js} +1 -1
  52. package/public/assets/MacResources-DmY5pRV2.js.br +0 -0
  53. package/public/assets/MacResources-DmY5pRV2.js.gz +0 -0
  54. package/public/assets/QASession-2QogWEX7.js +150 -0
  55. package/public/assets/QASession-2QogWEX7.js.br +0 -0
  56. package/public/assets/QASession-2QogWEX7.js.gz +0 -0
  57. package/public/assets/ResetPassword-jNRuertx.js +1 -0
  58. package/public/assets/ResetPassword-jNRuertx.js.br +0 -0
  59. package/public/assets/ResetPassword-jNRuertx.js.gz +0 -0
  60. package/public/assets/Setup-CF_FN9Dt.js +1 -0
  61. package/public/assets/Setup-CF_FN9Dt.js.br +0 -0
  62. package/public/assets/Setup-CF_FN9Dt.js.gz +0 -0
  63. package/public/assets/Team-BbiyOGau.js +6 -0
  64. package/public/assets/Team-BbiyOGau.js.br +0 -0
  65. package/public/assets/Team-BbiyOGau.js.gz +0 -0
  66. package/public/assets/Tokens-CZo4sPTx.js +1 -0
  67. package/public/assets/Tokens-CZo4sPTx.js.br +0 -0
  68. package/public/assets/Tokens-CZo4sPTx.js.gz +0 -0
  69. package/public/assets/alert-dialog-DFz8G_zu.js +1 -0
  70. package/public/assets/alert-dialog-DFz8G_zu.js.br +0 -0
  71. package/public/assets/alert-dialog-DFz8G_zu.js.gz +0 -0
  72. package/public/assets/badge-B3vaBVIQ.js +1 -0
  73. package/public/assets/badge-B3vaBVIQ.js.br +0 -0
  74. package/public/assets/badge-B3vaBVIQ.js.gz +0 -0
  75. package/public/assets/{build-format-fzt-JrUX.js → build-format-dbQPU3A4.js} +3 -3
  76. package/public/assets/build-format-dbQPU3A4.js.br +0 -0
  77. package/public/assets/build-format-dbQPU3A4.js.gz +0 -0
  78. package/public/assets/{dialog-DQkbaw8h.js → dialog-DeBPk-I3.js} +5 -5
  79. package/public/assets/dialog-DeBPk-I3.js.br +0 -0
  80. package/public/assets/dialog-DeBPk-I3.js.gz +0 -0
  81. package/public/assets/field-hint-CMKmbVpy.js +1 -0
  82. package/public/assets/field-hint-CMKmbVpy.js.br +0 -0
  83. package/public/assets/field-hint-CMKmbVpy.js.gz +0 -0
  84. package/public/assets/index-B4w4Lh3e.js +218 -0
  85. package/public/assets/index-B4w4Lh3e.js.br +0 -0
  86. package/public/assets/index-B4w4Lh3e.js.gz +0 -0
  87. package/public/assets/index-CDWu0GQL.css +1 -0
  88. package/public/assets/index-CDWu0GQL.css.br +0 -0
  89. package/public/assets/index-CDWu0GQL.css.gz +0 -0
  90. package/public/assets/list-view-BvINA5t4.js +1 -0
  91. package/public/assets/list-view-BvINA5t4.js.br +0 -0
  92. package/public/assets/list-view-BvINA5t4.js.gz +0 -0
  93. package/public/assets/{pencil-BJcOp3oN.js → pencil-CycAOoC_.js} +2 -2
  94. package/public/assets/pencil-CycAOoC_.js.br +0 -0
  95. package/public/assets/pencil-CycAOoC_.js.gz +0 -0
  96. package/public/assets/resource-health-C1qSG3MK.js +1 -0
  97. package/public/assets/resource-health-C1qSG3MK.js.br +0 -0
  98. package/public/assets/resource-health-C1qSG3MK.js.gz +0 -0
  99. package/public/assets/{trash-DhBxh6H7.js → trash-BXQnOI5U.js} +3 -3
  100. package/public/assets/trash-BXQnOI5U.js.br +0 -0
  101. package/public/assets/trash-BXQnOI5U.js.gz +0 -0
  102. package/public/index.html +2 -2
  103. package/public/index.html.br +0 -0
  104. package/public/index.html.gz +0 -0
  105. package/public/assets/AppCenter-DZCsC2wa.js.br +0 -0
  106. package/public/assets/AppCenter-DZCsC2wa.js.gz +0 -0
  107. package/public/assets/Default-xD9Fmd5X.js +0 -1
  108. package/public/assets/Default-xD9Fmd5X.js.br +0 -0
  109. package/public/assets/Default-xD9Fmd5X.js.gz +0 -0
  110. package/public/assets/Invite-29Erwv8j.js +0 -1
  111. package/public/assets/Invite-29Erwv8j.js.br +0 -0
  112. package/public/assets/Invite-29Erwv8j.js.gz +0 -0
  113. package/public/assets/MacResources-B0DQ9ISE.js.br +0 -0
  114. package/public/assets/MacResources-B0DQ9ISE.js.gz +0 -0
  115. package/public/assets/QASession-D-n6PXuD.js +0 -150
  116. package/public/assets/QASession-D-n6PXuD.js.br +0 -0
  117. package/public/assets/QASession-D-n6PXuD.js.gz +0 -0
  118. package/public/assets/ResetPassword-BnPIwTYH.js +0 -1
  119. package/public/assets/ResetPassword-BnPIwTYH.js.br +0 -0
  120. package/public/assets/ResetPassword-BnPIwTYH.js.gz +0 -0
  121. package/public/assets/Setup-DmUGFblg.js +0 -1
  122. package/public/assets/Setup-DmUGFblg.js.br +0 -0
  123. package/public/assets/Setup-DmUGFblg.js.gz +0 -0
  124. package/public/assets/Team-BpvRgFfZ.js +0 -6
  125. package/public/assets/Team-BpvRgFfZ.js.br +0 -0
  126. package/public/assets/Team-BpvRgFfZ.js.gz +0 -0
  127. package/public/assets/Tokens-x6BtCcL7.js +0 -1
  128. package/public/assets/Tokens-x6BtCcL7.js.br +0 -0
  129. package/public/assets/Tokens-x6BtCcL7.js.gz +0 -0
  130. package/public/assets/alert-dialog-C8wDimW-.js +0 -1
  131. package/public/assets/alert-dialog-C8wDimW-.js.br +0 -0
  132. package/public/assets/alert-dialog-C8wDimW-.js.gz +0 -0
  133. package/public/assets/badge-CWVbeuH3.js +0 -1
  134. package/public/assets/badge-CWVbeuH3.js.br +0 -0
  135. package/public/assets/badge-CWVbeuH3.js.gz +0 -0
  136. package/public/assets/build-format-fzt-JrUX.js.br +0 -0
  137. package/public/assets/build-format-fzt-JrUX.js.gz +0 -0
  138. package/public/assets/dialog-DQkbaw8h.js.br +0 -0
  139. package/public/assets/dialog-DQkbaw8h.js.gz +0 -0
  140. package/public/assets/index-C1Fo8g_c.js +0 -218
  141. package/public/assets/index-C1Fo8g_c.js.br +0 -0
  142. package/public/assets/index-C1Fo8g_c.js.gz +0 -0
  143. package/public/assets/index-C2F1AgwB.css +0 -1
  144. package/public/assets/index-C2F1AgwB.css.br +0 -0
  145. package/public/assets/index-C2F1AgwB.css.gz +0 -0
  146. package/public/assets/list-view-DxI397-H.js +0 -1
  147. package/public/assets/list-view-DxI397-H.js.br +0 -0
  148. package/public/assets/list-view-DxI397-H.js.gz +0 -0
  149. package/public/assets/pencil-BJcOp3oN.js.br +0 -0
  150. package/public/assets/pencil-BJcOp3oN.js.gz +0 -0
  151. package/public/assets/resource-health-Cq3gqqVd.js +0 -1
  152. package/public/assets/resource-health-Cq3gqqVd.js.br +0 -0
  153. package/public/assets/resource-health-Cq3gqqVd.js.gz +0 -0
  154. package/public/assets/trash-DhBxh6H7.js.br +0 -0
  155. package/public/assets/trash-DhBxh6H7.js.gz +0 -0
@@ -9,7 +9,7 @@ import { SessionManager } from './SessionManager.js';
9
9
  import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
10
10
  import { Router, json } from './router.js';
11
11
  import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
12
- import { AGENT_SCOPE, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, revalidatePrincipal } from './lib/connectionAuth.js';
12
+ import { AGENT_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
13
13
  import { isTunnelIngress, markTunnelIngress, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
14
14
  import { BuildTicketStore } from './lib/buildTickets.js';
15
15
  import { resolveBuildFile } from './lib/buildFiles.js';
@@ -22,7 +22,7 @@ import { createTrailingRequester, systemTimerScheduler } from './lib/trailingReq
22
22
  import { getDb } from './db.js';
23
23
  import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit, handleAuthStatus } from './api/auth.js';
24
24
  import { handleVerify, handleAccept } from './api/invitations.js';
25
- import { createLogger } from '@tapflowio/agent-core';
25
+ import { createLogger, hasCapability } from '@tapflowio/agent-core';
26
26
  import { createKeyframeAwareSender, createRateLimitedDropWarn, sendAudioYieldingToVideo, DEFAULT_BACKPRESSURE_BYTES, hasEnvelope, patchRelayedAt, readEnvelopeFlags, CODEC_JPEG, CODEC_AUDIO, } from '@tapflowio/agent-core/utils';
27
27
  const logger = createLogger('relay');
28
28
  // True when a remote IP is public — i.e. not loopback, private LAN, or link-local. Used to pick the
@@ -171,6 +171,19 @@ function describeRejection(failure, suppressed) {
171
171
  const outcome = failure.reason === 'bad-payload' ? 'refused, and the sender told' : 'dropped';
172
172
  return `[tapflow] inbound ${failure.type} does not match the contract — ${outcome}${also}:\n${failure.detail}`;
173
173
  }
174
+ /** Just past `mcp-server`'s 30s shutdown deadline, so an entry outlives every caller still waiting on it. */
175
+ const SHUTDOWN_REQUESTER_TTL_MS = 35_000;
176
+ /**
177
+ * How long a tracked boot lives without an answer. The same horizon as both clients'
178
+ * `BOOT_DEADLINE_MS`: past it the caller has already timed out, so the entry is pure
179
+ * bookkeeping. Expiry forgets silently — it sends no failure, because the client timeout
180
+ * owns that case. This bounds the map, not the boot: it changes nothing about how long a
181
+ * boot may take, which is #588's question, not this one's.
182
+ */
183
+ const PENDING_BOOT_TTL_MS = 180_000;
184
+ /** Said when a session ends with a shutdown unanswered — which is not the same as the shutdown failing. */
185
+ const SHUTDOWN_OUTCOME_LOST = 'The session ended before its agent confirmed the shutdown, so whether the device shut down is unknown.';
186
+ const shutdownRequesterKey = (sessionId, requestId) => `${sessionId}\n${requestId}`;
174
187
  export class RelayServer {
175
188
  options;
176
189
  httpServer;
@@ -278,6 +291,9 @@ export class RelayServer {
278
291
  backpressureBytes;
279
292
  screenshotTimeoutMs;
280
293
  agentGraceMs;
294
+ /** How long a tracked boot lives without an answer. An option so tests can shrink the
295
+ * 180s horizon; production always takes the default. */
296
+ pendingBootTtlMs;
281
297
  corsAllowed;
282
298
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
283
299
  warnedProxyMisconfig = false;
@@ -287,6 +303,39 @@ export class RelayServer {
287
303
  pendingScreenshots = new Map();
288
304
  uiTreeTimeoutMs;
289
305
  pendingUITrees = new Map();
306
+ /**
307
+ * Who asked for each correlated `device:shutdown` still waiting on its agent (#567). The agent's answer
308
+ * goes to the session's `browserSocket`, and a caller that never joined — an MCP client shutting down a
309
+ * session nobody holds, which `mayShutDown` permits — has no socket there, so it waited out its 30s for a
310
+ * shutdown that had happened. Keyed by session **and** id, because the id is chosen by the client and
311
+ * would alone let one session's answer reach another session's caller.
312
+ *
313
+ * **Settled when the session is removed, never when the agent socket drops — the opposite of a boot.**
314
+ * Both agents reconnect without restarting, and a shutdown that finishes after the reconnect is answered
315
+ * on the new socket, so an agent going away or being rebound is not evidence that the answer is lost.
316
+ * For a boot it is (#885): the binding a boot creates is exactly what a rebind loses. Folding the two
317
+ * into one rule would tell a caller its shutdown failed for a device that then powered off.
318
+ */
319
+ shutdownRequesters = new Map();
320
+ /**
321
+ * In-flight `device:boot` requests, keyed by session and correlator (#885).
322
+ *
323
+ * The relay is the only layer that knows which agent socket each boot was actually dispatched
324
+ * to, so it owns settling the ones a rebind strands. The client cannot tell "issued before the
325
+ * rebound" from "delivered to the old agent", which is why the previous client-side settle on
326
+ * `session:rebound` answered boots the new agent was already handling. Entries record the
327
+ * concrete agent socket from the `device:boot` dispatch, so a rebind invalidates only the boots
328
+ * tied to the replaced socket and leaves a boot dispatched to the new socket alone.
329
+ *
330
+ * Cleared when a correlated `device:ready` or `device:boot-error` arrives, when the boot is
331
+ * invalidated at rebind, when its session is forgotten or evicted — and by its own expiry
332
+ * (`PENDING_BOOT_TTL_MS`) when none of those happen first, so a live agent that never answers
333
+ * cannot pin the entry past every caller that could have waited on it. An absent `requestId`
334
+ * answers no request (protocol/AGENTS.md 「Lifecycle correlation」), so id-less replies clear
335
+ * nothing. A leftover entry is harmless: its id names no waiter, so both clients and the
336
+ * dashboard drop the synthetic error it would produce.
337
+ */
338
+ pendingBoots = new Map();
290
339
  constructor(options) {
291
340
  this.options = options;
292
341
  // 0 on both sides is two ephemeral ports, which is what the tests ask for.
@@ -311,6 +360,7 @@ export class RelayServer {
311
360
  const graceEnv = graceRaw ? Number(graceRaw) : NaN;
312
361
  const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
313
362
  this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
363
+ this.pendingBootTtlMs = options.pendingBootTtlMs ?? PENDING_BOOT_TTL_MS;
314
364
  // Say so rather than only documenting it. Both times this parsing was wrong the symptom was
315
365
  // the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
316
366
  // reading their terminal, not the configuration table.
@@ -550,6 +600,13 @@ export class RelayServer {
550
600
  for (const requester of this.networkStateRequesters.values())
551
601
  requester.dispose();
552
602
  this.networkStateRequesters.clear();
603
+ for (const entry of this.shutdownRequesters.values())
604
+ clearTimeout(entry.timer);
605
+ this.shutdownRequesters.clear();
606
+ // Same argument as the holds above: an expiry firing after `stop()` would run against a dead
607
+ // server, and an unref'd 180s timer still outlives the test runner's process.
608
+ for (const key of this.pendingBoots.keys())
609
+ this.forgetBoot(key);
553
610
  const closeTunnel = new Promise((resolve, reject) => {
554
611
  // Not listening means `start()` never reached it, or its listen failed; `close()` would reject.
555
612
  if (!this.tunnelServer?.listening)
@@ -618,15 +675,22 @@ export class RelayServer {
618
675
  }
619
676
  /**
620
677
  * Injected into the handlers whose write can take access away — member update and removal, token
621
- * revocation, a password reset or change — and called after the write commits. It carries no argument on
622
- * purpose: the sweep re-derives every socket's standing from the database, so no handler has to know
623
- * which sockets its change affects.
678
+ * revocation, a password reset or change — and called after the write commits. The sweep re-derives
679
+ * every socket's standing from the database, so when the database answers, no handler has to know which
680
+ * sockets its change affects.
681
+ *
682
+ * `affected` names the credential the write just reduced (`AuthChange`), and matters only when a lookup
683
+ * fails: the sockets resting on that credential are closed anyway (fail closed), because the one thing
684
+ * known for certain is that it just went down. Scoped to what the write touched, so revoking an unused
685
+ * CI token cannot take down the owner's agents and every session running on them. Every other socket is
686
+ * left alone as before.
624
687
  */
625
- onAuthChanged = () => {
626
- this.revalidateSockets(this.wss.clients);
688
+ onAuthChanged = (affected) => {
689
+ this.revalidateSockets(this.wss.clients, affected);
627
690
  };
628
- revalidateSockets(clients) {
691
+ revalidateSockets(clients, affected) {
629
692
  const now = Math.floor(Date.now() / 1000);
693
+ let fault = null;
630
694
  for (const ws of clients) {
631
695
  const principal = this.principals.get(ws);
632
696
  if (!principal || this.revokedSockets.has(ws) || ws.readyState !== WebSocket.OPEN)
@@ -651,13 +715,20 @@ export class RelayServer {
651
715
  }
652
716
  catch (err) {
653
717
  // A database fault is not a revocation: closing every socket on a transient `SQLITE_BUSY` would
654
- // disconnect the whole team. The next sweep asks again.
655
- logger.error('socket re-validation failed:', err);
656
- return;
718
+ // disconnect the whole team, so the others wait for the next sweep. The exception is the member
719
+ // whose access this sweep was run for: a write just took it away, and letting their socket keep
720
+ // sending until the database recovers is the hole this closes. Keep going rather than stopping at
721
+ // the first fault, or a socket of that member later in the set would never be reached.
722
+ fault ??= err;
723
+ if (affected && isAffectedBy(principal, affected))
724
+ this.closeForAuth(ws, WS_ACCESS_CHANGED_REASON);
725
+ continue;
657
726
  }
658
727
  if (reason !== null)
659
728
  this.closeForAuth(ws, reason);
660
729
  }
730
+ if (fault !== null)
731
+ logger.error('socket re-validation failed:', fault);
661
732
  }
662
733
  closeForAuth(ws, reason) {
663
734
  this.revokedSockets.add(ws);
@@ -743,6 +814,13 @@ export class RelayServer {
743
814
  this.idrRequesters.delete(sessionId);
744
815
  this.networkStateRequesters.get(sessionId)?.dispose();
745
816
  this.networkStateRequesters.delete(sessionId);
817
+ // Tracked boots die with the session too (#885 review). These paths — `session:end`,
818
+ // `session:leave`, the cross-identity removal — bypass `evictAgentSocket`, so without this
819
+ // the entry outlives the session it names. Harmless but unbounded: a late correlated reply
820
+ // still clears via `settleBoot`, and `invalidateBootsFor` sends nothing for a gone session,
821
+ // so this only bounds the map. Scoped by session, never by socket, so a rebound session's
822
+ // entry — which lives on under the same id — is untouched.
823
+ this.forgetEvictedBoots(new Set([sessionId]));
746
824
  }
747
825
  /**
748
826
  * Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
@@ -1010,6 +1088,7 @@ export class RelayServer {
1010
1088
  ws.on('close', () => {
1011
1089
  this.wsRoles.delete(ws);
1012
1090
  this.wsExternal.delete(ws);
1091
+ this.forgetShutdownRequesters(ws);
1013
1092
  // Agent main socket disconnected → hold its sessions open for a moment in case the agent is
1014
1093
  // coming back (#426), rather than ending them where they stand.
1015
1094
  if (this.holdAgentSocket(ws))
@@ -1223,7 +1302,12 @@ export class RelayServer {
1223
1302
  // `session:leave-error` would grow the wire for a message no consumer reads — and `session:end` has
1224
1303
  // no in-repo sender at all, so it would be a reply to nobody.
1225
1304
  case 'session:end': {
1226
- if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
1305
+ const ended = this.sessions.get(msg.sessionId);
1306
+ if (this.ownsSession(ws, ended)) {
1307
+ // Silently: the holder ended it, and the shutdown may still complete on the device — saying it
1308
+ // failed would be a guess. The caller keeps its own deadline, as it did before #567.
1309
+ if (ended)
1310
+ this.settleShutdownRequesters(ended, null);
1227
1311
  this.sessions.remove(msg.sessionId);
1228
1312
  this.forgetSessionState(msg.sessionId);
1229
1313
  }
@@ -1307,6 +1391,10 @@ export class RelayServer {
1307
1391
  break;
1308
1392
  }
1309
1393
  case 'device:boot-error': {
1394
+ // A correlated reply answers a tracked boot, whatever else it does — so the entry goes
1395
+ // even when nobody is left to forward to. An id-less frame answers no request, so it
1396
+ // clears nothing.
1397
+ this.settleBoot(msg.sessionId, msg.requestId);
1310
1398
  const session = this.sessions.get(msg.sessionId);
1311
1399
  if (session?.browserSocket?.readyState === WebSocket.OPEN) {
1312
1400
  session.browserSocket.send(JSON.stringify(raw));
@@ -1322,6 +1410,19 @@ export class RelayServer {
1322
1410
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1323
1411
  session.browserSocket.send(JSON.stringify(raw));
1324
1412
  }
1413
+ this.answerShutdownRequester(session, msg.requestId, raw);
1414
+ break;
1415
+ }
1416
+ case 'device:shutdown-error': {
1417
+ // The agent's half of the pair (#455). Device status is left alone: the shutdown was attempted
1418
+ // and not confirmed, so the device may well still be running.
1419
+ const session = this.sessions.get(msg.sessionId);
1420
+ if (!session)
1421
+ break;
1422
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
1423
+ session.browserSocket.send(JSON.stringify(raw));
1424
+ }
1425
+ this.answerShutdownRequester(session, msg.requestId, raw);
1325
1426
  break;
1326
1427
  }
1327
1428
  case 'device:ready': {
@@ -1330,6 +1431,8 @@ export class RelayServer {
1330
1431
  // an agent that omits it resolves no session here, exactly as before.
1331
1432
  if (msg.sessionId === undefined)
1332
1433
  break;
1434
+ // Correlated only: a replay or an old agent's id-less ready answers no tracked boot.
1435
+ this.settleBoot(msg.sessionId, msg.requestId);
1333
1436
  const session = this.sessions.get(msg.sessionId);
1334
1437
  if (!session)
1335
1438
  break;
@@ -1383,7 +1486,7 @@ export class RelayServer {
1383
1486
  // These two shared one fall-through clause. Separated because the sharing is the trap for
1384
1487
  // whoever adds the door gate: a correlator check written into a shared body would gate
1385
1488
  // `device:shutdown` too, and the relay originates that message with no id — so the dashboard's
1386
- // four senders and the relay's own idle timer would stop reaching the agent, silently, in the
1489
+ // three id-less senders and the relay's own idle timer would stop reaching the agent, silently, in the
1387
1490
  // one direction no reply reports. That gate is a schema now and cannot be written into a case at
1388
1491
  // all, which is what makes the trap unreachable rather than merely avoided.
1389
1492
  case 'device:boot': {
@@ -1422,6 +1525,9 @@ export class RelayServer {
1422
1525
  // presence check the old line carried is gone because the schema requires the payload — and
1423
1526
  // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
1424
1527
  msg.payload['external'] = this.wsExternal.get(ws) ?? false;
1528
+ // Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
1529
+ // tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
1530
+ this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
1425
1531
  // The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
1426
1532
  boot.session.agentSocket.send(JSON.stringify(msg));
1427
1533
  break;
@@ -1464,6 +1570,7 @@ export class RelayServer {
1464
1570
  });
1465
1571
  break;
1466
1572
  }
1573
+ this.rememberShutdownRequester(ws, target.session.id, msg.requestId);
1467
1574
  target.session.agentSocket.send(JSON.stringify(msg));
1468
1575
  break;
1469
1576
  }
@@ -1629,6 +1736,58 @@ export class RelayServer {
1629
1736
  * Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
1630
1737
  * they keep their inbound type and there is nothing new to check.
1631
1738
  */
1739
+ rememberShutdownRequester(ws, sessionId, requestId) {
1740
+ // The idle timer's shutdown and the dashboard's teardown carry no id, and nobody waits on either.
1741
+ if (requestId === undefined)
1742
+ return;
1743
+ const key = shutdownRequesterKey(sessionId, requestId);
1744
+ const prior = this.shutdownRequesters.get(key);
1745
+ if (prior)
1746
+ clearTimeout(prior.timer);
1747
+ // Bounds an entry an older agent will never answer — it has no failure reply to send.
1748
+ const timer = setTimeout(() => this.shutdownRequesters.delete(key), SHUTDOWN_REQUESTER_TTL_MS);
1749
+ this.shutdownRequesters.set(key, { sessionId, requestId, ws, timer });
1750
+ }
1751
+ /** Hands the agent's answer to whoever asked for it, when that is not the holder — who already has it. */
1752
+ answerShutdownRequester(session, requestId, raw) {
1753
+ if (requestId === undefined)
1754
+ return;
1755
+ const key = shutdownRequesterKey(session.id, requestId);
1756
+ const entry = this.shutdownRequesters.get(key);
1757
+ if (!entry)
1758
+ return;
1759
+ clearTimeout(entry.timer);
1760
+ this.shutdownRequesters.delete(key);
1761
+ // Compared now rather than at dispatch: a caller that has joined since holds the session, and was sent
1762
+ // this a line ago.
1763
+ if (entry.ws === session.browserSocket)
1764
+ return;
1765
+ if (entry.ws.readyState === WebSocket.OPEN)
1766
+ entry.ws.send(JSON.stringify(raw));
1767
+ }
1768
+ /**
1769
+ * The session is being removed, so an answer from its agent — if one ever comes — is dropped at
1770
+ * `sessions.get`. `message` null settles in silence; otherwise a caller that never joined is told.
1771
+ */
1772
+ settleShutdownRequesters(session, message) {
1773
+ for (const [key, entry] of this.shutdownRequesters) {
1774
+ if (entry.sessionId !== session.id)
1775
+ continue;
1776
+ clearTimeout(entry.timer);
1777
+ this.shutdownRequesters.delete(key);
1778
+ if (message === null || entry.ws === session.browserSocket)
1779
+ continue;
1780
+ this.sendTo(entry.ws, { type: 'device:shutdown-error', sessionId: session.id, requestId: entry.requestId, message });
1781
+ }
1782
+ }
1783
+ forgetShutdownRequesters(ws) {
1784
+ for (const [key, entry] of this.shutdownRequesters) {
1785
+ if (entry.ws !== ws)
1786
+ continue;
1787
+ clearTimeout(entry.timer);
1788
+ this.shutdownRequesters.delete(key);
1789
+ }
1790
+ }
1632
1791
  sendTo(socket, msg) {
1633
1792
  if (socket.readyState !== WebSocket.OPEN)
1634
1793
  return;
@@ -1651,6 +1810,77 @@ export class RelayServer {
1651
1810
  }
1652
1811
  }
1653
1812
  }
1813
+ bootKey(sessionId, requestId) {
1814
+ return `${sessionId}${requestId}`;
1815
+ }
1816
+ /** Record a boot against the agent socket it was dispatched to. The door guarantees both ids. */
1817
+ rememberBoot(sessionId, requestId, agentSocket) {
1818
+ const key = this.bootKey(sessionId, requestId);
1819
+ // Replacing, not doubling: a client never reuses a correlator, but forgetting the old timer
1820
+ // here is what keeps a replaced entry from firing after its successor was settled.
1821
+ this.forgetBoot(key);
1822
+ const timer = setTimeout(() => this.forgetBoot(key), this.pendingBootTtlMs);
1823
+ this.pendingBoots.set(key, { sessionId, requestId, agentSocket, timer });
1824
+ }
1825
+ /** The one way an entry leaves: every removal path funnels through here so no timer survives it. */
1826
+ forgetBoot(key) {
1827
+ const pending = this.pendingBoots.get(key);
1828
+ if (!pending)
1829
+ return;
1830
+ clearTimeout(pending.timer);
1831
+ this.pendingBoots.delete(key);
1832
+ }
1833
+ /**
1834
+ * Drop the tracked boot a correlated reply answers. The key needs no live session: an agent
1835
+ * answering after its session was removed still answers that request. An absent `requestId`
1836
+ * is not the answer to anything, so it drops nothing.
1837
+ */
1838
+ settleBoot(sessionId, requestId) {
1839
+ if (typeof sessionId !== 'string' || typeof requestId !== 'string')
1840
+ return;
1841
+ this.forgetBoot(this.bootKey(sessionId, requestId));
1842
+ }
1843
+ /**
1844
+ * Answer the tracked boots tied to a replaced agent socket (#885). Called after the
1845
+ * `session:rebound` frames are sent, so the client records `needsReboot` first and the error
1846
+ * below is classified with the cause attached. Boots dispatched to any other socket — in
1847
+ * particular to the new agent after the rebind — are left pending.
1848
+ */
1849
+ invalidateBootsFor(oldSockets) {
1850
+ if (oldSockets.size === 0)
1851
+ return;
1852
+ for (const [key, pending] of this.pendingBoots.entries()) {
1853
+ if (!oldSockets.has(pending.agentSocket))
1854
+ continue;
1855
+ this.forgetBoot(key);
1856
+ const session = this.sessions.get(pending.sessionId);
1857
+ // `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
1858
+ // `browserSocket` at all — either way the entry is still gone, so nothing later answers
1859
+ // a request nobody holds.
1860
+ if (!session?.browserSocket)
1861
+ continue;
1862
+ this.sendTo(session.browserSocket, {
1863
+ type: 'device:boot-error',
1864
+ sessionId: pending.sessionId,
1865
+ requestId: pending.requestId,
1866
+ message: `session ${pending.sessionId} rebounded to a new agent while this boot was in flight, ` +
1867
+ 'and that agent never saw this request',
1868
+ });
1869
+ }
1870
+ }
1871
+ /**
1872
+ * Silently drop the tracked boots of sessions that no longer exist. Their callers were already
1873
+ * settled with the better diagnosis (`session:terminated`), so answering as well would send a
1874
+ * second failure for the same request. Scoped to the evicted sessions rather than the socket:
1875
+ * rebound sessions have already moved off it, and their entries are `invalidateBootsFor`'s to
1876
+ * answer, not this one's to drop.
1877
+ */
1878
+ forgetEvictedBoots(sessionIds) {
1879
+ for (const [key, pending] of this.pendingBoots.entries()) {
1880
+ if (sessionIds.has(pending.sessionId))
1881
+ this.forgetBoot(key);
1882
+ }
1883
+ }
1654
1884
  /**
1655
1885
  * The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
1656
1886
  * of ending them here — a restarting agent registers about a second later, and until #426 stage 3
@@ -1725,6 +1955,10 @@ export class RelayServer {
1725
1955
  if (agentSessions.length === 0)
1726
1956
  return false;
1727
1957
  this.rejectPending(new Set(agentSessions.map((s) => s.id)), 'Agent disconnected');
1958
+ // Their tracked boots go with them, silently: the `session:terminated` below settles those
1959
+ // callers with the better diagnosis. Rebound sessions are not in this set — they moved off
1960
+ // this socket — so their boots survive for `invalidateBootsFor` to answer.
1961
+ this.forgetEvictedBoots(new Set(agentSessions.map((s) => s.id)));
1728
1962
  // Tell whoever is attached before the session stops existing — after `remove()` the socket
1729
1963
  // reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
1730
1964
  // relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
@@ -1741,7 +1975,10 @@ export class RelayServer {
1741
1975
  });
1742
1976
  }
1743
1977
  }
1978
+ // After `session:terminated`, so a caller that holds the session is settled by that — it carries the
1979
+ // better diagnosis — and only a caller that never joined hears from `settleShutdownRequesters`.
1744
1980
  for (const s of agentSessions) {
1981
+ this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
1745
1982
  this.sessions.remove(s.id);
1746
1983
  this.forgetSessionState(s.id);
1747
1984
  }
@@ -1774,6 +2011,10 @@ export class RelayServer {
1774
2011
  // deduplicate — the second card would name a session the agent has never heard of, which is the
1775
2012
  // symptom this whole change is fixing.
1776
2013
  const rebound = new Map();
2014
+ // Sockets a rebind actually moved a session off of. Only boots tied to one of these are
2015
+ // stranded: a boot dispatched after the rebind below rides the new socket, and the eviction
2016
+ // further down must not sweep the entries this set protects (see `forgetEvictedBoots`).
2017
+ const reboundOldSockets = new Set();
1777
2018
  if (identity) {
1778
2019
  for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
1779
2020
  if (old === ws)
@@ -1793,6 +2034,7 @@ export class RelayServer {
1793
2034
  continue;
1794
2035
  this.sessions.rebind(s.id, ws, device, agent);
1795
2036
  rebound.set(s.deviceId, s.id);
2037
+ reboundOldSockets.add(old);
1796
2038
  }
1797
2039
  // Evict before terminate: the old socket's close fires async, by which point its sessions are
1798
2040
  // gone and its in-flight screenshots would be undiscoverable — reject them here instead.
@@ -1803,6 +2045,7 @@ export class RelayServer {
1803
2045
  }
1804
2046
  // Their in-flight requests are addressed to a process that is gone, and the eviction above can
1805
2047
  // no longer see them. Nothing else would ever settle these.
2048
+ // Shutdowns are deliberately not settled here — see `shutdownRequesters` for why they follow the session.
1806
2049
  if (rebound.size > 0)
1807
2050
  this.rejectPending(new Set(rebound.values()), 'Agent restarted');
1808
2051
  // A device can be back under an agent this session cannot be rebound to — identity is
@@ -1819,6 +2062,7 @@ export class RelayServer {
1819
2062
  if (s.browserSocket) {
1820
2063
  this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
1821
2064
  }
2065
+ this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
1822
2066
  this.sessions.remove(s.id);
1823
2067
  this.forgetSessionState(s.id);
1824
2068
  }
@@ -1841,6 +2085,11 @@ export class RelayServer {
1841
2085
  this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
1842
2086
  }
1843
2087
  }
2088
+ // After the rebound frames, so the client records `needsReboot` before the error below
2089
+ // arrives and classifies it with the cause attached. Only boots tied to the replaced
2090
+ // sockets: a boot sent after the rebind above was dispatched to the new agent, which boots
2091
+ // the device and answers — settling it here would fail a boot that succeeds (#885).
2092
+ this.invalidateBootsFor(reboundOldSockets);
1844
2093
  if (rebound.size > 0)
1845
2094
  logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
1846
2095
  // The startup banner prints "Waiting for agents..." once and then the relay says nothing either
@@ -1868,8 +2117,9 @@ export class RelayServer {
1868
2117
  // pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
1869
2118
  // It is a map read the relay has already done, so ordering it first costs nothing.
1870
2119
  //
1871
- // `!== ws` because a socket re-joining the session it already holds is not contending with anyone:
1872
- // `SessionList` sends `session:start` before a shutdown, so pressing shutdown twice hit this.
2120
+ // `owner !== ownerOf(ws)` because a client re-joining a session it already holds is not contending
2121
+ // with anyone: the viewer re-sends `session:start` each time its socket reconnects, and `mcp-server`
2122
+ // and `flow-runner` keep one `clientId` per process, so their explicit re-join is the same client.
1873
2123
  // The same three conditions `join()` applies, and they have to agree: this one answers the caller and
1874
2124
  // that one performs the bind, so a disagreement is a refusal for a session the bind would have allowed.
1875
2125
  if (session.browserSocket &&
@@ -1988,7 +2238,7 @@ export class RelayServer {
1988
2238
  // skipping safe is not the agent's silence but the viewer's: with `readySent` false the
1989
2239
  // `device:ready` above is not replayed either, so nothing is rendering a live device to be wrong
1990
2240
  // about. `deviceStatus` is not the fix — the comment on `device:ready` says why (#440).
1991
- if (session.agentCapabilities?.includes('network-control'))
2241
+ if (hasCapability(session.agentCapabilities, 'network-control'))
1992
2242
  this.networkStateRequester(session.id)();
1993
2243
  }
1994
2244
  }
@@ -2350,7 +2600,7 @@ export class RelayServer {
2350
2600
  * laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
2351
2601
  */
2352
2602
  warnLegacyInstaller(identity, capabilities) {
2353
- if (capabilities.includes('build-download') || this.warnedLegacyInstaller.has(identity))
2603
+ if (hasCapability(capabilities, 'build-download') || this.warnedLegacyInstaller.has(identity))
2354
2604
  return;
2355
2605
  this.warnedLegacyInstaller.add(identity);
2356
2606
  logger.warn(`[relay] agent ${identity} predates build downloading. If it is not on the same machine as ` +
@@ -2654,6 +2904,17 @@ export class RelayServer {
2654
2904
  serveStatic(req, res) {
2655
2905
  const urlPath = (req.url ?? '/').split('?')[0];
2656
2906
  let filePath = path.join(this.publicDir, urlPath === '/' ? '/index.html' : urlPath);
2907
+ // **Contained before anything is read or probed.** `req.url` is the raw request target and Node does
2908
+ // not normalise it, so `/../../etc/passwd` reached `path.join` intact and the join walked out of
2909
+ // `publicDir` — unauthenticated, and the relay's environment and database were one request away.
2910
+ // `serveUpload` has had this check since #173. Checked on the joined path because the index lookup and
2911
+ // the `.br`/`.gz` siblings below are all derived from it; the separator keeps a sibling directory
2912
+ // whose name starts with `publicDir`'s outside.
2913
+ if (!path.resolve(filePath).startsWith(path.resolve(this.publicDir) + path.sep)) {
2914
+ res.writeHead(404);
2915
+ res.end('Not found');
2916
+ return;
2917
+ }
2657
2918
  // Next.js static export: try exact path, then path/index.html (trailingSlash)
2658
2919
  if (!fs.existsSync(filePath)) {
2659
2920
  const withIndex = path.join(filePath, 'index.html');