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