@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.
- package/dist/RelayServer.d.ts +72 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +254 -7
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +5 -3
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +3 -1
- package/dist/SessionManager.js.map +1 -1
- package/dist/lib/webhookTransport.d.ts +29 -0
- package/dist/lib/webhookTransport.d.ts.map +1 -0
- package/dist/lib/webhookTransport.js +86 -0
- package/dist/lib/webhookTransport.js.map +1 -0
- package/dist/lib/webhookUrl.d.ts +6 -0
- package/dist/lib/webhookUrl.d.ts.map +1 -1
- package/dist/lib/webhookUrl.js +50 -14
- package/dist/lib/webhookUrl.js.map +1 -1
- package/dist/lib/webhooks.d.ts +2 -10
- package/dist/lib/webhooks.d.ts.map +1 -1
- package/dist/lib/webhooks.js +8 -3
- package/dist/lib/webhooks.js.map +1 -1
- package/package.json +6 -6
- package/public/assets/{AppCenter-DZCsC2wa.js → AppCenter-BMdYtQbV.js} +7 -7
- package/public/assets/AppCenter-BMdYtQbV.js.br +0 -0
- package/public/assets/AppCenter-BMdYtQbV.js.gz +0 -0
- package/public/assets/Default-0DHHVGaN.js +1 -0
- package/public/assets/Default-0DHHVGaN.js.br +0 -0
- package/public/assets/Default-0DHHVGaN.js.gz +0 -0
- package/public/assets/Invite-C0ZB_HiU.js +1 -0
- package/public/assets/Invite-C0ZB_HiU.js.br +0 -0
- package/public/assets/Invite-C0ZB_HiU.js.gz +0 -0
- package/public/assets/{MacResources-B0DQ9ISE.js → MacResources-DmY5pRV2.js} +1 -1
- package/public/assets/MacResources-DmY5pRV2.js.br +0 -0
- package/public/assets/MacResources-DmY5pRV2.js.gz +0 -0
- package/public/assets/QASession-2QogWEX7.js +150 -0
- package/public/assets/QASession-2QogWEX7.js.br +0 -0
- package/public/assets/QASession-2QogWEX7.js.gz +0 -0
- package/public/assets/ResetPassword-jNRuertx.js +1 -0
- package/public/assets/ResetPassword-jNRuertx.js.br +0 -0
- package/public/assets/ResetPassword-jNRuertx.js.gz +0 -0
- package/public/assets/Setup-CF_FN9Dt.js +1 -0
- package/public/assets/Setup-CF_FN9Dt.js.br +0 -0
- package/public/assets/Setup-CF_FN9Dt.js.gz +0 -0
- package/public/assets/Team-BbiyOGau.js +6 -0
- package/public/assets/Team-BbiyOGau.js.br +0 -0
- package/public/assets/Team-BbiyOGau.js.gz +0 -0
- package/public/assets/Tokens-CZo4sPTx.js +1 -0
- package/public/assets/Tokens-CZo4sPTx.js.br +0 -0
- package/public/assets/Tokens-CZo4sPTx.js.gz +0 -0
- package/public/assets/alert-dialog-DFz8G_zu.js +1 -0
- package/public/assets/alert-dialog-DFz8G_zu.js.br +0 -0
- package/public/assets/alert-dialog-DFz8G_zu.js.gz +0 -0
- package/public/assets/badge-B3vaBVIQ.js +1 -0
- package/public/assets/badge-B3vaBVIQ.js.br +0 -0
- package/public/assets/badge-B3vaBVIQ.js.gz +0 -0
- package/public/assets/{build-format-fzt-JrUX.js → build-format-dbQPU3A4.js} +3 -3
- package/public/assets/build-format-dbQPU3A4.js.br +0 -0
- package/public/assets/build-format-dbQPU3A4.js.gz +0 -0
- package/public/assets/{dialog-DQkbaw8h.js → dialog-DeBPk-I3.js} +5 -5
- package/public/assets/dialog-DeBPk-I3.js.br +0 -0
- package/public/assets/dialog-DeBPk-I3.js.gz +0 -0
- package/public/assets/field-hint-CMKmbVpy.js +1 -0
- package/public/assets/field-hint-CMKmbVpy.js.br +0 -0
- package/public/assets/field-hint-CMKmbVpy.js.gz +0 -0
- package/public/assets/index-B4w4Lh3e.js +218 -0
- package/public/assets/index-B4w4Lh3e.js.br +0 -0
- package/public/assets/index-B4w4Lh3e.js.gz +0 -0
- package/public/assets/index-CDWu0GQL.css +1 -0
- package/public/assets/index-CDWu0GQL.css.br +0 -0
- package/public/assets/index-CDWu0GQL.css.gz +0 -0
- package/public/assets/list-view-BvINA5t4.js +1 -0
- package/public/assets/list-view-BvINA5t4.js.br +0 -0
- package/public/assets/list-view-BvINA5t4.js.gz +0 -0
- package/public/assets/{pencil-BJcOp3oN.js → pencil-CycAOoC_.js} +2 -2
- package/public/assets/pencil-CycAOoC_.js.br +0 -0
- package/public/assets/pencil-CycAOoC_.js.gz +0 -0
- package/public/assets/resource-health-C1qSG3MK.js +1 -0
- package/public/assets/resource-health-C1qSG3MK.js.br +0 -0
- package/public/assets/resource-health-C1qSG3MK.js.gz +0 -0
- package/public/assets/{trash-DhBxh6H7.js → trash-BXQnOI5U.js} +3 -3
- package/public/assets/trash-BXQnOI5U.js.br +0 -0
- package/public/assets/trash-BXQnOI5U.js.gz +0 -0
- package/public/index.html +2 -2
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/assets/AppCenter-DZCsC2wa.js.br +0 -0
- package/public/assets/AppCenter-DZCsC2wa.js.gz +0 -0
- package/public/assets/Default-xD9Fmd5X.js +0 -1
- package/public/assets/Default-xD9Fmd5X.js.br +0 -0
- package/public/assets/Default-xD9Fmd5X.js.gz +0 -0
- package/public/assets/Invite-29Erwv8j.js +0 -1
- package/public/assets/Invite-29Erwv8j.js.br +0 -0
- package/public/assets/Invite-29Erwv8j.js.gz +0 -0
- package/public/assets/MacResources-B0DQ9ISE.js.br +0 -0
- package/public/assets/MacResources-B0DQ9ISE.js.gz +0 -0
- package/public/assets/QASession-D-n6PXuD.js +0 -150
- package/public/assets/QASession-D-n6PXuD.js.br +0 -0
- package/public/assets/QASession-D-n6PXuD.js.gz +0 -0
- package/public/assets/ResetPassword-BnPIwTYH.js +0 -1
- package/public/assets/ResetPassword-BnPIwTYH.js.br +0 -0
- package/public/assets/ResetPassword-BnPIwTYH.js.gz +0 -0
- package/public/assets/Setup-DmUGFblg.js +0 -1
- package/public/assets/Setup-DmUGFblg.js.br +0 -0
- package/public/assets/Setup-DmUGFblg.js.gz +0 -0
- package/public/assets/Team-BpvRgFfZ.js +0 -6
- package/public/assets/Team-BpvRgFfZ.js.br +0 -0
- package/public/assets/Team-BpvRgFfZ.js.gz +0 -0
- package/public/assets/Tokens-x6BtCcL7.js +0 -1
- package/public/assets/Tokens-x6BtCcL7.js.br +0 -0
- package/public/assets/Tokens-x6BtCcL7.js.gz +0 -0
- package/public/assets/alert-dialog-C8wDimW-.js +0 -1
- package/public/assets/alert-dialog-C8wDimW-.js.br +0 -0
- package/public/assets/alert-dialog-C8wDimW-.js.gz +0 -0
- package/public/assets/badge-CWVbeuH3.js +0 -1
- package/public/assets/badge-CWVbeuH3.js.br +0 -0
- package/public/assets/badge-CWVbeuH3.js.gz +0 -0
- package/public/assets/build-format-fzt-JrUX.js.br +0 -0
- package/public/assets/build-format-fzt-JrUX.js.gz +0 -0
- package/public/assets/dialog-DQkbaw8h.js.br +0 -0
- package/public/assets/dialog-DQkbaw8h.js.gz +0 -0
- package/public/assets/index-C1Fo8g_c.js +0 -218
- package/public/assets/index-C1Fo8g_c.js.br +0 -0
- package/public/assets/index-C1Fo8g_c.js.gz +0 -0
- package/public/assets/index-C2F1AgwB.css +0 -1
- package/public/assets/index-C2F1AgwB.css.br +0 -0
- package/public/assets/index-C2F1AgwB.css.gz +0 -0
- package/public/assets/list-view-DxI397-H.js +0 -1
- package/public/assets/list-view-DxI397-H.js.br +0 -0
- package/public/assets/list-view-DxI397-H.js.gz +0 -0
- package/public/assets/pencil-BJcOp3oN.js.br +0 -0
- package/public/assets/pencil-BJcOp3oN.js.gz +0 -0
- package/public/assets/resource-health-Cq3gqqVd.js +0 -1
- package/public/assets/resource-health-Cq3gqqVd.js.br +0 -0
- package/public/assets/resource-health-Cq3gqqVd.js.gz +0 -0
- package/public/assets/trash-DhBxh6H7.js.br +0 -0
- package/public/assets/trash-DhBxh6H7.js.gz +0 -0
package/dist/RelayServer.d.ts
CHANGED
|
@@ -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":"AAuBA,OAAO,EAA4D,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAwBjH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAQvD;AAGD;;gFAEgF;AAChF,eAAO,MAAM,uBAAuB,MAAM,CAAA;AAC1C;;sGAEsG;AACtG,eAAO,MAAM,iCAAiC,MAAM,CAAA;AAsEpD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,EACnC,OAAO,EAAE,MAAM,GAAG,IAAI,GACrB;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAUhD;AAQD,eAAO,MAAM,sBAAsB,yEAAyE,CAAA;AAE5G;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAKxD;
|
|
1
|
+
{"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
|
package/dist/RelayServer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1886
|
-
//
|
|
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
|
|
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
|
|
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');
|