@tapflowio/relay 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +1 -1
  2. package/dist/RelayServer.d.ts +313 -2
  3. package/dist/RelayServer.d.ts.map +1 -1
  4. package/dist/RelayServer.js +1245 -211
  5. package/dist/RelayServer.js.map +1 -1
  6. package/dist/SessionManager.d.ts +124 -21
  7. package/dist/SessionManager.d.ts.map +1 -1
  8. package/dist/SessionManager.js +201 -22
  9. package/dist/SessionManager.js.map +1 -1
  10. package/dist/index.d.ts +0 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/types.d.ts +3 -44
  13. package/dist/types.d.ts.map +1 -1
  14. package/dist/types.js +13 -0
  15. package/dist/types.js.map +1 -1
  16. package/package.json +10 -8
  17. package/public/assets/{AppCenter-BRmG2FEm.js → AppCenter-DyP0aqfF.js} +2 -2
  18. package/public/assets/AppCenter-DyP0aqfF.js.br +0 -0
  19. package/public/assets/Default-D7ScPE30.js +1 -0
  20. package/public/assets/Default-D7ScPE30.js.br +0 -0
  21. package/public/assets/Invite-UmhV5rAf.js +1 -0
  22. package/public/assets/Invite-UmhV5rAf.js.br +0 -0
  23. package/public/assets/MacResources-MS4E00w3.js +1 -0
  24. package/public/assets/MacResources-MS4E00w3.js.br +0 -0
  25. package/public/assets/NotFound-BoDDivK5.js +1 -0
  26. package/public/assets/NotFound-BoDDivK5.js.br +0 -0
  27. package/public/assets/QASession-CjgJHDZ3.js +125 -0
  28. package/public/assets/QASession-CjgJHDZ3.js.br +0 -0
  29. package/public/assets/ResetPassword-CAlB6CfP.js +1 -0
  30. package/public/assets/ResetPassword-CAlB6CfP.js.br +0 -0
  31. package/public/assets/Setup-CPm2-hPJ.js +1 -0
  32. package/public/assets/Setup-CPm2-hPJ.js.br +0 -0
  33. package/public/assets/{Team-D1iwg2vL.js → Team-GEIzrM1D.js} +2 -2
  34. package/public/assets/Team-GEIzrM1D.js.br +0 -0
  35. package/public/assets/Tokens-DSeBz_CJ.js +1 -0
  36. package/public/assets/Tokens-DSeBz_CJ.js.br +0 -0
  37. package/public/assets/alert-dialog-BsXI9SVV.js +1 -0
  38. package/public/assets/alert-dialog-BsXI9SVV.js.br +0 -0
  39. package/public/assets/badge-BkdYQFsh.js +1 -0
  40. package/public/assets/badge-BkdYQFsh.js.br +0 -0
  41. package/public/assets/{build-format-BZV6gt41.js → build-format-CXuQt1X2.js} +3 -3
  42. package/public/assets/build-format-CXuQt1X2.js.br +0 -0
  43. package/public/assets/dialog-DuXZpbiF.js +11 -0
  44. package/public/assets/dialog-DuXZpbiF.js.br +0 -0
  45. package/public/assets/index-Bk2hqaHZ.css +1 -0
  46. package/public/assets/index-Bk2hqaHZ.css.br +0 -0
  47. package/public/assets/index-bitr6DIC.js +215 -0
  48. package/public/assets/index-bitr6DIC.js.br +0 -0
  49. package/public/assets/{pencil-BGrOEXS6.js → pencil-iRR275s8.js} +1 -1
  50. package/public/assets/pencil-iRR275s8.js.br +0 -0
  51. package/public/assets/table-CHmhGpHG.js +1 -0
  52. package/public/assets/table-CHmhGpHG.js.br +0 -0
  53. package/public/assets/tabs-BnbeuAOe.js +1 -0
  54. package/public/assets/tabs-BnbeuAOe.js.br +0 -0
  55. package/public/assets/{trash-2-CwlFrbEF.js → trash-2-QI162w2K.js} +1 -1
  56. package/public/assets/trash-2-QI162w2K.js.br +0 -0
  57. package/public/assets/vendor-charts-DyX5CBcf.js +5 -0
  58. package/public/assets/vendor-charts-DyX5CBcf.js.br +0 -0
  59. package/public/assets/vendor-react-AJpf4YZ0.js +49 -0
  60. package/public/assets/vendor-react-AJpf4YZ0.js.br +0 -0
  61. package/public/assets/vendor-router-ChFy8CZ1.js +12 -0
  62. package/public/assets/vendor-router-ChFy8CZ1.js.br +0 -0
  63. package/public/index.html +4 -2
  64. package/public/index.html.br +0 -0
  65. package/public/assets/AppCenter-BRmG2FEm.js.br +0 -0
  66. package/public/assets/Default-DTr0wqds.js +0 -1
  67. package/public/assets/Default-DTr0wqds.js.br +0 -0
  68. package/public/assets/Invite-DKsNh2GB.js +0 -1
  69. package/public/assets/Invite-DKsNh2GB.js.br +0 -0
  70. package/public/assets/MacResources-CLDdcibL.js +0 -5
  71. package/public/assets/MacResources-CLDdcibL.js.br +0 -0
  72. package/public/assets/NotFound-BtKq2uv6.js +0 -1
  73. package/public/assets/NotFound-BtKq2uv6.js.br +0 -0
  74. package/public/assets/QASession-B_5aJ67l.js +0 -130
  75. package/public/assets/QASession-B_5aJ67l.js.br +0 -0
  76. package/public/assets/ResetPassword-gp6hVQrf.js +0 -1
  77. package/public/assets/ResetPassword-gp6hVQrf.js.br +0 -0
  78. package/public/assets/Setup-DiG4Nbs7.js +0 -1
  79. package/public/assets/Setup-DiG4Nbs7.js.br +0 -0
  80. package/public/assets/Team-D1iwg2vL.js.br +0 -0
  81. package/public/assets/Tokens-DGtE4Xcy.js +0 -1
  82. package/public/assets/Tokens-DGtE4Xcy.js.br +0 -0
  83. package/public/assets/alert-dialog-CXwkFlF3.js +0 -1
  84. package/public/assets/alert-dialog-CXwkFlF3.js.br +0 -0
  85. package/public/assets/badge-CavXTq2x.js +0 -1
  86. package/public/assets/badge-CavXTq2x.js.br +0 -0
  87. package/public/assets/build-format-BZV6gt41.js.br +0 -0
  88. package/public/assets/dialog-JbKwqqS0.js +0 -11
  89. package/public/assets/dialog-JbKwqqS0.js.br +0 -0
  90. package/public/assets/index-CBnxiD7-.js +0 -274
  91. package/public/assets/index-CBnxiD7-.js.br +0 -0
  92. package/public/assets/index-Ctx3_cYz.css +0 -1
  93. package/public/assets/index-Ctx3_cYz.css.br +0 -0
  94. package/public/assets/pencil-BGrOEXS6.js.br +0 -0
  95. package/public/assets/table-CqOA0Bb1.js +0 -1
  96. package/public/assets/table-CqOA0Bb1.js.br +0 -0
  97. package/public/assets/tabs-BVBbhCFG.js +0 -1
  98. package/public/assets/tabs-BVBbhCFG.js.br +0 -0
  99. package/public/assets/trash-2-CwlFrbEF.js.br +0 -0
package/README.md CHANGED
@@ -6,7 +6,7 @@ Handles NAT traversal between browser clients and device agents, session routing
6
6
 
7
7
  ## Requirements
8
8
 
9
- - Node.js ≥ 20
9
+ - Node.js ≥ 22
10
10
 
11
11
  ## Usage
12
12
 
@@ -1,4 +1,27 @@
1
1
  export declare function isExternalAddress(addr: string): boolean;
2
+ /** Exported for the re-join test, which pins both edges of this window rather than hardcoding 500 —
3
+ * a test carrying its own copy of the number passes when the constant moves, which is the drift the
4
+ * wire-contract work keeps finding. Not re-exported from the package index. */
5
+ export declare const IDR_REQUEST_THROTTLE_MS = 500;
6
+ /**
7
+ * The owner key a connection speaks for, from the authenticated user and the client id it claimed.
8
+ *
9
+ * **Both halves, and a pure function so both can be tested.** The user id is what keeps a leaked client id
10
+ * useless to anyone else — without it this would be the relay's first ownership claim a caller can forge,
11
+ * where socket identity could not be. It comes from the cookie *or* the PAT: reading only the cookie would
12
+ * have made every agent and every non-browser client `anon:`, which is the population most likely to have
13
+ * its handshake URL end up in a reverse proxy's access log. The mint is what removes the fallback branch: a connection that
14
+ * claims nothing gets an identity of its own, which is per-socket, which is the behaviour ownership had
15
+ * before it moved off the socket.
16
+ *
17
+ * Exported for the test that holds the pairing. Inline in the handler it was unreachable without standing
18
+ * up an authenticated handshake, and a mutation dropping the user id survived the whole suite.
19
+ */
20
+ export declare function ownerKeyFor(userId: number | string | undefined, claimed: string | null): {
21
+ key: string;
22
+ user: string;
23
+ minted: boolean;
24
+ };
2
25
  export declare class RelayServer {
3
26
  private readonly options;
4
27
  private httpServer;
@@ -15,15 +38,50 @@ export declare class RelayServer {
15
38
  private purgeBuildsTimer;
16
39
  private flushResourcesTimer;
17
40
  private heartbeatTimer;
18
- private wsAlive;
41
+ /**
42
+ * When each socket last answered a ping. WeakMap → no manual cleanup on close (GC handles it).
43
+ *
44
+ * **A timestamp, not the swept boolean it replaced.** That flag was set `false` for *every* socket at the
45
+ * start of each sweep and back to `true` only when the pong returned, so a perfectly healthy socket read
46
+ * as dead for the whole round trip — a normal state on a 30s cycle rather than an edge case. And the
47
+ * window is longest for exactly the socket that matters: video goes out on `browserSocket`, and the ping
48
+ * is written behind whatever is already queued there, so an external viewer's pong can take a second or
49
+ * two.
50
+ *
51
+ * That was invisible while the flag only decided termination, because a sweep that finds `false` has by
52
+ * definition already waited a full interval. Reading it to answer *"is this holder alive right now"* is
53
+ * what exposes it: occupancy would hand a live tester's session away, and `busy` would flash a device as
54
+ * free every 30 seconds — the dashboard polls every 5s and `disabled={isBusy}`, so the UI would open the
55
+ * button rather than a race being needed.
56
+ */
57
+ private lastPongAt;
58
+ /**
59
+ * Who each socket speaks for — `<userId>:<clientId>`, the key a session's ownership is compared against.
60
+ *
61
+ * **Two parts, and both are load-bearing.** The client id is supplied by the caller (`?client=`), so on
62
+ * its own it is the relay's first *forgeable* ownership claim: today's ownership is socket identity,
63
+ * which nothing can spoof. Binding it to the authenticated user — which `getAuth` already resolves at
64
+ * the handshake — keeps a leaked id useless to anyone else's account.
65
+ *
66
+ * **A connection that supplies no client id is given one**, rather than falling back to socket identity.
67
+ * There is deliberately no fallback branch: one model, and a client that does not identify itself gets an
68
+ * identity that happens to be per-socket, which is exactly today's behaviour.
69
+ */
70
+ private ownerKey;
19
71
  private dropHandlers;
20
72
  private audioDropHandlers;
21
73
  private droppers;
22
74
  private idrRequesters;
23
75
  private wsRoles;
76
+ /** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
77
+ * with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
78
+ private readonly rejectionLog;
79
+ private agentHolds;
80
+ private stopping;
24
81
  private wsExternal;
25
82
  private readonly backpressureBytes;
26
83
  private readonly screenshotTimeoutMs;
84
+ private readonly agentGraceMs;
27
85
  private readonly corsAllowed;
28
86
  private warnedProxyMisconfig;
29
87
  private pendingScreenshots;
@@ -43,6 +101,7 @@ export declare class RelayServer {
43
101
  cert: string;
44
102
  key: string;
45
103
  };
104
+ agentGraceMs?: number;
46
105
  });
47
106
  private registerRoutes;
48
107
  pushLog(msg: string): void;
@@ -50,20 +109,272 @@ export declare class RelayServer {
50
109
  stop(): Promise<void>;
51
110
  address(): string | import("net").AddressInfo | null;
52
111
  private runHeartbeat;
112
+ /**
113
+ * Has this socket answered recently enough to be treated as present?
114
+ *
115
+ * `1.5` intervals rather than one: a pong that arrives *during* a sweep must not be read as late, and a
116
+ * socket pinged at the end of one interval has until halfway through the next. The cost is that a
117
+ * genuinely dead socket is recognised in up to 45s instead of 30 — **and 45 seconds with no false
118
+ * negative beats 30 with one every cycle**, because a false negative here does not merely delay: it
119
+ * hands a live tester's session to whoever asked next.
120
+ */
121
+ private isAlive;
53
122
  updateTlsContext(material: {
54
123
  cert: string;
55
124
  key: string;
56
125
  }): void;
57
- private makeIdrRequester;
126
+ /**
127
+ * Drop every per-session stream record keyed by this id.
128
+ *
129
+ * **Four maps that have to move together, called from four places that did not.** They were deleted
130
+ * inline in `session:end` and `session:leave` and nowhere else, so a session ending any other way —
131
+ * the agent going away, the idle timer, a device disappearing from a re-register — left all four
132
+ * behind for the life of the process.
133
+ *
134
+ * Pre-existing, and this slice is what makes it worth fixing here rather than filing: `idrRequesters`
135
+ * used to be populated only by the binary drop path, i.e. only under backpressure, and a re-join now
136
+ * creates an entry for any streaming session (#515). The leak went from rare to ordinary.
137
+ */
138
+ private forgetSessionStreamState;
139
+ /**
140
+ * Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
141
+ * agents that don't support it.
142
+ *
143
+ * **One per session, and the construction is folded in here on purpose.** The throttle is `lastAt` in
144
+ * the closure, so a second requester for the same session is a second budget — which is what the
145
+ * `idrRequesters` map exists to prevent, and it prevented it only as long as every caller remembered
146
+ * to look the session up first. There are two callers now (the drop path and the re-join in
147
+ * `handleSessionStart`), and a separate `makeIdrRequester` was a factory either of them could reach
148
+ * past the map. Measured: calling it directly from one caller left every test green.
149
+ */
150
+ private idrRequester;
58
151
  private remoteAddressOf;
59
152
  private warnProxyMisconfigOnce;
60
153
  private handleConnection;
154
+ /**
155
+ * @param msg what the door proved — see `parseInbound`. An Envelope-tier member arrives carrying
156
+ * `type` and its correlators and nothing else, so a payload the parser did not check
157
+ * cannot be read off it.
158
+ * @param raw the frame as it arrived. Agent-origin messages are **forwarded as this**, so a field
159
+ * a newer agent added survives a relay that does not know it — `z.object` strips, and
160
+ * stripping is wrong in the one direction where the sender is the more recently
161
+ * updated side. Browser-origin messages are forwarded as `msg` instead, so a key an attacker
162
+ * appended does not survive. It is also where the two stored Envelope payloads come
163
+ * from, which is the point: a value read off `raw` is one the parser did not vouch for.
164
+ */
165
+ /**
166
+ * Assign this socket's role if it does not have one, then refuse a message its role may not send.
167
+ *
168
+ * @returns `false` when the caller must stop — the socket was closed, or the frame is a failed
169
+ * handshake that must not confer a role.
170
+ */
171
+ private settleRole;
172
+ /**
173
+ * What the door refused, said once and in a form an operator can act on.
174
+ *
175
+ * This is where `isAddressed` and `isCorrelated` ended up. Both were predicates whose whole
176
+ * observable output was a `console.warn` — an id-less request resolved no session and was dropped by
177
+ * the miss anyway — and the schemas now reject the same frames earlier, including the empty-string
178
+ * case a bare `z.string()` would have let through. What is new is that the log names the *field*:
179
+ * "requestId: Too small" instead of "dropped, cannot correlate a reply".
180
+ *
181
+ * Worth logging at all for the reason `isCorrelated` gave: the three places a bad frame can be
182
+ * dropped are otherwise silent, and an operator who upgrades the relay but not an independently
183
+ * installed `mcp-server` would watch commands do nothing with no trace.
184
+ *
185
+ * **Throttled per socket, and the "per socket" is the whole design.** A first draft wrote one line
186
+ * per rejected frame, on the direction a viewer with devtools controls and at the rate a gesture
187
+ * produces — the unbounded, attacker-driven log volume this file already refuses at
188
+ * `forwardUnacked`, with the reason written beside it. A single module-level timestamp would fix the
189
+ * volume and break the diagnostic: one noisy socket would silence the *other* socket's first bad
190
+ * frame, which is the skewed-client case the log exists for. So the state is keyed by socket, in a
191
+ * `WeakMap` that needs no cleanup, and **the first rejection from any socket is always written** —
192
+ * the one that names the skew is never the hundredth. What is dropped is only repetition, and the
193
+ * next line says how much.
194
+ *
195
+ * `unknown-type` stays at debug: eleven relay-produced literals land here whenever a client echoes
196
+ * one back, and none of them is a defect. It shares the throttle so turning debug on cannot
197
+ * reintroduce the volume.
198
+ */
199
+ private logInboundRejection;
61
200
  private route;
62
201
  private handleAgentResources;
202
+ /**
203
+ * Every message the relay *originates* goes through here. The union checks the shape where the
204
+ * literal is written — a mistyped `type`, a missing field or one that does not belong are all
205
+ * compile errors — and the readyState guard replaces the check that was repeated (and sometimes
206
+ * omitted) at each call site.
207
+ *
208
+ * Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
209
+ * they keep their inbound type and there is nothing new to check.
210
+ */
211
+ private sendTo;
212
+ /**
213
+ * Settle every in-flight screenshot / UI-tree request belonging to these sessions. Both callers
214
+ * are moments where the agent that was going to answer them no longer exists — it disconnected,
215
+ * or it restarted and its session moved to the new process. Left alone these reject on their own
216
+ * timeout, minutes later, having told the caller nothing in between.
217
+ */
218
+ private rejectPending;
219
+ /**
220
+ * The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
221
+ * of ending them here — a restarting agent registers about a second later, and until #426 stage 3
222
+ * the close always won that race, so a restart cost the tester their place.
223
+ *
224
+ * The sessions stay exactly where they are. They are NOT moved to a holding structure of their
225
+ * own: `getAgentSocketsByIdentity` finds the returning agent's previous socket by walking
226
+ * `sessions` and reading `agentSocket`, so a session parked anywhere else is a session that can
227
+ * never be reclaimed.
228
+ *
229
+ * @returns whether this was an agent socket, matching `evictAgentSocket`'s contract — the close
230
+ * handler uses it to decide whether to keep looking.
231
+ */
232
+ private holdAgentSocket;
233
+ /** Stop waiting on a socket, whether or not anything was reclaimed from it. */
234
+ private releaseHold;
235
+ /**
236
+ * @param cause `'disconnect'` — the socket closed. `'replaced'` — the same agent re-registered
237
+ * and this is its previous socket. Only affects the log line: a restart otherwise reads as a
238
+ * crash followed by a recovery, which is not what happened.
239
+ */
63
240
  private evictAgentSocket;
64
241
  private handleAgentRegister;
242
+ /** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
243
+ *
244
+ * That is what makes the address possible rather than aspirational: `msg.sessionId` is narrowed to a
245
+ * non-empty `string` by the door — the inbound schema, `isAddressed` before it — so every refusal can
246
+ * name the join it refuses. Before
247
+ * L5d they carried none, and the clients' join waiters matched `sessionId === undefined || sessionId ===
248
+ * mine` — with no such key the left half was always true, so any refusal resolved any pending join. */
65
249
  private handleSessionStart;
250
+ /** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
251
+ * of the close handler so the release loop there reads as one line per session. */
252
+ private idleShutdown;
253
+ /** Where a browser-role command can be dispatched, or what its sender needs to be told instead.
254
+ *
255
+ * Three refusals used to be spread across seven `case` bodies as two conditions each, and the ownership
256
+ * one was in none of them. Collapsed here so the set is decided once: a command reaches an agent only if
257
+ * the session exists, **this socket holds it**, and the agent is connected. Anything else has prose the
258
+ * caller can act on, and each case wraps that prose in the reply type its own waiter reads.
259
+ *
260
+ * The two ownership strings are one reason with different prose, the treatment `#492` settled: telling a
261
+ * caller the session is in use when it is idle steers it off a device it could have had.
262
+ */
263
+ private dispatchTarget;
264
+ /** `dispatchTarget` without the ownership clause: is there a session here, and is its agent listening?
265
+ *
266
+ * **The name carries the omission because a doc block does not reach an autocomplete.** This is the
267
+ * resolver a future handler must not pick by accident — it is the general-looking one and the unsafe
268
+ * one at the same time, which is the pairing worth spelling out.
269
+ *
270
+ * **Still here after #527, and this paragraph used to say it would not be.** It predicted that
271
+ * answering "who may shut a device down" would move `device:shutdown` up to `dispatchTarget`. The
272
+ * answer turned out to be a *different* gate rather than that one: `mayShutDown` refuses a session
273
+ * another client holds and permits an unheld one, because the dashboard's unmount teardown races its
274
+ * own viewer socket's close and an owns-it gate refuses the teardown whenever the close lands first —
275
+ * leaving the device booted until the idle timer, which is the cost #527 was filed to avoid.
276
+ *
277
+ * So the two are ordered rather than merged: `mayShutDown` decides *who*, this decides *whether it can
278
+ * be delivered at all*, and without the second a shutdown addressed to a missing session or a closed
279
+ * agent socket is dropped in silence and `mcp-server`'s caller burns a 30s deadline (#542).
280
+ *
281
+ * The disclosure it was written to record stands: a caller that is not the holder still learns whether
282
+ * a session id exists and whether its agent is up.
283
+ */
284
+ private reachableTargetWithoutOwnership;
285
+ /** Is `ws` the socket this session is bound to?
286
+ *
287
+ * Until L5c the input path resolved the session and forwarded without asking. `clipboard:data` asks the
288
+ * mirror-image question one branch up (`session?.agentSocket !== ws`) with the reason written beside it —
289
+ * a second agent on the same relay must not address someone else's session — and the browser direction
290
+ * had no equivalent. So any authenticated client that knew a session id could drive a device another
291
+ * tester was looking at, and the agent's ack went to the session holder rather than to whoever asked.
292
+ *
293
+ * An **unheld** session (no `browserSocket`, or one that has gone) is also not owned by the sender. That
294
+ * is deliberate rather than incidental: it makes an input arriving inside the reconnect grace fail fast
295
+ * instead of being applied to a device with nobody watching the result. */
296
+ private ownsSession;
297
+ /** Which owner this socket speaks for. Every connection has one — supplied or minted — so this never
298
+ * answers `undefined` and two anonymous sockets can never match by both lacking an identity. */
299
+ private ownerOf;
300
+ /** The principal behind a socket — a user id, or `anon` for a connection carrying no cookie and no PAT. */
301
+ private userOf;
302
+ /** Everything the bind needs to record about who is joining.
303
+ *
304
+ * **A socket with no record gets one of its own rather than a shared sentinel.** `handleConnection`
305
+ * seeds every accepted connection before a message can arrive, so the fallback is unreachable today —
306
+ * but a literal would make any two unseeded sockets *equal*, and equal keys pass `ownsSession` while
307
+ * equal users pass `mayShutDown`'s same-principal exemption. That rests the whole ownership invariant
308
+ * on one call site staying total, which is the shape of dependency the sentinel is supposed to remove.
309
+ * Minting keeps the answer "this socket and no other"; storing it keeps repeat calls consistent, since
310
+ * a fresh value each time would fail to recognise the very socket that bound the session. `minted` is
311
+ * `false` deliberately — the legacy exemption exists for a client that identified itself with nothing,
312
+ * not for one the relay failed to record. */
313
+ private ownerRecord;
314
+ /**
315
+ * May this socket shut the session's device down?
316
+ *
317
+ * **"Not someone else's" rather than "mine", and the difference is the reason #527 stayed open.** The
318
+ * dashboard's unmount teardown sends `device:shutdown` from one socket while another socket's `close`
319
+ * races it to the relay — two connections, no ordering — and if the close lands first the session is
320
+ * unheld. An owns-it gate refuses the teardown there, and the device stays booted until the five-minute
321
+ * idle timer: the path #527 named as the reason a gate could not be added at all.
322
+ *
323
+ * An unheld session therefore stays shutdownable by anyone authenticated, which is unchanged from today
324
+ * — this command has never had a gate — and is written down rather than implied. What changes is the
325
+ * case the issue is about: a device another tester is *holding* can no longer be powered off.
326
+ */
327
+ private mayShutDown;
328
+ /** Input that no ack answers: opening and move frames, rotation, the keyboard-forwarding toggle.
329
+ *
330
+ * Dropped rather than refused when unowned, and that asymmetry with `handleAckedInput` is the whole
331
+ * reason these have their own clause: refusing means answering, and there is no waiter here to answer.
332
+ * `clipboard:data`'s silent `break` is the precedent that fits — a frame nobody is waiting on. */
333
+ private forwardUnacked;
334
+ /** The five inputs an ack answers, so the five that carry a required correlator.
335
+ *
336
+ * Every exit answers the sender. That is the rule this method exists to hold: a request with a waiter on
337
+ * a 2s deadline must not be dropped silently, because the caller's fallback reports silence from a
338
+ * session that has never acked as **success** (#457) — so a silent drop here would report an input that
339
+ * never left the relay as landed, which is worse than the misrouting it replaced. */
340
+ private handleAckedInput;
341
+ /**
342
+ * Tells the sender its payload was refused, in the shape that request's own waiter reads.
343
+ *
344
+ * **This is what keeps the door from turning an answered failure into silence.** Before it, a
345
+ * malformed `open-url` reached the agent and the agent's own guard answered `open-url:error`;
346
+ * `IOSAgent.ts` says so beside that guard, and names this validation as what would take it over.
347
+ * Taking the responsibility without taking the answer would have been a regression — worst on the
348
+ * inputs, and not obviously: `awaitInputAck` reports silence from a session that has never acked as
349
+ * **success** (#457), so a dropped `input:key` reads to an MCP caller as an input that landed.
350
+ *
351
+ * The address and the correlator come from the envelope, which `parseInbound` judges separately from
352
+ * the payload for exactly this reason — a frame with a good envelope carries everything a reply
353
+ * needs. `reason: 'malformed'` is not a new member; the input vocabulary already had it, and until
354
+ * now only agents produced it.
355
+ *
356
+ * No ownership check, deliberately: the reply goes to the socket that sent the frame and says only
357
+ * that its own message was malformed, so it discloses nothing about the session. Every other refusal
358
+ * in this file answers a question about the session's state and is gated for that reason.
359
+ */
360
+ private refuseMalformed;
361
+ /** Answers the sender in the shape its waiter is keyed on.
362
+ *
363
+ * `input:type` needs `input:type-error`, not `input:error` — its waiters in `mcp-server` and
364
+ * `flow-runner` match on the `input:type-*` pair and ignore an `input:error` entirely. That is why
365
+ * adding `input:type` to the terminal set was never the fix for it, and answering it properly here is
366
+ * closes the gap `relay/AGENTS.md` recorded: before L5c an `input:type` on an offline agent got nothing
367
+ * and burned its caller's full deadline. */
368
+ private refuseInput;
369
+ /** Relay looks up file_path from DB and enriches it for the agent.
370
+ *
371
+ * Every exit carries the request's `sessionId`, including the failures. A dashboard viewer holds
372
+ * one session per socket, so an uncorrelated error still lands somewhere sensible — but an MCP
373
+ * caller waits for the reply matching its own sessionId, so anything else is indistinguishable
374
+ * from silence and it waits out the deadline (#445). `Session not found` is app-specific for the
375
+ * same reason: a generic `error` cannot be correlated by construction. */
66
376
  private handleBrowserAppInstall;
377
+ /** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
67
378
  private handleBrowserAppLaunch;
68
379
  private handleGetScreenshot;
69
380
  private handleGetUITree;
@@ -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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"}