@tapflowio/relay 0.26.1 → 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 (135) hide show
  1. package/dist/RelayServer.d.ts +72 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +254 -7
  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/lib/webhookTransport.d.ts +29 -0
  10. package/dist/lib/webhookTransport.d.ts.map +1 -0
  11. package/dist/lib/webhookTransport.js +86 -0
  12. package/dist/lib/webhookTransport.js.map +1 -0
  13. package/dist/lib/webhookUrl.d.ts +6 -0
  14. package/dist/lib/webhookUrl.d.ts.map +1 -1
  15. package/dist/lib/webhookUrl.js +50 -14
  16. package/dist/lib/webhookUrl.js.map +1 -1
  17. package/dist/lib/webhooks.d.ts +2 -10
  18. package/dist/lib/webhooks.d.ts.map +1 -1
  19. package/dist/lib/webhooks.js +8 -3
  20. package/dist/lib/webhooks.js.map +1 -1
  21. package/package.json +6 -6
  22. package/public/assets/{AppCenter-DZCsC2wa.js → AppCenter-BMdYtQbV.js} +7 -7
  23. package/public/assets/AppCenter-BMdYtQbV.js.br +0 -0
  24. package/public/assets/AppCenter-BMdYtQbV.js.gz +0 -0
  25. package/public/assets/Default-0DHHVGaN.js +1 -0
  26. package/public/assets/Default-0DHHVGaN.js.br +0 -0
  27. package/public/assets/Default-0DHHVGaN.js.gz +0 -0
  28. package/public/assets/Invite-C0ZB_HiU.js +1 -0
  29. package/public/assets/Invite-C0ZB_HiU.js.br +0 -0
  30. package/public/assets/Invite-C0ZB_HiU.js.gz +0 -0
  31. package/public/assets/{MacResources-B0DQ9ISE.js → MacResources-DmY5pRV2.js} +1 -1
  32. package/public/assets/MacResources-DmY5pRV2.js.br +0 -0
  33. package/public/assets/MacResources-DmY5pRV2.js.gz +0 -0
  34. package/public/assets/QASession-2QogWEX7.js +150 -0
  35. package/public/assets/QASession-2QogWEX7.js.br +0 -0
  36. package/public/assets/QASession-2QogWEX7.js.gz +0 -0
  37. package/public/assets/ResetPassword-jNRuertx.js +1 -0
  38. package/public/assets/ResetPassword-jNRuertx.js.br +0 -0
  39. package/public/assets/ResetPassword-jNRuertx.js.gz +0 -0
  40. package/public/assets/Setup-CF_FN9Dt.js +1 -0
  41. package/public/assets/Setup-CF_FN9Dt.js.br +0 -0
  42. package/public/assets/Setup-CF_FN9Dt.js.gz +0 -0
  43. package/public/assets/Team-BbiyOGau.js +6 -0
  44. package/public/assets/Team-BbiyOGau.js.br +0 -0
  45. package/public/assets/Team-BbiyOGau.js.gz +0 -0
  46. package/public/assets/Tokens-CZo4sPTx.js +1 -0
  47. package/public/assets/Tokens-CZo4sPTx.js.br +0 -0
  48. package/public/assets/Tokens-CZo4sPTx.js.gz +0 -0
  49. package/public/assets/alert-dialog-DFz8G_zu.js +1 -0
  50. package/public/assets/alert-dialog-DFz8G_zu.js.br +0 -0
  51. package/public/assets/alert-dialog-DFz8G_zu.js.gz +0 -0
  52. package/public/assets/badge-B3vaBVIQ.js +1 -0
  53. package/public/assets/badge-B3vaBVIQ.js.br +0 -0
  54. package/public/assets/badge-B3vaBVIQ.js.gz +0 -0
  55. package/public/assets/{build-format-fzt-JrUX.js → build-format-dbQPU3A4.js} +3 -3
  56. package/public/assets/build-format-dbQPU3A4.js.br +0 -0
  57. package/public/assets/build-format-dbQPU3A4.js.gz +0 -0
  58. package/public/assets/{dialog-DQkbaw8h.js → dialog-DeBPk-I3.js} +5 -5
  59. package/public/assets/dialog-DeBPk-I3.js.br +0 -0
  60. package/public/assets/dialog-DeBPk-I3.js.gz +0 -0
  61. package/public/assets/field-hint-CMKmbVpy.js +1 -0
  62. package/public/assets/field-hint-CMKmbVpy.js.br +0 -0
  63. package/public/assets/field-hint-CMKmbVpy.js.gz +0 -0
  64. package/public/assets/index-B4w4Lh3e.js +218 -0
  65. package/public/assets/index-B4w4Lh3e.js.br +0 -0
  66. package/public/assets/index-B4w4Lh3e.js.gz +0 -0
  67. package/public/assets/index-CDWu0GQL.css +1 -0
  68. package/public/assets/index-CDWu0GQL.css.br +0 -0
  69. package/public/assets/index-CDWu0GQL.css.gz +0 -0
  70. package/public/assets/list-view-BvINA5t4.js +1 -0
  71. package/public/assets/list-view-BvINA5t4.js.br +0 -0
  72. package/public/assets/list-view-BvINA5t4.js.gz +0 -0
  73. package/public/assets/{pencil-BJcOp3oN.js → pencil-CycAOoC_.js} +2 -2
  74. package/public/assets/pencil-CycAOoC_.js.br +0 -0
  75. package/public/assets/pencil-CycAOoC_.js.gz +0 -0
  76. package/public/assets/resource-health-C1qSG3MK.js +1 -0
  77. package/public/assets/resource-health-C1qSG3MK.js.br +0 -0
  78. package/public/assets/resource-health-C1qSG3MK.js.gz +0 -0
  79. package/public/assets/{trash-DhBxh6H7.js → trash-BXQnOI5U.js} +3 -3
  80. package/public/assets/trash-BXQnOI5U.js.br +0 -0
  81. package/public/assets/trash-BXQnOI5U.js.gz +0 -0
  82. package/public/index.html +2 -2
  83. package/public/index.html.br +0 -0
  84. package/public/index.html.gz +0 -0
  85. package/public/assets/AppCenter-DZCsC2wa.js.br +0 -0
  86. package/public/assets/AppCenter-DZCsC2wa.js.gz +0 -0
  87. package/public/assets/Default-xD9Fmd5X.js +0 -1
  88. package/public/assets/Default-xD9Fmd5X.js.br +0 -0
  89. package/public/assets/Default-xD9Fmd5X.js.gz +0 -0
  90. package/public/assets/Invite-29Erwv8j.js +0 -1
  91. package/public/assets/Invite-29Erwv8j.js.br +0 -0
  92. package/public/assets/Invite-29Erwv8j.js.gz +0 -0
  93. package/public/assets/MacResources-B0DQ9ISE.js.br +0 -0
  94. package/public/assets/MacResources-B0DQ9ISE.js.gz +0 -0
  95. package/public/assets/QASession-D-n6PXuD.js +0 -150
  96. package/public/assets/QASession-D-n6PXuD.js.br +0 -0
  97. package/public/assets/QASession-D-n6PXuD.js.gz +0 -0
  98. package/public/assets/ResetPassword-BnPIwTYH.js +0 -1
  99. package/public/assets/ResetPassword-BnPIwTYH.js.br +0 -0
  100. package/public/assets/ResetPassword-BnPIwTYH.js.gz +0 -0
  101. package/public/assets/Setup-DmUGFblg.js +0 -1
  102. package/public/assets/Setup-DmUGFblg.js.br +0 -0
  103. package/public/assets/Setup-DmUGFblg.js.gz +0 -0
  104. package/public/assets/Team-BpvRgFfZ.js +0 -6
  105. package/public/assets/Team-BpvRgFfZ.js.br +0 -0
  106. package/public/assets/Team-BpvRgFfZ.js.gz +0 -0
  107. package/public/assets/Tokens-x6BtCcL7.js +0 -1
  108. package/public/assets/Tokens-x6BtCcL7.js.br +0 -0
  109. package/public/assets/Tokens-x6BtCcL7.js.gz +0 -0
  110. package/public/assets/alert-dialog-C8wDimW-.js +0 -1
  111. package/public/assets/alert-dialog-C8wDimW-.js.br +0 -0
  112. package/public/assets/alert-dialog-C8wDimW-.js.gz +0 -0
  113. package/public/assets/badge-CWVbeuH3.js +0 -1
  114. package/public/assets/badge-CWVbeuH3.js.br +0 -0
  115. package/public/assets/badge-CWVbeuH3.js.gz +0 -0
  116. package/public/assets/build-format-fzt-JrUX.js.br +0 -0
  117. package/public/assets/build-format-fzt-JrUX.js.gz +0 -0
  118. package/public/assets/dialog-DQkbaw8h.js.br +0 -0
  119. package/public/assets/dialog-DQkbaw8h.js.gz +0 -0
  120. package/public/assets/index-C1Fo8g_c.js +0 -218
  121. package/public/assets/index-C1Fo8g_c.js.br +0 -0
  122. package/public/assets/index-C1Fo8g_c.js.gz +0 -0
  123. package/public/assets/index-C2F1AgwB.css +0 -1
  124. package/public/assets/index-C2F1AgwB.css.br +0 -0
  125. package/public/assets/index-C2F1AgwB.css.gz +0 -0
  126. package/public/assets/list-view-DxI397-H.js +0 -1
  127. package/public/assets/list-view-DxI397-H.js.br +0 -0
  128. package/public/assets/list-view-DxI397-H.js.gz +0 -0
  129. package/public/assets/pencil-BJcOp3oN.js.br +0 -0
  130. package/public/assets/pencil-BJcOp3oN.js.gz +0 -0
  131. package/public/assets/resource-health-Cq3gqqVd.js +0 -1
  132. package/public/assets/resource-health-Cq3gqqVd.js.br +0 -0
  133. package/public/assets/resource-health-Cq3gqqVd.js.gz +0 -0
  134. package/public/assets/trash-DhBxh6H7.js.br +0 -0
  135. package/public/assets/trash-DhBxh6H7.js.gz +0 -0
@@ -128,6 +128,9 @@ export declare class RelayServer {
128
128
  private readonly backpressureBytes;
129
129
  private readonly screenshotTimeoutMs;
130
130
  private readonly agentGraceMs;
131
+ /** How long a tracked boot lives without an answer. An option so tests can shrink the
132
+ * 180s horizon; production always takes the default. */
133
+ private readonly pendingBootTtlMs;
131
134
  private readonly corsAllowed;
132
135
  private warnedProxyMisconfig;
133
136
  /** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
@@ -136,6 +139,39 @@ export declare class RelayServer {
136
139
  private pendingScreenshots;
137
140
  private readonly uiTreeTimeoutMs;
138
141
  private pendingUITrees;
142
+ /**
143
+ * Who asked for each correlated `device:shutdown` still waiting on its agent (#567). The agent's answer
144
+ * goes to the session's `browserSocket`, and a caller that never joined — an MCP client shutting down a
145
+ * session nobody holds, which `mayShutDown` permits — has no socket there, so it waited out its 30s for a
146
+ * shutdown that had happened. Keyed by session **and** id, because the id is chosen by the client and
147
+ * would alone let one session's answer reach another session's caller.
148
+ *
149
+ * **Settled when the session is removed, never when the agent socket drops — the opposite of a boot.**
150
+ * Both agents reconnect without restarting, and a shutdown that finishes after the reconnect is answered
151
+ * on the new socket, so an agent going away or being rebound is not evidence that the answer is lost.
152
+ * For a boot it is (#885): the binding a boot creates is exactly what a rebind loses. Folding the two
153
+ * into one rule would tell a caller its shutdown failed for a device that then powered off.
154
+ */
155
+ private readonly shutdownRequesters;
156
+ /**
157
+ * In-flight `device:boot` requests, keyed by session and correlator (#885).
158
+ *
159
+ * The relay is the only layer that knows which agent socket each boot was actually dispatched
160
+ * to, so it owns settling the ones a rebind strands. The client cannot tell "issued before the
161
+ * rebound" from "delivered to the old agent", which is why the previous client-side settle on
162
+ * `session:rebound` answered boots the new agent was already handling. Entries record the
163
+ * concrete agent socket from the `device:boot` dispatch, so a rebind invalidates only the boots
164
+ * tied to the replaced socket and leaves a boot dispatched to the new socket alone.
165
+ *
166
+ * Cleared when a correlated `device:ready` or `device:boot-error` arrives, when the boot is
167
+ * invalidated at rebind, when its session is forgotten or evicted — and by its own expiry
168
+ * (`PENDING_BOOT_TTL_MS`) when none of those happen first, so a live agent that never answers
169
+ * cannot pin the entry past every caller that could have waited on it. An absent `requestId`
170
+ * answers no request (protocol/AGENTS.md 「Lifecycle correlation」), so id-less replies clear
171
+ * nothing. A leftover entry is harmless: its id names no waiter, so both clients and the
172
+ * dashboard drop the synthetic error it would produce.
173
+ */
174
+ private readonly pendingBoots;
139
175
  constructor(options: {
140
176
  port: number;
141
177
  publicDir?: string;
@@ -151,6 +187,7 @@ export declare class RelayServer {
151
187
  key: string;
152
188
  };
153
189
  agentGraceMs?: number;
190
+ pendingBootTtlMs?: number;
154
191
  tunnel?: TunnelRuntime;
155
192
  tunnelPort?: number;
156
193
  });
@@ -324,6 +361,15 @@ export declare class RelayServer {
324
361
  * Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
325
362
  * they keep their inbound type and there is nothing new to check.
326
363
  */
364
+ private rememberShutdownRequester;
365
+ /** Hands the agent's answer to whoever asked for it, when that is not the holder — who already has it. */
366
+ private answerShutdownRequester;
367
+ /**
368
+ * The session is being removed, so an answer from its agent — if one ever comes — is dropped at
369
+ * `sessions.get`. `message` null settles in silence; otherwise a caller that never joined is told.
370
+ */
371
+ private settleShutdownRequesters;
372
+ private forgetShutdownRequesters;
327
373
  private sendTo;
328
374
  /**
329
375
  * Settle every in-flight screenshot / UI-tree request belonging to these sessions. Both callers
@@ -332,6 +378,32 @@ export declare class RelayServer {
332
378
  * timeout, minutes later, having told the caller nothing in between.
333
379
  */
334
380
  private rejectPending;
381
+ private bootKey;
382
+ /** Record a boot against the agent socket it was dispatched to. The door guarantees both ids. */
383
+ private rememberBoot;
384
+ /** The one way an entry leaves: every removal path funnels through here so no timer survives it. */
385
+ private forgetBoot;
386
+ /**
387
+ * Drop the tracked boot a correlated reply answers. The key needs no live session: an agent
388
+ * answering after its session was removed still answers that request. An absent `requestId`
389
+ * is not the answer to anything, so it drops nothing.
390
+ */
391
+ private settleBoot;
392
+ /**
393
+ * Answer the tracked boots tied to a replaced agent socket (#885). Called after the
394
+ * `session:rebound` frames are sent, so the client records `needsReboot` first and the error
395
+ * below is classified with the cause attached. Boots dispatched to any other socket — in
396
+ * particular to the new agent after the rebind — are left pending.
397
+ */
398
+ private invalidateBootsFor;
399
+ /**
400
+ * Silently drop the tracked boots of sessions that no longer exist. Their callers were already
401
+ * settled with the better diagnosis (`session:terminated`), so answering as well would send a
402
+ * second failure for the same request. Scoped to the evicted sessions rather than the socket:
403
+ * rebound sessions have already moved off it, and their entries are `invalidateBootsFor`'s to
404
+ * answer, not this one's to drop.
405
+ */
406
+ private forgetEvictedBoots;
335
407
  /**
336
408
  * The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
337
409
  * of ending them here — a restarting agent registers about a second later, and until #426 stage 3
@@ -1 +1 @@
1
- {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
@@ -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)
@@ -757,6 +814,13 @@ export class RelayServer {
757
814
  this.idrRequesters.delete(sessionId);
758
815
  this.networkStateRequesters.get(sessionId)?.dispose();
759
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]));
760
824
  }
761
825
  /**
762
826
  * Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
@@ -1024,6 +1088,7 @@ export class RelayServer {
1024
1088
  ws.on('close', () => {
1025
1089
  this.wsRoles.delete(ws);
1026
1090
  this.wsExternal.delete(ws);
1091
+ this.forgetShutdownRequesters(ws);
1027
1092
  // Agent main socket disconnected → hold its sessions open for a moment in case the agent is
1028
1093
  // coming back (#426), rather than ending them where they stand.
1029
1094
  if (this.holdAgentSocket(ws))
@@ -1237,7 +1302,12 @@ export class RelayServer {
1237
1302
  // `session:leave-error` would grow the wire for a message no consumer reads — and `session:end` has
1238
1303
  // no in-repo sender at all, so it would be a reply to nobody.
1239
1304
  case 'session:end': {
1240
- 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);
1241
1311
  this.sessions.remove(msg.sessionId);
1242
1312
  this.forgetSessionState(msg.sessionId);
1243
1313
  }
@@ -1321,6 +1391,10 @@ export class RelayServer {
1321
1391
  break;
1322
1392
  }
1323
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);
1324
1398
  const session = this.sessions.get(msg.sessionId);
1325
1399
  if (session?.browserSocket?.readyState === WebSocket.OPEN) {
1326
1400
  session.browserSocket.send(JSON.stringify(raw));
@@ -1336,6 +1410,19 @@ export class RelayServer {
1336
1410
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1337
1411
  session.browserSocket.send(JSON.stringify(raw));
1338
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);
1339
1426
  break;
1340
1427
  }
1341
1428
  case 'device:ready': {
@@ -1344,6 +1431,8 @@ export class RelayServer {
1344
1431
  // an agent that omits it resolves no session here, exactly as before.
1345
1432
  if (msg.sessionId === undefined)
1346
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);
1347
1436
  const session = this.sessions.get(msg.sessionId);
1348
1437
  if (!session)
1349
1438
  break;
@@ -1397,7 +1486,7 @@ export class RelayServer {
1397
1486
  // These two shared one fall-through clause. Separated because the sharing is the trap for
1398
1487
  // whoever adds the door gate: a correlator check written into a shared body would gate
1399
1488
  // `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
1489
+ // three id-less senders and the relay's own idle timer would stop reaching the agent, silently, in the
1401
1490
  // one direction no reply reports. That gate is a schema now and cannot be written into a case at
1402
1491
  // all, which is what makes the trap unreachable rather than merely avoided.
1403
1492
  case 'device:boot': {
@@ -1436,6 +1525,9 @@ export class RelayServer {
1436
1525
  // presence check the old line carried is gone because the schema requires the payload — and
1437
1526
  // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
1438
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);
1439
1531
  // The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
1440
1532
  boot.session.agentSocket.send(JSON.stringify(msg));
1441
1533
  break;
@@ -1478,6 +1570,7 @@ export class RelayServer {
1478
1570
  });
1479
1571
  break;
1480
1572
  }
1573
+ this.rememberShutdownRequester(ws, target.session.id, msg.requestId);
1481
1574
  target.session.agentSocket.send(JSON.stringify(msg));
1482
1575
  break;
1483
1576
  }
@@ -1643,6 +1736,58 @@ export class RelayServer {
1643
1736
  * Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
1644
1737
  * they keep their inbound type and there is nothing new to check.
1645
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
+ }
1646
1791
  sendTo(socket, msg) {
1647
1792
  if (socket.readyState !== WebSocket.OPEN)
1648
1793
  return;
@@ -1665,6 +1810,77 @@ export class RelayServer {
1665
1810
  }
1666
1811
  }
1667
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
+ }
1668
1884
  /**
1669
1885
  * The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
1670
1886
  * of ending them here — a restarting agent registers about a second later, and until #426 stage 3
@@ -1739,6 +1955,10 @@ export class RelayServer {
1739
1955
  if (agentSessions.length === 0)
1740
1956
  return false;
1741
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)));
1742
1962
  // Tell whoever is attached before the session stops existing — after `remove()` the socket
1743
1963
  // reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
1744
1964
  // relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
@@ -1755,7 +1975,10 @@ export class RelayServer {
1755
1975
  });
1756
1976
  }
1757
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`.
1758
1980
  for (const s of agentSessions) {
1981
+ this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
1759
1982
  this.sessions.remove(s.id);
1760
1983
  this.forgetSessionState(s.id);
1761
1984
  }
@@ -1788,6 +2011,10 @@ export class RelayServer {
1788
2011
  // deduplicate — the second card would name a session the agent has never heard of, which is the
1789
2012
  // symptom this whole change is fixing.
1790
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();
1791
2018
  if (identity) {
1792
2019
  for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
1793
2020
  if (old === ws)
@@ -1807,6 +2034,7 @@ export class RelayServer {
1807
2034
  continue;
1808
2035
  this.sessions.rebind(s.id, ws, device, agent);
1809
2036
  rebound.set(s.deviceId, s.id);
2037
+ reboundOldSockets.add(old);
1810
2038
  }
1811
2039
  // Evict before terminate: the old socket's close fires async, by which point its sessions are
1812
2040
  // gone and its in-flight screenshots would be undiscoverable — reject them here instead.
@@ -1817,6 +2045,7 @@ export class RelayServer {
1817
2045
  }
1818
2046
  // Their in-flight requests are addressed to a process that is gone, and the eviction above can
1819
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.
1820
2049
  if (rebound.size > 0)
1821
2050
  this.rejectPending(new Set(rebound.values()), 'Agent restarted');
1822
2051
  // A device can be back under an agent this session cannot be rebound to — identity is
@@ -1833,6 +2062,7 @@ export class RelayServer {
1833
2062
  if (s.browserSocket) {
1834
2063
  this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
1835
2064
  }
2065
+ this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
1836
2066
  this.sessions.remove(s.id);
1837
2067
  this.forgetSessionState(s.id);
1838
2068
  }
@@ -1855,6 +2085,11 @@ export class RelayServer {
1855
2085
  this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
1856
2086
  }
1857
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);
1858
2093
  if (rebound.size > 0)
1859
2094
  logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
1860
2095
  // The startup banner prints "Waiting for agents..." once and then the relay says nothing either
@@ -1882,8 +2117,9 @@ export class RelayServer {
1882
2117
  // pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
1883
2118
  // It is a map read the relay has already done, so ordering it first costs nothing.
1884
2119
  //
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.
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.
1887
2123
  // The same three conditions `join()` applies, and they have to agree: this one answers the caller and
1888
2124
  // that one performs the bind, so a disagreement is a refusal for a session the bind would have allowed.
1889
2125
  if (session.browserSocket &&
@@ -2002,7 +2238,7 @@ export class RelayServer {
2002
2238
  // skipping safe is not the agent's silence but the viewer's: with `readySent` false the
2003
2239
  // `device:ready` above is not replayed either, so nothing is rendering a live device to be wrong
2004
2240
  // about. `deviceStatus` is not the fix — the comment on `device:ready` says why (#440).
2005
- if (session.agentCapabilities?.includes('network-control'))
2241
+ if (hasCapability(session.agentCapabilities, 'network-control'))
2006
2242
  this.networkStateRequester(session.id)();
2007
2243
  }
2008
2244
  }
@@ -2364,7 +2600,7 @@ export class RelayServer {
2364
2600
  * laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
2365
2601
  */
2366
2602
  warnLegacyInstaller(identity, capabilities) {
2367
- if (capabilities.includes('build-download') || this.warnedLegacyInstaller.has(identity))
2603
+ if (hasCapability(capabilities, 'build-download') || this.warnedLegacyInstaller.has(identity))
2368
2604
  return;
2369
2605
  this.warnedLegacyInstaller.add(identity);
2370
2606
  logger.warn(`[relay] agent ${identity} predates build downloading. If it is not on the same machine as ` +
@@ -2668,6 +2904,17 @@ export class RelayServer {
2668
2904
  serveStatic(req, res) {
2669
2905
  const urlPath = (req.url ?? '/').split('?')[0];
2670
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
+ }
2671
2918
  // Next.js static export: try exact path, then path/index.html (trailingSlash)
2672
2919
  if (!fs.existsSync(filePath)) {
2673
2920
  const withIndex = path.join(filePath, 'index.html');