@tapflowio/relay 0.26.1 → 0.27.1

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 (161) hide show
  1. package/dist/RelayServer.d.ts +92 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +352 -13
  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/builds.d.ts.map +1 -1
  10. package/dist/api/builds.js +13 -8
  11. package/dist/api/builds.js.map +1 -1
  12. package/dist/api/comments.d.ts.map +1 -1
  13. package/dist/api/comments.js +5 -7
  14. package/dist/api/comments.js.map +1 -1
  15. package/dist/api/invitations.d.ts.map +1 -1
  16. package/dist/api/invitations.js +3 -0
  17. package/dist/api/invitations.js.map +1 -1
  18. package/dist/api/profile.d.ts.map +1 -1
  19. package/dist/api/profile.js +28 -9
  20. package/dist/api/profile.js.map +1 -1
  21. package/dist/api/recordings.d.ts.map +1 -1
  22. package/dist/api/recordings.js +5 -6
  23. package/dist/api/recordings.js.map +1 -1
  24. package/dist/api/settings.d.ts.map +1 -1
  25. package/dist/api/settings.js +28 -9
  26. package/dist/api/settings.js.map +1 -1
  27. package/dist/lib/clientAddress.d.ts +12 -0
  28. package/dist/lib/clientAddress.d.ts.map +1 -1
  29. package/dist/lib/clientAddress.js +22 -0
  30. package/dist/lib/clientAddress.js.map +1 -1
  31. package/dist/lib/uploads.d.ts +13 -0
  32. package/dist/lib/uploads.d.ts.map +1 -1
  33. package/dist/lib/uploads.js +41 -0
  34. package/dist/lib/uploads.js.map +1 -1
  35. package/dist/lib/webhookTransport.d.ts +29 -0
  36. package/dist/lib/webhookTransport.d.ts.map +1 -0
  37. package/dist/lib/webhookTransport.js +86 -0
  38. package/dist/lib/webhookTransport.js.map +1 -0
  39. package/dist/lib/webhookUrl.d.ts +6 -0
  40. package/dist/lib/webhookUrl.d.ts.map +1 -1
  41. package/dist/lib/webhookUrl.js +50 -14
  42. package/dist/lib/webhookUrl.js.map +1 -1
  43. package/dist/lib/webhooks.d.ts +2 -10
  44. package/dist/lib/webhooks.d.ts.map +1 -1
  45. package/dist/lib/webhooks.js +8 -3
  46. package/dist/lib/webhooks.js.map +1 -1
  47. package/package.json +6 -6
  48. package/public/assets/{AppCenter-DZCsC2wa.js → AppCenter-CeLjUbnR.js} +7 -7
  49. package/public/assets/AppCenter-CeLjUbnR.js.br +0 -0
  50. package/public/assets/AppCenter-CeLjUbnR.js.gz +0 -0
  51. package/public/assets/Default-UB0Pa3lN.js +1 -0
  52. package/public/assets/Default-UB0Pa3lN.js.br +0 -0
  53. package/public/assets/Default-UB0Pa3lN.js.gz +0 -0
  54. package/public/assets/Invite-Dv3omu3x.js +1 -0
  55. package/public/assets/Invite-Dv3omu3x.js.br +0 -0
  56. package/public/assets/Invite-Dv3omu3x.js.gz +0 -0
  57. package/public/assets/{MacResources-B0DQ9ISE.js → MacResources-CY4-OdGt.js} +1 -1
  58. package/public/assets/MacResources-CY4-OdGt.js.br +0 -0
  59. package/public/assets/MacResources-CY4-OdGt.js.gz +0 -0
  60. package/public/assets/QASession-H5eRSwZv.js +150 -0
  61. package/public/assets/QASession-H5eRSwZv.js.br +0 -0
  62. package/public/assets/QASession-H5eRSwZv.js.gz +0 -0
  63. package/public/assets/ResetPassword-nFTigYt2.js +1 -0
  64. package/public/assets/ResetPassword-nFTigYt2.js.br +0 -0
  65. package/public/assets/ResetPassword-nFTigYt2.js.gz +0 -0
  66. package/public/assets/Setup-UmLtF2PF.js +1 -0
  67. package/public/assets/Setup-UmLtF2PF.js.br +0 -0
  68. package/public/assets/Setup-UmLtF2PF.js.gz +0 -0
  69. package/public/assets/Team-DW40kt3H.js +6 -0
  70. package/public/assets/Team-DW40kt3H.js.br +0 -0
  71. package/public/assets/Team-DW40kt3H.js.gz +0 -0
  72. package/public/assets/Tokens-B1a4ChJK.js +1 -0
  73. package/public/assets/Tokens-B1a4ChJK.js.br +0 -0
  74. package/public/assets/Tokens-B1a4ChJK.js.gz +0 -0
  75. package/public/assets/alert-dialog-aoqYdAwx.js +1 -0
  76. package/public/assets/alert-dialog-aoqYdAwx.js.br +0 -0
  77. package/public/assets/alert-dialog-aoqYdAwx.js.gz +0 -0
  78. package/public/assets/badge-DNaS0Mu3.js +1 -0
  79. package/public/assets/badge-DNaS0Mu3.js.br +0 -0
  80. package/public/assets/badge-DNaS0Mu3.js.gz +0 -0
  81. package/public/assets/{build-format-fzt-JrUX.js → build-format-DF5aFcag.js} +3 -3
  82. package/public/assets/build-format-DF5aFcag.js.br +0 -0
  83. package/public/assets/build-format-DF5aFcag.js.gz +0 -0
  84. package/public/assets/{dialog-DQkbaw8h.js → dialog-DE2WUMuX.js} +5 -5
  85. package/public/assets/dialog-DE2WUMuX.js.br +0 -0
  86. package/public/assets/dialog-DE2WUMuX.js.gz +0 -0
  87. package/public/assets/field-hint-Csb40Gzp.js +1 -0
  88. package/public/assets/field-hint-Csb40Gzp.js.br +2 -0
  89. package/public/assets/field-hint-Csb40Gzp.js.gz +0 -0
  90. package/public/assets/index-BbCkTtjO.css +1 -0
  91. package/public/assets/index-BbCkTtjO.css.br +0 -0
  92. package/public/assets/index-BbCkTtjO.css.gz +0 -0
  93. package/public/assets/index-DSW9h8XX.js +218 -0
  94. package/public/assets/index-DSW9h8XX.js.br +0 -0
  95. package/public/assets/index-DSW9h8XX.js.gz +0 -0
  96. package/public/assets/list-view-C0w97ltJ.js +1 -0
  97. package/public/assets/list-view-C0w97ltJ.js.br +0 -0
  98. package/public/assets/list-view-C0w97ltJ.js.gz +0 -0
  99. package/public/assets/{pencil-BJcOp3oN.js → pencil-BN18R4oa.js} +2 -2
  100. package/public/assets/pencil-BN18R4oa.js.br +0 -0
  101. package/public/assets/pencil-BN18R4oa.js.gz +0 -0
  102. package/public/assets/resource-health-Bi20EUDs.js +1 -0
  103. package/public/assets/resource-health-Bi20EUDs.js.br +0 -0
  104. package/public/assets/resource-health-Bi20EUDs.js.gz +0 -0
  105. package/public/assets/{trash-DhBxh6H7.js → trash-D1l1Fm-Z.js} +3 -3
  106. package/public/assets/trash-D1l1Fm-Z.js.br +0 -0
  107. package/public/assets/trash-D1l1Fm-Z.js.gz +0 -0
  108. package/public/index.html +2 -2
  109. package/public/index.html.br +0 -0
  110. package/public/index.html.gz +0 -0
  111. package/public/assets/AppCenter-DZCsC2wa.js.br +0 -0
  112. package/public/assets/AppCenter-DZCsC2wa.js.gz +0 -0
  113. package/public/assets/Default-xD9Fmd5X.js +0 -1
  114. package/public/assets/Default-xD9Fmd5X.js.br +0 -0
  115. package/public/assets/Default-xD9Fmd5X.js.gz +0 -0
  116. package/public/assets/Invite-29Erwv8j.js +0 -1
  117. package/public/assets/Invite-29Erwv8j.js.br +0 -0
  118. package/public/assets/Invite-29Erwv8j.js.gz +0 -0
  119. package/public/assets/MacResources-B0DQ9ISE.js.br +0 -0
  120. package/public/assets/MacResources-B0DQ9ISE.js.gz +0 -0
  121. package/public/assets/QASession-D-n6PXuD.js +0 -150
  122. package/public/assets/QASession-D-n6PXuD.js.br +0 -0
  123. package/public/assets/QASession-D-n6PXuD.js.gz +0 -0
  124. package/public/assets/ResetPassword-BnPIwTYH.js +0 -1
  125. package/public/assets/ResetPassword-BnPIwTYH.js.br +0 -0
  126. package/public/assets/ResetPassword-BnPIwTYH.js.gz +0 -0
  127. package/public/assets/Setup-DmUGFblg.js +0 -1
  128. package/public/assets/Setup-DmUGFblg.js.br +0 -0
  129. package/public/assets/Setup-DmUGFblg.js.gz +0 -0
  130. package/public/assets/Team-BpvRgFfZ.js +0 -6
  131. package/public/assets/Team-BpvRgFfZ.js.br +0 -0
  132. package/public/assets/Team-BpvRgFfZ.js.gz +0 -0
  133. package/public/assets/Tokens-x6BtCcL7.js +0 -1
  134. package/public/assets/Tokens-x6BtCcL7.js.br +0 -0
  135. package/public/assets/Tokens-x6BtCcL7.js.gz +0 -0
  136. package/public/assets/alert-dialog-C8wDimW-.js +0 -1
  137. package/public/assets/alert-dialog-C8wDimW-.js.br +0 -0
  138. package/public/assets/alert-dialog-C8wDimW-.js.gz +0 -0
  139. package/public/assets/badge-CWVbeuH3.js +0 -1
  140. package/public/assets/badge-CWVbeuH3.js.br +0 -0
  141. package/public/assets/badge-CWVbeuH3.js.gz +0 -0
  142. package/public/assets/build-format-fzt-JrUX.js.br +0 -0
  143. package/public/assets/build-format-fzt-JrUX.js.gz +0 -0
  144. package/public/assets/dialog-DQkbaw8h.js.br +0 -0
  145. package/public/assets/dialog-DQkbaw8h.js.gz +0 -0
  146. package/public/assets/index-C1Fo8g_c.js +0 -218
  147. package/public/assets/index-C1Fo8g_c.js.br +0 -0
  148. package/public/assets/index-C1Fo8g_c.js.gz +0 -0
  149. package/public/assets/index-C2F1AgwB.css +0 -1
  150. package/public/assets/index-C2F1AgwB.css.br +0 -0
  151. package/public/assets/index-C2F1AgwB.css.gz +0 -0
  152. package/public/assets/list-view-DxI397-H.js +0 -1
  153. package/public/assets/list-view-DxI397-H.js.br +0 -0
  154. package/public/assets/list-view-DxI397-H.js.gz +0 -0
  155. package/public/assets/pencil-BJcOp3oN.js.br +0 -0
  156. package/public/assets/pencil-BJcOp3oN.js.gz +0 -0
  157. package/public/assets/resource-health-Cq3gqqVd.js +0 -1
  158. package/public/assets/resource-health-Cq3gqqVd.js.br +0 -0
  159. package/public/assets/resource-health-Cq3gqqVd.js.gz +0 -0
  160. package/public/assets/trash-DhBxh6H7.js.br +0 -0
  161. package/public/assets/trash-DhBxh6H7.js.gz +0 -0
@@ -10,7 +10,7 @@ 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
12
  import { AGENT_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
13
- import { isTunnelIngress, markTunnelIngress, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
13
+ import { isTunnelIngress, markTunnelIngress, proxiedWithoutForwardedFor, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
14
14
  import { BuildTicketStore } from './lib/buildTickets.js';
15
15
  import { resolveBuildFile } from './lib/buildFiles.js';
16
16
  import { resolveCorsHeaders } from './lib/cors.js';
@@ -22,7 +22,8 @@ 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
+ import { acquireLoopStallWatch } from '@tapflowio/agent-core/utils';
26
27
  import { createKeyframeAwareSender, createRateLimitedDropWarn, sendAudioYieldingToVideo, DEFAULT_BACKPRESSURE_BYTES, hasEnvelope, patchRelayedAt, readEnvelopeFlags, CODEC_JPEG, CODEC_AUDIO, } from '@tapflowio/agent-core/utils';
27
28
  const logger = createLogger('relay');
28
29
  // True when a remote IP is public — i.e. not loopback, private LAN, or link-local. Used to pick the
@@ -171,6 +172,19 @@ function describeRejection(failure, suppressed) {
171
172
  const outcome = failure.reason === 'bad-payload' ? 'refused, and the sender told' : 'dropped';
172
173
  return `[tapflow] inbound ${failure.type} does not match the contract — ${outcome}${also}:\n${failure.detail}`;
173
174
  }
175
+ /** Just past `mcp-server`'s 30s shutdown deadline, so an entry outlives every caller still waiting on it. */
176
+ const SHUTDOWN_REQUESTER_TTL_MS = 35_000;
177
+ /**
178
+ * How long a tracked boot lives without an answer. The same horizon as both clients'
179
+ * `BOOT_DEADLINE_MS`: past it the caller has already timed out, so the entry is pure
180
+ * bookkeeping. Expiry forgets silently — it sends no failure, because the client timeout
181
+ * owns that case. This bounds the map, not the boot: it changes nothing about how long a
182
+ * boot may take, which is #588's question, not this one's.
183
+ */
184
+ const PENDING_BOOT_TTL_MS = 180_000;
185
+ /** Said when a session ends with a shutdown unanswered — which is not the same as the shutdown failing. */
186
+ const SHUTDOWN_OUTCOME_LOST = 'The session ended before its agent confirmed the shutdown, so whether the device shut down is unknown.';
187
+ const shutdownRequesterKey = (sessionId, requestId) => `${sessionId}\n${requestId}`;
174
188
  export class RelayServer {
175
189
  options;
176
190
  httpServer;
@@ -201,6 +215,15 @@ export class RelayServer {
201
215
  purgeBuildsTimer = null;
202
216
  flushResourcesTimer = null;
203
217
  heartbeatTimer = null;
218
+ /** When the last heartbeat sweep ran, by the same clock `isAlive` judges with. */
219
+ lastSweepAt = 0;
220
+ /** Names agents give at registration, kept apart from the session map so an agent with no sessions
221
+ * can still be named when its socket goes. */
222
+ agentNames = new WeakMap();
223
+ releaseStallWatch = null;
224
+ /** Agent sockets whose close was already explained — ended by a sweep, or replaced by the same agent
225
+ * registering again — so the close handler does not log them as a fresh drop. */
226
+ explainedCloses = new WeakMap();
204
227
  /**
205
228
  * When each socket last answered a ping. WeakMap → no manual cleanup on close (GC handles it).
206
229
  *
@@ -278,15 +301,52 @@ export class RelayServer {
278
301
  backpressureBytes;
279
302
  screenshotTimeoutMs;
280
303
  agentGraceMs;
304
+ /** How long a tracked boot lives without an answer. An option so tests can shrink the
305
+ * 180s horizon; production always takes the default. */
306
+ pendingBootTtlMs;
281
307
  corsAllowed;
282
308
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
283
309
  warnedProxyMisconfig = false;
310
+ warnedTrustedProxyWithoutXff = false;
284
311
  /** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
285
312
  warnedLegacyInstaller = new Set();
286
313
  buildTickets = new BuildTicketStore();
287
314
  pendingScreenshots = new Map();
288
315
  uiTreeTimeoutMs;
289
316
  pendingUITrees = new Map();
317
+ /**
318
+ * Who asked for each correlated `device:shutdown` still waiting on its agent (#567). The agent's answer
319
+ * goes to the session's `browserSocket`, and a caller that never joined — an MCP client shutting down a
320
+ * session nobody holds, which `mayShutDown` permits — has no socket there, so it waited out its 30s for a
321
+ * shutdown that had happened. Keyed by session **and** id, because the id is chosen by the client and
322
+ * would alone let one session's answer reach another session's caller.
323
+ *
324
+ * **Settled when the session is removed, never when the agent socket drops — the opposite of a boot.**
325
+ * Both agents reconnect without restarting, and a shutdown that finishes after the reconnect is answered
326
+ * on the new socket, so an agent going away or being rebound is not evidence that the answer is lost.
327
+ * For a boot it is (#885): the binding a boot creates is exactly what a rebind loses. Folding the two
328
+ * into one rule would tell a caller its shutdown failed for a device that then powered off.
329
+ */
330
+ shutdownRequesters = new Map();
331
+ /**
332
+ * In-flight `device:boot` requests, keyed by session and correlator (#885).
333
+ *
334
+ * The relay is the only layer that knows which agent socket each boot was actually dispatched
335
+ * to, so it owns settling the ones a rebind strands. The client cannot tell "issued before the
336
+ * rebound" from "delivered to the old agent", which is why the previous client-side settle on
337
+ * `session:rebound` answered boots the new agent was already handling. Entries record the
338
+ * concrete agent socket from the `device:boot` dispatch, so a rebind invalidates only the boots
339
+ * tied to the replaced socket and leaves a boot dispatched to the new socket alone.
340
+ *
341
+ * Cleared when a correlated `device:ready` or `device:boot-error` arrives, when the boot is
342
+ * invalidated at rebind, when its session is forgotten or evicted — and by its own expiry
343
+ * (`PENDING_BOOT_TTL_MS`) when none of those happen first, so a live agent that never answers
344
+ * cannot pin the entry past every caller that could have waited on it. An absent `requestId`
345
+ * answers no request (protocol/AGENTS.md 「Lifecycle correlation」), so id-less replies clear
346
+ * nothing. A leftover entry is harmless: its id names no waiter, so both clients and the
347
+ * dashboard drop the synthetic error it would produce.
348
+ */
349
+ pendingBoots = new Map();
290
350
  constructor(options) {
291
351
  this.options = options;
292
352
  // 0 on both sides is two ephemeral ports, which is what the tests ask for.
@@ -311,6 +371,7 @@ export class RelayServer {
311
371
  const graceEnv = graceRaw ? Number(graceRaw) : NaN;
312
372
  const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
313
373
  this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
374
+ this.pendingBootTtlMs = options.pendingBootTtlMs ?? PENDING_BOOT_TTL_MS;
314
375
  // Say so rather than only documenting it. Both times this parsing was wrong the symptom was
315
376
  // the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
316
377
  // reading their terminal, not the configuration table.
@@ -476,8 +537,17 @@ export class RelayServer {
476
537
  this.purgeBuildsTimer.unref();
477
538
  this.flushResourcesTimer = setInterval(() => this.flushResourceBuffers(), 60_000);
478
539
  this.flushResourcesTimer.unref();
479
- this.heartbeatTimer = setInterval(() => this.runHeartbeat(), HEARTBEAT_MS);
540
+ this.lastSweepAt = Date.now();
541
+ this.heartbeatTimer = setInterval(() => { this.noteLateSweep(); this.runHeartbeat(); }, HEARTBEAT_MS);
480
542
  this.heartbeatTimer.unref();
543
+ this.releaseStallWatch ??= acquireLoopStallWatch();
544
+ // The rule a proxy has to follow, said once to whoever set the list: the case it covers (a proxy that
545
+ // sends no forwarding header at all) is one no request-time check can see.
546
+ const trustedProxies = this.options.trustedProxies ?? [];
547
+ if (trustedProxies.length > 0) {
548
+ logger.info(`Trusted proxies: ${trustedProxies.join(', ')}. A request from one of these without ` +
549
+ 'X-Forwarded-For is treated as coming from that address itself, so the proxy must send X-Forwarded-For.');
550
+ }
481
551
  return new Promise((resolve, reject) => {
482
552
  this.httpServer.once('error', (err) => {
483
553
  if (err.code === 'EADDRINUSE') {
@@ -536,6 +606,8 @@ export class RelayServer {
536
606
  clearInterval(this.heartbeatTimer);
537
607
  this.heartbeatTimer = null;
538
608
  }
609
+ this.releaseStallWatch?.();
610
+ this.releaseStallWatch = null;
539
611
  // `unref()` would not do: the test runner's process outlives the server, so an un-cleared hold
540
612
  // still fires — against a server that has already stopped.
541
613
  this.stopping = true;
@@ -550,6 +622,13 @@ export class RelayServer {
550
622
  for (const requester of this.networkStateRequesters.values())
551
623
  requester.dispose();
552
624
  this.networkStateRequesters.clear();
625
+ for (const entry of this.shutdownRequesters.values())
626
+ clearTimeout(entry.timer);
627
+ this.shutdownRequesters.clear();
628
+ // Same argument as the holds above: an expiry firing after `stop()` would run against a dead
629
+ // server, and an unref'd 180s timer still outlives the test runner's process.
630
+ for (const key of this.pendingBoots.keys())
631
+ this.forgetBoot(key);
553
632
  const closeTunnel = new Promise((resolve, reject) => {
554
633
  // Not listening means `start()` never reached it, or its listen failed; `close()` would reject.
555
634
  if (!this.tunnelServer?.listening)
@@ -607,14 +686,42 @@ export class RelayServer {
607
686
  // interval, whatever path the change took. `onAuthChanged` is the same check run early.
608
687
  runHeartbeat(clients = this.wss.clients) {
609
688
  this.revalidateSockets(clients);
689
+ // Agents one line each; everything else summed, because a relay waking from sleep finds every socket
690
+ // stale at once and one line per browser tab would push the agent lines out of the log buffer.
691
+ const others = new Map();
610
692
  for (const ws of clients) {
611
693
  if (!this.isAlive(ws)) {
694
+ const role = this.wsRoles.get(ws);
695
+ if (role === 'agent') {
696
+ this.pushLog(`heartbeat terminated agent ${this.agentNames.get(ws) ?? 'unknown'} — last pong ${this.pongAgeSeconds(ws)}s ago`);
697
+ this.explainedCloses.set(ws, 'terminated');
698
+ }
699
+ else {
700
+ const key = role ?? 'unregistered';
701
+ others.set(key, (others.get(key) ?? 0) + 1);
702
+ }
612
703
  ws.terminate();
613
704
  continue;
614
705
  }
615
706
  if (ws.readyState === WebSocket.OPEN)
616
707
  ws.ping();
617
708
  }
709
+ if (others.size > 0) {
710
+ this.pushLog(`heartbeat terminated ${[...others].map(([role, n]) => `${n} ${role}`).join(', ')} socket(s)`);
711
+ }
712
+ }
713
+ /** A sweep that runs late means this process was held, or the machine slept: the pongs it judges were
714
+ * never read, so its terminations say nothing about the clients. Same clock as `isAlive`. */
715
+ noteLateSweep(now = Date.now()) {
716
+ const late = now - this.lastSweepAt - HEARTBEAT_MS;
717
+ if (late > HEARTBEAT_MS / 2) {
718
+ this.pushLog(`heartbeat sweep ran ${Math.round(late / 1000)}s late — the relay was stalled or the system slept; this sweep may end sockets that were alive`);
719
+ }
720
+ this.lastSweepAt = now;
721
+ }
722
+ pongAgeSeconds(ws) {
723
+ const at = this.lastPongAt.get(ws);
724
+ return at === undefined ? '?' : Math.round((Date.now() - at) / 1000);
618
725
  }
619
726
  /**
620
727
  * Injected into the handlers whose write can take access away — member update and removal, token
@@ -757,6 +864,13 @@ export class RelayServer {
757
864
  this.idrRequesters.delete(sessionId);
758
865
  this.networkStateRequesters.get(sessionId)?.dispose();
759
866
  this.networkStateRequesters.delete(sessionId);
867
+ // Tracked boots die with the session too (#885 review). These paths — `session:end`,
868
+ // `session:leave`, the cross-identity removal — bypass `evictAgentSocket`, so without this
869
+ // the entry outlives the session it names. Harmless but unbounded: a late correlated reply
870
+ // still clears via `settleBoot`, and `invalidateBootsFor` sends nothing for a gone session,
871
+ // so this only bounds the map. Scoped by session, never by socket, so a rebound session's
872
+ // entry — which lives on under the same id — is untouched.
873
+ this.forgetEvictedBoots(new Set([sessionId]));
760
874
  }
761
875
  /**
762
876
  * Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
@@ -833,6 +947,24 @@ export class RelayServer {
833
947
  remoteAddressOf(request) {
834
948
  return request.socket.remoteAddress ?? '';
835
949
  }
950
+ /**
951
+ * Called on both doors — the WebSocket upgrade and every HTTP request — since `auth/init` and the logs
952
+ * route decide locality on the HTTP side. Not on the tunnel listener, where every request is remote
953
+ * whatever it carries.
954
+ */
955
+ warnTrustedProxyWithoutXffOnce(request) {
956
+ if (this.warnedTrustedProxyWithoutXff || isTunnelIngress(request))
957
+ return;
958
+ const header = proxiedWithoutForwardedFor(this.remoteAddressOf(request), request.headers, this.options.trustedProxies ?? []);
959
+ if (!header)
960
+ return;
961
+ logger.warn(`A request from a trusted proxy (TAPFLOW_TRUSTED_PROXIES) carried ${header} but no X-Forwarded-For. ` +
962
+ 'Without that header the relay cannot see the real client, so it treats the request as coming from the ' +
963
+ 'proxy itself: local, and so unauthenticated, when that address is loopback. Configure the proxy to ' +
964
+ 'send X-Forwarded-For. A proxy on this host can instead use the tunnel port, which is open only when a ' +
965
+ 'tunnel is configured or TAPFLOW_TUNNEL_PORT names a port.');
966
+ this.warnedTrustedProxyWithoutXff = true;
967
+ }
836
968
  warnProxyMisconfigOnce(socketAddr, forwardedFor) {
837
969
  if (this.warnedProxyMisconfig)
838
970
  return;
@@ -842,12 +974,19 @@ export class RelayServer {
842
974
  if (a === '::1' || a.startsWith('127.')) {
843
975
  logger.warn('Received X-Forwarded-For from a loopback connection but TAPFLOW_TRUSTED_PROXIES is unset. ' +
844
976
  'If a same-host reverse proxy or `tailscale serve` forwards to this port, every client it forwards is ' +
845
- 'treated as localhost (unauthenticated). Point it at the tunnel port instead (TAPFLOW_TUNNEL_PORT, ' +
846
- 'default 4001), or set TAPFLOW_TRUSTED_PROXIES so the real client IP is used.');
977
+ 'treated as localhost (unauthenticated). Point it at the tunnel port instead (open only when a tunnel ' +
978
+ 'is configured or TAPFLOW_TUNNEL_PORT names a port), or set TAPFLOW_TRUSTED_PROXIES so the real ' +
979
+ 'client IP is used.');
847
980
  this.warnedProxyMisconfig = true;
848
981
  }
849
982
  }
850
983
  handleConnection(ws, request) {
984
+ // **First, before anything can return.** A frame that breaks the protocol makes `ws` emit `'error'` on
985
+ // this socket (a reset it handles itself), and an EventEmitter with no `'error'` listener throws: from
986
+ // inside `ws`'s receiver, with nothing above it to catch. One client could end the relay for everyone,
987
+ // signed in or not. `ws` closes the socket itself after emitting, so the `'close'` handler below still runs.
988
+ // Debug, not warn: any client can produce this at will, and the close is already logged where it matters.
989
+ ws.on('error', (err) => logger.debug(`WebSocket error: ${err.message}`));
851
990
  const socketAddr = this.remoteAddressOf(request);
852
991
  const xff = request.headers['x-forwarded-for'];
853
992
  const forwardedFor = Array.isArray(xff) ? xff[0] : xff;
@@ -855,6 +994,7 @@ export class RelayServer {
855
994
  // A proxy in front of the tunnel port adds the header too, and there it changes nothing about auth.
856
995
  if (!viaTunnel)
857
996
  this.warnProxyMisconfigOnce(socketAddr, forwardedFor);
997
+ this.warnTrustedProxyWithoutXffOnce(request);
858
998
  const { addr, isLocal } = resolveClientAddress({
859
999
  socketAddr,
860
1000
  forwardedFor,
@@ -1021,9 +1161,22 @@ export class RelayServer {
1021
1161
  logger.error(`route failed for ${inbound.msg.type}:`, e);
1022
1162
  }
1023
1163
  });
1024
- ws.on('close', () => {
1164
+ ws.on('close', (code, reason) => {
1165
+ // Not while stopping: the relay ending every socket itself says nothing about the agents.
1166
+ if (this.wsRoles.get(ws) === 'agent' && !this.stopping) {
1167
+ const name = this.agentNames.get(ws) ?? 'unknown';
1168
+ const explained = this.explainedCloses.get(ws);
1169
+ if (explained === 'replaced') {
1170
+ this.pushLog(`agent socket replaced: ${name} registered again on a new connection`);
1171
+ }
1172
+ else if (!explained) {
1173
+ const why = reason.length > 0 ? ` ${reason.toString()}` : '';
1174
+ this.pushLog(`agent socket closed: ${name} (code ${code}${why}, last pong ${this.pongAgeSeconds(ws)}s ago)`);
1175
+ }
1176
+ }
1025
1177
  this.wsRoles.delete(ws);
1026
1178
  this.wsExternal.delete(ws);
1179
+ this.forgetShutdownRequesters(ws);
1027
1180
  // Agent main socket disconnected → hold its sessions open for a moment in case the agent is
1028
1181
  // coming back (#426), rather than ending them where they stand.
1029
1182
  if (this.holdAgentSocket(ws))
@@ -1237,7 +1390,12 @@ export class RelayServer {
1237
1390
  // `session:leave-error` would grow the wire for a message no consumer reads — and `session:end` has
1238
1391
  // no in-repo sender at all, so it would be a reply to nobody.
1239
1392
  case 'session:end': {
1240
- if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
1393
+ const ended = this.sessions.get(msg.sessionId);
1394
+ if (this.ownsSession(ws, ended)) {
1395
+ // Silently: the holder ended it, and the shutdown may still complete on the device — saying it
1396
+ // failed would be a guess. The caller keeps its own deadline, as it did before #567.
1397
+ if (ended)
1398
+ this.settleShutdownRequesters(ended, null);
1241
1399
  this.sessions.remove(msg.sessionId);
1242
1400
  this.forgetSessionState(msg.sessionId);
1243
1401
  }
@@ -1321,6 +1479,10 @@ export class RelayServer {
1321
1479
  break;
1322
1480
  }
1323
1481
  case 'device:boot-error': {
1482
+ // A correlated reply answers a tracked boot, whatever else it does — so the entry goes
1483
+ // even when nobody is left to forward to. An id-less frame answers no request, so it
1484
+ // clears nothing.
1485
+ this.settleBoot(msg.sessionId, msg.requestId);
1324
1486
  const session = this.sessions.get(msg.sessionId);
1325
1487
  if (session?.browserSocket?.readyState === WebSocket.OPEN) {
1326
1488
  session.browserSocket.send(JSON.stringify(raw));
@@ -1336,6 +1498,19 @@ export class RelayServer {
1336
1498
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1337
1499
  session.browserSocket.send(JSON.stringify(raw));
1338
1500
  }
1501
+ this.answerShutdownRequester(session, msg.requestId, raw);
1502
+ break;
1503
+ }
1504
+ case 'device:shutdown-error': {
1505
+ // The agent's half of the pair (#455). Device status is left alone: the shutdown was attempted
1506
+ // and not confirmed, so the device may well still be running.
1507
+ const session = this.sessions.get(msg.sessionId);
1508
+ if (!session)
1509
+ break;
1510
+ if (session.browserSocket?.readyState === WebSocket.OPEN) {
1511
+ session.browserSocket.send(JSON.stringify(raw));
1512
+ }
1513
+ this.answerShutdownRequester(session, msg.requestId, raw);
1339
1514
  break;
1340
1515
  }
1341
1516
  case 'device:ready': {
@@ -1344,6 +1519,8 @@ export class RelayServer {
1344
1519
  // an agent that omits it resolves no session here, exactly as before.
1345
1520
  if (msg.sessionId === undefined)
1346
1521
  break;
1522
+ // Correlated only: a replay or an old agent's id-less ready answers no tracked boot.
1523
+ this.settleBoot(msg.sessionId, msg.requestId);
1347
1524
  const session = this.sessions.get(msg.sessionId);
1348
1525
  if (!session)
1349
1526
  break;
@@ -1397,7 +1574,7 @@ export class RelayServer {
1397
1574
  // These two shared one fall-through clause. Separated because the sharing is the trap for
1398
1575
  // whoever adds the door gate: a correlator check written into a shared body would gate
1399
1576
  // `device:shutdown` too, and the relay originates that message with no id — so the dashboard's
1400
- // four senders and the relay's own idle timer would stop reaching the agent, silently, in the
1577
+ // three id-less senders and the relay's own idle timer would stop reaching the agent, silently, in the
1401
1578
  // one direction no reply reports. That gate is a schema now and cannot be written into a case at
1402
1579
  // all, which is what makes the trap unreachable rather than merely avoided.
1403
1580
  case 'device:boot': {
@@ -1436,6 +1613,9 @@ export class RelayServer {
1436
1613
  // presence check the old line carried is gone because the schema requires the payload — and
1437
1614
  // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
1438
1615
  msg.payload['external'] = this.wsExternal.get(ws) ?? false;
1616
+ // Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
1617
+ // tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
1618
+ this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
1439
1619
  // The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
1440
1620
  boot.session.agentSocket.send(JSON.stringify(msg));
1441
1621
  break;
@@ -1478,6 +1658,7 @@ export class RelayServer {
1478
1658
  });
1479
1659
  break;
1480
1660
  }
1661
+ this.rememberShutdownRequester(ws, target.session.id, msg.requestId);
1481
1662
  target.session.agentSocket.send(JSON.stringify(msg));
1482
1663
  break;
1483
1664
  }
@@ -1643,6 +1824,58 @@ export class RelayServer {
1643
1824
  * Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
1644
1825
  * they keep their inbound type and there is nothing new to check.
1645
1826
  */
1827
+ rememberShutdownRequester(ws, sessionId, requestId) {
1828
+ // The idle timer's shutdown and the dashboard's teardown carry no id, and nobody waits on either.
1829
+ if (requestId === undefined)
1830
+ return;
1831
+ const key = shutdownRequesterKey(sessionId, requestId);
1832
+ const prior = this.shutdownRequesters.get(key);
1833
+ if (prior)
1834
+ clearTimeout(prior.timer);
1835
+ // Bounds an entry an older agent will never answer — it has no failure reply to send.
1836
+ const timer = setTimeout(() => this.shutdownRequesters.delete(key), SHUTDOWN_REQUESTER_TTL_MS);
1837
+ this.shutdownRequesters.set(key, { sessionId, requestId, ws, timer });
1838
+ }
1839
+ /** Hands the agent's answer to whoever asked for it, when that is not the holder — who already has it. */
1840
+ answerShutdownRequester(session, requestId, raw) {
1841
+ if (requestId === undefined)
1842
+ return;
1843
+ const key = shutdownRequesterKey(session.id, requestId);
1844
+ const entry = this.shutdownRequesters.get(key);
1845
+ if (!entry)
1846
+ return;
1847
+ clearTimeout(entry.timer);
1848
+ this.shutdownRequesters.delete(key);
1849
+ // Compared now rather than at dispatch: a caller that has joined since holds the session, and was sent
1850
+ // this a line ago.
1851
+ if (entry.ws === session.browserSocket)
1852
+ return;
1853
+ if (entry.ws.readyState === WebSocket.OPEN)
1854
+ entry.ws.send(JSON.stringify(raw));
1855
+ }
1856
+ /**
1857
+ * The session is being removed, so an answer from its agent — if one ever comes — is dropped at
1858
+ * `sessions.get`. `message` null settles in silence; otherwise a caller that never joined is told.
1859
+ */
1860
+ settleShutdownRequesters(session, message) {
1861
+ for (const [key, entry] of this.shutdownRequesters) {
1862
+ if (entry.sessionId !== session.id)
1863
+ continue;
1864
+ clearTimeout(entry.timer);
1865
+ this.shutdownRequesters.delete(key);
1866
+ if (message === null || entry.ws === session.browserSocket)
1867
+ continue;
1868
+ this.sendTo(entry.ws, { type: 'device:shutdown-error', sessionId: session.id, requestId: entry.requestId, message });
1869
+ }
1870
+ }
1871
+ forgetShutdownRequesters(ws) {
1872
+ for (const [key, entry] of this.shutdownRequesters) {
1873
+ if (entry.ws !== ws)
1874
+ continue;
1875
+ clearTimeout(entry.timer);
1876
+ this.shutdownRequesters.delete(key);
1877
+ }
1878
+ }
1646
1879
  sendTo(socket, msg) {
1647
1880
  if (socket.readyState !== WebSocket.OPEN)
1648
1881
  return;
@@ -1665,6 +1898,77 @@ export class RelayServer {
1665
1898
  }
1666
1899
  }
1667
1900
  }
1901
+ bootKey(sessionId, requestId) {
1902
+ return `${sessionId}${requestId}`;
1903
+ }
1904
+ /** Record a boot against the agent socket it was dispatched to. The door guarantees both ids. */
1905
+ rememberBoot(sessionId, requestId, agentSocket) {
1906
+ const key = this.bootKey(sessionId, requestId);
1907
+ // Replacing, not doubling: a client never reuses a correlator, but forgetting the old timer
1908
+ // here is what keeps a replaced entry from firing after its successor was settled.
1909
+ this.forgetBoot(key);
1910
+ const timer = setTimeout(() => this.forgetBoot(key), this.pendingBootTtlMs);
1911
+ this.pendingBoots.set(key, { sessionId, requestId, agentSocket, timer });
1912
+ }
1913
+ /** The one way an entry leaves: every removal path funnels through here so no timer survives it. */
1914
+ forgetBoot(key) {
1915
+ const pending = this.pendingBoots.get(key);
1916
+ if (!pending)
1917
+ return;
1918
+ clearTimeout(pending.timer);
1919
+ this.pendingBoots.delete(key);
1920
+ }
1921
+ /**
1922
+ * Drop the tracked boot a correlated reply answers. The key needs no live session: an agent
1923
+ * answering after its session was removed still answers that request. An absent `requestId`
1924
+ * is not the answer to anything, so it drops nothing.
1925
+ */
1926
+ settleBoot(sessionId, requestId) {
1927
+ if (typeof sessionId !== 'string' || typeof requestId !== 'string')
1928
+ return;
1929
+ this.forgetBoot(this.bootKey(sessionId, requestId));
1930
+ }
1931
+ /**
1932
+ * Answer the tracked boots tied to a replaced agent socket (#885). Called after the
1933
+ * `session:rebound` frames are sent, so the client records `needsReboot` first and the error
1934
+ * below is classified with the cause attached. Boots dispatched to any other socket — in
1935
+ * particular to the new agent after the rebind — are left pending.
1936
+ */
1937
+ invalidateBootsFor(oldSockets) {
1938
+ if (oldSockets.size === 0)
1939
+ return;
1940
+ for (const [key, pending] of this.pendingBoots.entries()) {
1941
+ if (!oldSockets.has(pending.agentSocket))
1942
+ continue;
1943
+ this.forgetBoot(key);
1944
+ const session = this.sessions.get(pending.sessionId);
1945
+ // `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
1946
+ // `browserSocket` at all — either way the entry is still gone, so nothing later answers
1947
+ // a request nobody holds.
1948
+ if (!session?.browserSocket)
1949
+ continue;
1950
+ this.sendTo(session.browserSocket, {
1951
+ type: 'device:boot-error',
1952
+ sessionId: pending.sessionId,
1953
+ requestId: pending.requestId,
1954
+ message: `session ${pending.sessionId} rebounded to a new agent while this boot was in flight, ` +
1955
+ 'and that agent never saw this request',
1956
+ });
1957
+ }
1958
+ }
1959
+ /**
1960
+ * Silently drop the tracked boots of sessions that no longer exist. Their callers were already
1961
+ * settled with the better diagnosis (`session:terminated`), so answering as well would send a
1962
+ * second failure for the same request. Scoped to the evicted sessions rather than the socket:
1963
+ * rebound sessions have already moved off it, and their entries are `invalidateBootsFor`'s to
1964
+ * answer, not this one's to drop.
1965
+ */
1966
+ forgetEvictedBoots(sessionIds) {
1967
+ for (const [key, pending] of this.pendingBoots.entries()) {
1968
+ if (sessionIds.has(pending.sessionId))
1969
+ this.forgetBoot(key);
1970
+ }
1971
+ }
1668
1972
  /**
1669
1973
  * The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
1670
1974
  * of ending them here — a restarting agent registers about a second later, and until #426 stage 3
@@ -1739,6 +2043,10 @@ export class RelayServer {
1739
2043
  if (agentSessions.length === 0)
1740
2044
  return false;
1741
2045
  this.rejectPending(new Set(agentSessions.map((s) => s.id)), 'Agent disconnected');
2046
+ // Their tracked boots go with them, silently: the `session:terminated` below settles those
2047
+ // callers with the better diagnosis. Rebound sessions are not in this set — they moved off
2048
+ // this socket — so their boots survive for `invalidateBootsFor` to answer.
2049
+ this.forgetEvictedBoots(new Set(agentSessions.map((s) => s.id)));
1742
2050
  // Tell whoever is attached before the session stops existing — after `remove()` the socket
1743
2051
  // reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
1744
2052
  // relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
@@ -1755,7 +2063,10 @@ export class RelayServer {
1755
2063
  });
1756
2064
  }
1757
2065
  }
2066
+ // After `session:terminated`, so a caller that holds the session is settled by that — it carries the
2067
+ // better diagnosis — and only a caller that never joined hears from `settleShutdownRequesters`.
1758
2068
  for (const s of agentSessions) {
2069
+ this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
1759
2070
  this.sessions.remove(s.id);
1760
2071
  this.forgetSessionState(s.id);
1761
2072
  }
@@ -1788,6 +2099,10 @@ export class RelayServer {
1788
2099
  // deduplicate — the second card would name a session the agent has never heard of, which is the
1789
2100
  // symptom this whole change is fixing.
1790
2101
  const rebound = new Map();
2102
+ // Sockets a rebind actually moved a session off of. Only boots tied to one of these are
2103
+ // stranded: a boot dispatched after the rebind below rides the new socket, and the eviction
2104
+ // further down must not sweep the entries this set protects (see `forgetEvictedBoots`).
2105
+ const reboundOldSockets = new Set();
1791
2106
  if (identity) {
1792
2107
  for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
1793
2108
  if (old === ws)
@@ -1807,16 +2122,19 @@ export class RelayServer {
1807
2122
  continue;
1808
2123
  this.sessions.rebind(s.id, ws, device, agent);
1809
2124
  rebound.set(s.deviceId, s.id);
2125
+ reboundOldSockets.add(old);
1810
2126
  }
1811
2127
  // Evict before terminate: the old socket's close fires async, by which point its sessions are
1812
2128
  // gone and its in-flight screenshots would be undiscoverable — reject them here instead.
1813
2129
  // The rebound sessions have already moved off `old`, so this no longer covers them.
1814
2130
  this.evictAgentSocket(old, 'replaced');
2131
+ this.explainedCloses.set(old, 'replaced');
1815
2132
  old.terminate();
1816
2133
  }
1817
2134
  }
1818
2135
  // Their in-flight requests are addressed to a process that is gone, and the eviction above can
1819
2136
  // no longer see them. Nothing else would ever settle these.
2137
+ // Shutdowns are deliberately not settled here — see `shutdownRequesters` for why they follow the session.
1820
2138
  if (rebound.size > 0)
1821
2139
  this.rejectPending(new Set(rebound.values()), 'Agent restarted');
1822
2140
  // A device can be back under an agent this session cannot be rebound to — identity is
@@ -1833,6 +2151,7 @@ export class RelayServer {
1833
2151
  if (s.browserSocket) {
1834
2152
  this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
1835
2153
  }
2154
+ this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
1836
2155
  this.sessions.remove(s.id);
1837
2156
  this.forgetSessionState(s.id);
1838
2157
  }
@@ -1855,6 +2174,11 @@ export class RelayServer {
1855
2174
  this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
1856
2175
  }
1857
2176
  }
2177
+ // After the rebound frames, so the client records `needsReboot` before the error below
2178
+ // arrives and classifies it with the cause attached. Only boots tied to the replaced
2179
+ // sockets: a boot sent after the rebind above was dispatched to the new agent, which boots
2180
+ // the device and answers — settling it here would fail a boot that succeeds (#885).
2181
+ this.invalidateBootsFor(reboundOldSockets);
1858
2182
  if (rebound.size > 0)
1859
2183
  logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
1860
2184
  // The startup banner prints "Waiting for agents..." once and then the relay says nothing either
@@ -1862,7 +2186,9 @@ export class RelayServer {
1862
2186
  // transition, matching the disconnect line in evictAgentSocket.
1863
2187
  // `||`, not `??`: the schema defaults both of these to `''` for an agent that omits them, so `??`
1864
2188
  // would print an empty name and an empty platform where this used to print `unknown`.
1865
- logger.info(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
2189
+ this.agentNames.set(ws, msg.agentName || msg.agentId || 'unknown');
2190
+ // Through `pushLog` too, so `tapflow logs` shows an agent coming back after the line about it leaving.
2191
+ this.pushLog(`agent connected: ${msg.agentName || msg.agentId || 'unknown'} (${msg.platform || 'unknown'}) — ${registeredSessions.length} device(s)`);
1866
2192
  }
1867
2193
  /** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
1868
2194
  *
@@ -1882,8 +2208,9 @@ export class RelayServer {
1882
2208
  // pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
1883
2209
  // It is a map read the relay has already done, so ordering it first costs nothing.
1884
2210
  //
1885
- // `!== ws` because a socket re-joining the session it already holds is not contending with anyone:
1886
- // `SessionList` sends `session:start` before a shutdown, so pressing shutdown twice hit this.
2211
+ // `owner !== ownerOf(ws)` because a client re-joining a session it already holds is not contending
2212
+ // with anyone: the viewer re-sends `session:start` each time its socket reconnects, and `mcp-server`
2213
+ // and `flow-runner` keep one `clientId` per process, so their explicit re-join is the same client.
1887
2214
  // The same three conditions `join()` applies, and they have to agree: this one answers the caller and
1888
2215
  // that one performs the bind, so a disagreement is a refusal for a session the bind would have allowed.
1889
2216
  if (session.browserSocket &&
@@ -2002,7 +2329,7 @@ export class RelayServer {
2002
2329
  // skipping safe is not the agent's silence but the viewer's: with `readySent` false the
2003
2330
  // `device:ready` above is not replayed either, so nothing is rendering a live device to be wrong
2004
2331
  // about. `deviceStatus` is not the fix — the comment on `device:ready` says why (#440).
2005
- if (session.agentCapabilities?.includes('network-control'))
2332
+ if (hasCapability(session.agentCapabilities, 'network-control'))
2006
2333
  this.networkStateRequester(session.id)();
2007
2334
  }
2008
2335
  }
@@ -2364,7 +2691,7 @@ export class RelayServer {
2364
2691
  * laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
2365
2692
  */
2366
2693
  warnLegacyInstaller(identity, capabilities) {
2367
- if (capabilities.includes('build-download') || this.warnedLegacyInstaller.has(identity))
2694
+ if (hasCapability(capabilities, 'build-download') || this.warnedLegacyInstaller.has(identity))
2368
2695
  return;
2369
2696
  this.warnedLegacyInstaller.add(identity);
2370
2697
  logger.warn(`[relay] agent ${identity} predates build downloading. If it is not on the same machine as ` +
@@ -2555,6 +2882,7 @@ export class RelayServer {
2555
2882
  this.resourceBuffers.clear();
2556
2883
  }
2557
2884
  async handleRequest(req, res) {
2885
+ this.warnTrustedProxyWithoutXffOnce(req);
2558
2886
  const corsHeaders = resolveCorsHeaders(req.headers.origin, this.corsAllowed);
2559
2887
  if (corsHeaders) {
2560
2888
  for (const [k, v] of Object.entries(corsHeaders))
@@ -2668,6 +2996,17 @@ export class RelayServer {
2668
2996
  serveStatic(req, res) {
2669
2997
  const urlPath = (req.url ?? '/').split('?')[0];
2670
2998
  let filePath = path.join(this.publicDir, urlPath === '/' ? '/index.html' : urlPath);
2999
+ // **Contained before anything is read or probed.** `req.url` is the raw request target and Node does
3000
+ // not normalise it, so `/../../etc/passwd` reached `path.join` intact and the join walked out of
3001
+ // `publicDir` — unauthenticated, and the relay's environment and database were one request away.
3002
+ // `serveUpload` has had this check since #173. Checked on the joined path because the index lookup and
3003
+ // the `.br`/`.gz` siblings below are all derived from it; the separator keeps a sibling directory
3004
+ // whose name starts with `publicDir`'s outside.
3005
+ if (!path.resolve(filePath).startsWith(path.resolve(this.publicDir) + path.sep)) {
3006
+ res.writeHead(404);
3007
+ res.end('Not found');
3008
+ return;
3009
+ }
2671
3010
  // Next.js static export: try exact path, then path/index.html (trailingSlash)
2672
3011
  if (!fs.existsSync(filePath)) {
2673
3012
  const withIndex = path.join(filePath, 'index.html');