@debugg-ai/debugg-ai-mcp 3.9.1 → 3.9.3
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/handlers/probePageHandler.js +7 -13
- package/dist/handlers/runTestSuiteHandler.js +12 -4
- package/dist/handlers/testPageChangesHandler.js +11 -16
- package/dist/handlers/triggerCrawlHandler.js +10 -13
- package/dist/services/ngrok/ngrokAgentInspector.js +210 -0
- package/dist/services/ngrok/tunnelFaultInjection.js +15 -2
- package/dist/services/ngrok/tunnelManager.js +250 -39
- package/dist/services/ngrok/tunnelRegistry.js +174 -15
- package/dist/utils/localReachability.js +148 -6
- package/dist/utils/tunnelDisposition.js +128 -0
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
23
23
|
import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
|
|
24
24
|
import { getDefaultRegistry, } from './tunnelRegistry.js';
|
|
25
25
|
import { startAgentSession } from './ngrokAgentSession.js';
|
|
26
|
+
import { getDefaultInspector } from './ngrokAgentInspector.js';
|
|
26
27
|
let ngrokModule = null;
|
|
27
28
|
async function getNgrok() {
|
|
28
29
|
if (!ngrokModule) {
|
|
@@ -49,18 +50,51 @@ class TunnelManager {
|
|
|
49
50
|
activeTunnels = new Map();
|
|
50
51
|
pendingTunnels = new Map();
|
|
51
52
|
initialized = false;
|
|
52
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Idle window before an owned tunnel auto-shuts-off. THE constant of this
|
|
55
|
+
* class: every other lifetime below is derived from it, because they are all
|
|
56
|
+
* answering the same question — "could this tunnel still be alive?" — and
|
|
57
|
+
* when they answered it differently they cost real money (bead y7x6).
|
|
58
|
+
*
|
|
59
|
+
* Public so timer tests can run in milliseconds instead of 55 minutes,
|
|
60
|
+
* matching the `connectBackoffMs` / `agentSessionStarter` precedent.
|
|
61
|
+
*/
|
|
62
|
+
idleTimeoutMs = 55 * 60 * 1000;
|
|
53
63
|
/**
|
|
54
64
|
* Bead `3th`: registry-entry freshness window. An entry not touched within
|
|
55
65
|
* this many ms is treated as stale even if its owner PID is alive — defends
|
|
56
66
|
* against PID-reuse (OS reassigns dead-owner's PID to a different process).
|
|
67
|
+
*
|
|
68
|
+
* Bead `y7x6`: this was a hard-coded 30 minutes while tunnels lived for 55,
|
|
69
|
+
* so between T+30 and T+55 an entry was judged unusable while the tunnel it
|
|
70
|
+
* named was alive and billing. The next request provisioned a duplicate and
|
|
71
|
+
* OVERWROTE the entry, orphaning the original: a systematic double-bill on a
|
|
72
|
+
* 25-minute-wide window. Deriving it from the idle timeout is the fix, and
|
|
73
|
+
* it is the derivation — not the number — that matters, because it makes the
|
|
74
|
+
* two impossible to drift apart again.
|
|
75
|
+
*
|
|
76
|
+
* No guard band is subtracted. A band would just re-open a narrower version
|
|
77
|
+
* of the same window, and the T+55 boundary is already handled: a borrower
|
|
78
|
+
* writes `lastAccessedAt` before the owner's timer fires, and the owner then
|
|
79
|
+
* extends rather than shutting down.
|
|
57
80
|
*/
|
|
58
|
-
|
|
81
|
+
get registryFreshnessTtlMs() {
|
|
82
|
+
return this.idleTimeoutMs;
|
|
83
|
+
}
|
|
59
84
|
/**
|
|
60
85
|
* Bead `mdp`: prune-on-startup eviction window. Entries older than this OR
|
|
61
86
|
* with dead owner PID get swept out when TunnelManager initializes.
|
|
87
|
+
*
|
|
88
|
+
* Also derived, for the same reason: an entry older than the idle timeout
|
|
89
|
+
* names a tunnel that has already auto-shut-off, and one that has NOT is
|
|
90
|
+
* recovered by re-adoption (bead lc62) rather than by keeping a longer
|
|
91
|
+
* threshold here. Pruning something `isEntryUsable` already rejects costs
|
|
92
|
+
* nothing; the danger was never prune's window, it was that nothing put a
|
|
93
|
+
* live tunnel back.
|
|
62
94
|
*/
|
|
63
|
-
|
|
95
|
+
get registryPruneThresholdMs() {
|
|
96
|
+
return this.idleTimeoutMs;
|
|
97
|
+
}
|
|
64
98
|
/**
|
|
65
99
|
* Backoff schedule (ms) between ngrok.connect() retry attempts. Bead ixh.
|
|
66
100
|
* Exposed on the class so tests can override with short delays without
|
|
@@ -80,17 +114,31 @@ class TunnelManager {
|
|
|
80
114
|
* a hang.
|
|
81
115
|
*/
|
|
82
116
|
agentSessionTimeoutMs = 5000;
|
|
117
|
+
/**
|
|
118
|
+
* Bead lc62: where we learn which tunnels are actually alive on this machine.
|
|
119
|
+
* Overridable so tests drive a fake agent API instead of loopback HTTP.
|
|
120
|
+
*/
|
|
121
|
+
tunnelInspector = getDefaultInspector();
|
|
83
122
|
/** Whether the ngrok agent's client session is established (bead pqgj). */
|
|
84
123
|
agentSessionReady = false;
|
|
85
124
|
/** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
|
|
86
125
|
agentSessionPromise = null;
|
|
126
|
+
/** Memoized one-shot reconcile against the local ngrok agents (bead lc62). */
|
|
127
|
+
reconcilePromise = null;
|
|
87
128
|
constructor(reg = getDefaultRegistry()) {
|
|
88
129
|
this.reg = reg;
|
|
89
130
|
// Bead `mdp`: sweep stale entries on startup so the registry doesn't grow
|
|
90
131
|
// unboundedly across MCP processes that exited without stopAllTunnels
|
|
91
132
|
// (SIGKILL / crash). Best-effort — no-op registries don't actually prune.
|
|
133
|
+
//
|
|
134
|
+
// Bead lc62: this sweep deletes map keys, which cannot stop a tunnel, so a
|
|
135
|
+
// pruned-but-live tunnel becomes an invisible billing line. Making prune
|
|
136
|
+
// tear tunnels down would be far worse — two billed hours for every idle
|
|
137
|
+
// gap, on exactly the days-long sessions this design exists to serve — so
|
|
138
|
+
// recovery is handled the other way round, by reconcileWithLocalAgents()
|
|
139
|
+
// putting live tunnels BACK. Prune stays cheap, synchronous, and harmless.
|
|
92
140
|
try {
|
|
93
|
-
const result = this.reg.prune({ staleAfterMs: this.
|
|
141
|
+
const result = this.reg.prune({ staleAfterMs: this.registryPruneThresholdMs });
|
|
94
142
|
if (result.pruned > 0) {
|
|
95
143
|
logger.info(`Pruned ${result.pruned} stale registry entries on startup (${result.remaining} remaining)`);
|
|
96
144
|
}
|
|
@@ -105,7 +153,7 @@ class TunnelManager {
|
|
|
105
153
|
*/
|
|
106
154
|
isEntryUsable(entry, nowMs = Date.now()) {
|
|
107
155
|
return (this.reg.isPidAlive(entry.ownerPid) &&
|
|
108
|
-
(nowMs - entry.lastAccessedAt) <= this.
|
|
156
|
+
(nowMs - entry.lastAccessedAt) <= this.registryFreshnessTtlMs);
|
|
109
157
|
}
|
|
110
158
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
111
159
|
async processUrl(url, authToken, specificTunnelId, keyId, revokeKey) {
|
|
@@ -132,7 +180,7 @@ class TunnelManager {
|
|
|
132
180
|
return undefined;
|
|
133
181
|
if (!existing.isOwned) {
|
|
134
182
|
// Verify the owning process is still alive AND the entry is fresh
|
|
135
|
-
// (lastAccessedAt within
|
|
183
|
+
// (lastAccessedAt within registryFreshnessTtlMs — defends against
|
|
136
184
|
// PID-reuse per bead 3th).
|
|
137
185
|
const entry = this.reg.read()[String(port)];
|
|
138
186
|
if (!entry || !this.isEntryUsable(entry)) {
|
|
@@ -184,25 +232,61 @@ class TunnelManager {
|
|
|
184
232
|
// best-effort — a failed eviction just means the next call re-probes and re-evicts
|
|
185
233
|
}
|
|
186
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Mark a tunnel as in-use: refresh the shared registry entry so the owner
|
|
237
|
+
* does not auto-shut-off underneath us, and reset the local idle timer.
|
|
238
|
+
*
|
|
239
|
+
* Bead lc62 — two changes here, both about the registry telling the truth:
|
|
240
|
+
*
|
|
241
|
+
* 1. The refresh is now scoped to OUR tunnelId. It used to refresh whatever
|
|
242
|
+
* entry held our port, so using tunnel A kept tunnel B's entry alive after
|
|
243
|
+
* B had replaced A on that port.
|
|
244
|
+
* 2. If we OWN the tunnel and the entry has gone missing, we put it back.
|
|
245
|
+
* Nothing did this before: prune (or a registry the process could not see,
|
|
246
|
+
* bead fcbm) deleted the entry, and since the in-process reuse path never
|
|
247
|
+
* wrote to the registry, the tunnel stayed live, stayed billing, and
|
|
248
|
+
* stayed permanently invisible to every other MCP on the machine. One file
|
|
249
|
+
* write makes that self-heal, and it cannot churn a tunnel because it only
|
|
250
|
+
* ever ADDS the entry for a tunnel this process is holding open.
|
|
251
|
+
*
|
|
252
|
+
* A foreign entry — same port, different tunnelId — is left completely alone.
|
|
253
|
+
* That is another process's live tunnel; overwriting it would displace it.
|
|
254
|
+
*/
|
|
187
255
|
touchTunnel(tunnelId) {
|
|
188
256
|
const tunnelInfo = this.activeTunnels.get(tunnelId);
|
|
189
257
|
if (!tunnelInfo)
|
|
190
258
|
return;
|
|
191
|
-
// Refresh the shared registry entry so the owning process won't auto-shutoff
|
|
192
|
-
// while we're actively using the tunnel (even if we're borrowing it).
|
|
193
259
|
try {
|
|
194
260
|
const registry = this.reg.read();
|
|
195
|
-
const
|
|
196
|
-
|
|
261
|
+
const key = String(tunnelInfo.port);
|
|
262
|
+
const entry = registry[key];
|
|
263
|
+
if (entry?.tunnelId === tunnelInfo.tunnelId) {
|
|
197
264
|
entry.lastAccessedAt = Date.now();
|
|
198
265
|
this.reg.write(registry);
|
|
199
266
|
}
|
|
267
|
+
else if (!entry && tunnelInfo.isOwned) {
|
|
268
|
+
registry[key] = this.registryEntryFor(tunnelInfo);
|
|
269
|
+
this.reg.write(registry);
|
|
270
|
+
logger.info(`Re-registered owned tunnel ${tunnelInfo.tunnelId} for port ${tunnelInfo.port} — ` +
|
|
271
|
+
'its registry entry had gone missing while the tunnel was still live (bead lc62).');
|
|
272
|
+
}
|
|
200
273
|
}
|
|
201
274
|
catch {
|
|
202
275
|
// best-effort
|
|
203
276
|
}
|
|
204
277
|
this.resetTunnelTimer(tunnelInfo);
|
|
205
278
|
}
|
|
279
|
+
/** The shared-registry view of a tunnel this process owns. */
|
|
280
|
+
registryEntryFor(tunnelInfo) {
|
|
281
|
+
return {
|
|
282
|
+
tunnelId: tunnelInfo.tunnelId,
|
|
283
|
+
publicUrl: tunnelInfo.publicUrl,
|
|
284
|
+
tunnelUrl: tunnelInfo.tunnelUrl,
|
|
285
|
+
port: tunnelInfo.port,
|
|
286
|
+
ownerPid: process.pid,
|
|
287
|
+
lastAccessedAt: Date.now(),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
206
290
|
touchTunnelByUrl(url) {
|
|
207
291
|
const tunnelId = this.extractTunnelId(url);
|
|
208
292
|
if (tunnelId) {
|
|
@@ -238,11 +322,19 @@ class TunnelManager {
|
|
|
238
322
|
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'released', isOwned: false });
|
|
239
323
|
return;
|
|
240
324
|
}
|
|
241
|
-
// Owned — remove from shared registry, then disconnect + revoke
|
|
325
|
+
// Owned — remove from shared registry, then disconnect + revoke.
|
|
326
|
+
// Guarded by tunnelId (bead lc62, same reasoning as the auto-shutoff check
|
|
327
|
+
// and markTunnelDead): if a replacement already holds this port's entry,
|
|
328
|
+
// deleting it would strand ITS live tunnel and buy the next caller a
|
|
329
|
+
// duplicate. Only ever remove the entry that names the tunnel we are
|
|
330
|
+
// actually stopping.
|
|
242
331
|
try {
|
|
243
332
|
const registry = this.reg.read();
|
|
244
|
-
|
|
245
|
-
|
|
333
|
+
const key = String(tunnelInfo.port);
|
|
334
|
+
if (registry[key]?.tunnelId === tunnelInfo.tunnelId) {
|
|
335
|
+
delete registry[key];
|
|
336
|
+
this.reg.write(registry);
|
|
337
|
+
}
|
|
246
338
|
}
|
|
247
339
|
catch {
|
|
248
340
|
// best-effort
|
|
@@ -281,7 +373,7 @@ class TunnelManager {
|
|
|
281
373
|
tunnel,
|
|
282
374
|
age: now - tunnel.createdAt,
|
|
283
375
|
timeSinceLastAccess: now - tunnel.lastAccessedAt,
|
|
284
|
-
timeUntilAutoShutoff: Math.max(0, tunnel.lastAccessedAt + this.
|
|
376
|
+
timeUntilAutoShutoff: Math.max(0, tunnel.lastAccessedAt + this.idleTimeoutMs - now),
|
|
285
377
|
};
|
|
286
378
|
}
|
|
287
379
|
getAllTunnelStatuses() {
|
|
@@ -300,6 +392,12 @@ class TunnelManager {
|
|
|
300
392
|
if (existing) {
|
|
301
393
|
// Bead zmc9: retarget to THIS caller's path; publicUrl carries the creator's.
|
|
302
394
|
const url_ = retargetTunnelUrl(existing.tunnelUrl, url);
|
|
395
|
+
// Bead lc62: reuse used to return straight from the in-process map without
|
|
396
|
+
// touching the registry at all, so a tunnel could be in constant use and
|
|
397
|
+
// still look abandoned to every other MCP — and its own idle timer kept
|
|
398
|
+
// counting down. touchTunnel refreshes (or restores) the shared entry and
|
|
399
|
+
// resets that timer, which is what "this tunnel is in use" should mean.
|
|
400
|
+
this.touchTunnel(existing.tunnelId);
|
|
303
401
|
logger.info(`Reusing existing tunnel for port ${port}: ${url_}`);
|
|
304
402
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'reused' });
|
|
305
403
|
return { url: url_, tunnelId: existing.tunnelId, isLocalhost: true };
|
|
@@ -320,34 +418,20 @@ class TunnelManager {
|
|
|
320
418
|
}
|
|
321
419
|
// 3. Check cross-process registry — another MCP instance may own a tunnel.
|
|
322
420
|
// Borrow only if the entry is fresh (PID alive AND touched within
|
|
323
|
-
//
|
|
421
|
+
// registryFreshnessTtlMs — defends against PID-reuse, bead 3th).
|
|
324
422
|
const registry = this.reg.read();
|
|
325
423
|
const regEntry = registry[String(port)];
|
|
326
424
|
if (regEntry && this.isEntryUsable(regEntry)) {
|
|
327
|
-
|
|
328
|
-
const now = Date.now();
|
|
329
|
-
const borrowed = {
|
|
330
|
-
tunnelId: regEntry.tunnelId,
|
|
331
|
-
originalUrl: url,
|
|
332
|
-
tunnelUrl: regEntry.tunnelUrl,
|
|
333
|
-
publicUrl: regEntry.publicUrl,
|
|
334
|
-
port,
|
|
335
|
-
createdAt: now,
|
|
336
|
-
lastAccessedAt: now,
|
|
337
|
-
isOwned: false,
|
|
338
|
-
};
|
|
339
|
-
this.activeTunnels.set(regEntry.tunnelId, borrowed);
|
|
340
|
-
// Touch registry so the owner knows not to auto-shutoff
|
|
341
|
-
regEntry.lastAccessedAt = now;
|
|
342
|
-
this.reg.write(registry);
|
|
343
|
-
this.resetTunnelTimer(borrowed);
|
|
344
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'borrowed' });
|
|
425
|
+
const borrowed = this.borrowRegistryEntry(regEntry, url, registry);
|
|
345
426
|
// Bead zmc9: retarget to THIS caller's path; regEntry.publicUrl carries the
|
|
346
427
|
// owning PID's creating-call path — replaying it is the cross-session poison.
|
|
347
|
-
return { url: retargetTunnelUrl(
|
|
428
|
+
return { url: retargetTunnelUrl(borrowed.tunnelUrl, url), tunnelId: borrowed.tunnelId, isLocalhost: true };
|
|
348
429
|
}
|
|
349
|
-
// 4.
|
|
350
|
-
|
|
430
|
+
// 4. Nothing to reuse. Publish the pending promise SYNCHRONOUSLY — every
|
|
431
|
+
// check above is synchronous precisely so a second caller arriving in
|
|
432
|
+
// this same tick joins us rather than buying a second hour — and do the
|
|
433
|
+
// slow work (agent reconcile, then connect) inside it.
|
|
434
|
+
const creationPromise = this.adoptOrCreateTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
|
|
351
435
|
this.pendingTunnels.set(port, creationPromise);
|
|
352
436
|
let tunnelInfo;
|
|
353
437
|
try {
|
|
@@ -356,7 +440,123 @@ class TunnelManager {
|
|
|
356
440
|
finally {
|
|
357
441
|
this.pendingTunnels.delete(port);
|
|
358
442
|
}
|
|
359
|
-
|
|
443
|
+
// A tunnel we just created carries this caller's path in publicUrl; an
|
|
444
|
+
// ADOPTED one carries someone else's, so retarget (bead zmc9).
|
|
445
|
+
const resolvedUrl = tunnelInfo.isOwned
|
|
446
|
+
? tunnelInfo.publicUrl
|
|
447
|
+
: retargetTunnelUrl(tunnelInfo.tunnelUrl, url);
|
|
448
|
+
return { url: resolvedUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Take a live registry entry into this process as a BORROWED tunnel, and
|
|
452
|
+
* stamp it as touched so its owner does not auto-shut-off underneath us.
|
|
453
|
+
*/
|
|
454
|
+
borrowRegistryEntry(entry, url, registry) {
|
|
455
|
+
logger.info(`Borrowing tunnel from PID ${entry.ownerPid} for port ${entry.port}: ${entry.publicUrl}`);
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
const borrowed = {
|
|
458
|
+
tunnelId: entry.tunnelId,
|
|
459
|
+
originalUrl: url,
|
|
460
|
+
tunnelUrl: entry.tunnelUrl,
|
|
461
|
+
publicUrl: entry.publicUrl,
|
|
462
|
+
port: entry.port,
|
|
463
|
+
createdAt: now,
|
|
464
|
+
lastAccessedAt: now,
|
|
465
|
+
isOwned: false,
|
|
466
|
+
};
|
|
467
|
+
this.activeTunnels.set(entry.tunnelId, borrowed);
|
|
468
|
+
entry.lastAccessedAt = now;
|
|
469
|
+
try {
|
|
470
|
+
this.reg.write(registry);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
// best-effort
|
|
474
|
+
}
|
|
475
|
+
this.resetTunnelTimer(borrowed);
|
|
476
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port: entry.port, how: 'borrowed' });
|
|
477
|
+
return borrowed;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Last stop before spending a billed hour (bead lc62).
|
|
481
|
+
*
|
|
482
|
+
* The registry says there is nothing to reuse for this port. Before believing
|
|
483
|
+
* it, ask the local ngrok agents what is ACTUALLY running: a tunnel whose
|
|
484
|
+
* owner was SIGKILLed, or whose entry got pruned or written to a registry
|
|
485
|
+
* this process could not see, is still open and still billing, and the
|
|
486
|
+
* registry is simply wrong about it. Re-adopting one costs a loopback GET;
|
|
487
|
+
* not adopting it costs an hour for the replacement plus the remaining hour
|
|
488
|
+
* of the orphan nobody is using.
|
|
489
|
+
*
|
|
490
|
+
* Reaping orphans is deliberately NOT done here. Once they can be re-adopted
|
|
491
|
+
* an orphan pointing at a live port is an asset, and killing it only
|
|
492
|
+
* guarantees we buy that hour again later.
|
|
493
|
+
*/
|
|
494
|
+
async adoptOrCreateTunnel(url, port, tunnelId, authToken, keyId, revokeKey) {
|
|
495
|
+
await this.reconcileWithLocalAgents();
|
|
496
|
+
const registry = this.reg.read();
|
|
497
|
+
const entry = registry[String(port)];
|
|
498
|
+
if (entry && this.isEntryUsable(entry)) {
|
|
499
|
+
logger.info(`Adopted live tunnel ${entry.tunnelId} for port ${port} instead of provisioning a new one`);
|
|
500
|
+
return this.borrowRegistryEntry(entry, url, registry);
|
|
501
|
+
}
|
|
502
|
+
return this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Reconcile the shared registry against the tunnels the local ngrok agents
|
|
506
|
+
* report (bead lc62). Runs at most once per process, lazily — on the first
|
|
507
|
+
* request that would otherwise provision — so importing this module never
|
|
508
|
+
* touches the network and a process that only ever borrows never pays for it.
|
|
509
|
+
*
|
|
510
|
+
* ADD-ONLY, and that is the whole safety argument. This can create an entry
|
|
511
|
+
* or refresh an unusable one; it can never delete or invalidate anything. So
|
|
512
|
+
* an agent that is down, a scan that misses the right port, or a parse that
|
|
513
|
+
* fails all degrade to "learned nothing" and leave behaviour exactly as it is
|
|
514
|
+
* today. Nothing this function can get wrong is able to cost a re-provision.
|
|
515
|
+
*
|
|
516
|
+
* A usable entry is never disturbed, even by a live tunnel claiming the same
|
|
517
|
+
* port: that entry is somebody's working tunnel and displacing it would
|
|
518
|
+
* strand a paid-for session.
|
|
519
|
+
*/
|
|
520
|
+
reconcileWithLocalAgents() {
|
|
521
|
+
if (!this.reconcilePromise) {
|
|
522
|
+
this.reconcilePromise = (async () => {
|
|
523
|
+
const live = await this.tunnelInspector.listLiveTunnels();
|
|
524
|
+
if (live.length === 0)
|
|
525
|
+
return;
|
|
526
|
+
const registry = this.reg.read();
|
|
527
|
+
const adopted = [];
|
|
528
|
+
for (const tunnel of live) {
|
|
529
|
+
const key = String(tunnel.port);
|
|
530
|
+
const entry = registry[key];
|
|
531
|
+
// Somebody's working entry — never touch it, even to "correct" it.
|
|
532
|
+
if (entry && this.isEntryUsable(entry))
|
|
533
|
+
continue;
|
|
534
|
+
registry[key] = {
|
|
535
|
+
tunnelId: tunnel.tunnelId,
|
|
536
|
+
publicUrl: tunnel.publicUrl,
|
|
537
|
+
tunnelUrl: tunnel.publicUrl,
|
|
538
|
+
port: tunnel.port,
|
|
539
|
+
// We are not the ngrok owner and never claim to be — TunnelInfo for
|
|
540
|
+
// this entry is always built with isOwned:false. ownerPid is the
|
|
541
|
+
// registry's liveness proxy, and pointing it at a live process is
|
|
542
|
+
// what makes the entry borrowable at all.
|
|
543
|
+
ownerPid: process.pid,
|
|
544
|
+
lastAccessedAt: Date.now(),
|
|
545
|
+
};
|
|
546
|
+
adopted.push(`${tunnel.tunnelId}→${tunnel.port}`);
|
|
547
|
+
}
|
|
548
|
+
if (adopted.length > 0) {
|
|
549
|
+
this.reg.write(registry);
|
|
550
|
+
logger.info(`Re-adopted ${adopted.length} live ngrok tunnel(s) the registry had lost: ${adopted.join(', ')}. ` +
|
|
551
|
+
'Each one saves provisioning a duplicate for a port that is already served.');
|
|
552
|
+
}
|
|
553
|
+
})().catch((err) => {
|
|
554
|
+
// An inspector failure must never block tunnelling — it only ever had
|
|
555
|
+
// the power to save us money, never to authorise anything.
|
|
556
|
+
logger.debug(`ngrok agent reconcile unavailable (non-fatal): ${err}`);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return this.reconcilePromise;
|
|
360
560
|
}
|
|
361
561
|
findTunnelByPort(port) {
|
|
362
562
|
for (const tunnel of this.activeTunnels.values()) {
|
|
@@ -619,10 +819,21 @@ class TunnelManager {
|
|
|
619
819
|
tunnelInfo.autoShutoffTimer = setTimeout(async () => {
|
|
620
820
|
// For owned tunnels: if another process recently touched the registry entry,
|
|
621
821
|
// reset the timer rather than disconnecting — that process is still using it.
|
|
822
|
+
//
|
|
823
|
+
// Bead lc62: the entry has to be OURS. This lookup is keyed by port, and
|
|
824
|
+
// the check used to stop at the timestamp, so once a replacement tunnel
|
|
825
|
+
// took over the port the displaced tunnel read the replacement's activity
|
|
826
|
+
// as its own and extended itself — forever, since every extension found
|
|
827
|
+
// the entry fresh again. That is what turned a 55-minute mistake into a
|
|
828
|
+
// multi-day one: an orphan nobody could reach, billing indefinitely.
|
|
829
|
+
// Comparing tunnelId makes an orphan simply time out, 55 idle minutes
|
|
830
|
+
// after the last time anyone actually used IT.
|
|
622
831
|
if (tunnelInfo.isOwned) {
|
|
623
832
|
try {
|
|
624
833
|
const entry = this.reg.read()[String(tunnelInfo.port)];
|
|
625
|
-
if (entry &&
|
|
834
|
+
if (entry &&
|
|
835
|
+
entry.tunnelId === tunnelInfo.tunnelId &&
|
|
836
|
+
Date.now() - entry.lastAccessedAt < this.idleTimeoutMs) {
|
|
626
837
|
logger.info(`Tunnel ${tunnelInfo.tunnelId} accessed by another process — extending lifetime`);
|
|
627
838
|
this.resetTunnelTimer(tunnelInfo);
|
|
628
839
|
return;
|
|
@@ -635,7 +846,7 @@ class TunnelManager {
|
|
|
635
846
|
logger.info(`Auto-shutting down tunnel ${tunnelInfo.tunnelId} after inactivity`);
|
|
636
847
|
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'auto-shutoff', isOwned: tunnelInfo.isOwned });
|
|
637
848
|
await this.stopTunnel(tunnelInfo.tunnelId).catch((err) => logger.error(`Failed to auto-shutdown tunnel ${tunnelInfo.tunnelId}:`, err));
|
|
638
|
-
}, this.
|
|
849
|
+
}, this.idleTimeoutMs);
|
|
639
850
|
}
|
|
640
851
|
}
|
|
641
852
|
const tunnelManager = new TunnelManager();
|
|
@@ -7,29 +7,109 @@
|
|
|
7
7
|
* The file registry uses an atomic rename-write so concurrent processes never
|
|
8
8
|
* see a partial JSON file. All operations are best-effort — errors are
|
|
9
9
|
* swallowed so a broken registry never blocks tunnel creation.
|
|
10
|
+
*
|
|
11
|
+
* Bead `fcbm` — WHERE the file lives is load-bearing. It used to be
|
|
12
|
+
* `join(tmpdir(), 'debugg-ai-tunnels.json')`, and `os.tmpdir()` honours
|
|
13
|
+
* `$TMPDIR`, which is not a property of the machine — it is a property of how
|
|
14
|
+
* the process was LAUNCHED. Under launchd it is a per-user
|
|
15
|
+
* `/var/folders/<...>/T`; a shell with a scrubbed environment gets `/tmp`; the
|
|
16
|
+
* Docker image gets `/tmp`. Two MCPs started differently therefore kept two
|
|
17
|
+
* SEPARATE registries, never saw each other's tunnels, and provisioned a
|
|
18
|
+
* duplicate per port — silently and permanently. At a 1-hour minimum charge per
|
|
19
|
+
* tunnel that is a guaranteed double-bill, not an edge case.
|
|
20
|
+
*
|
|
21
|
+
* So the path is pinned to `~/.debugg-ai/tunnels.json`, which is the same file
|
|
22
|
+
* for the same user no matter how the process was started, with a
|
|
23
|
+
* `DEBUGG_AI_TUNNEL_REGISTRY` override for containers that mount a shared
|
|
24
|
+
* volume elsewhere. Anything found at the legacy tmpdir() path is merged in
|
|
25
|
+
* once, so tunnels already paid for on the old path stay discoverable.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
28
|
+
import { homedir, tmpdir } from 'os';
|
|
29
|
+
import { dirname, join } from 'path';
|
|
30
|
+
// ── File location ─────────────────────────────────────────────────────────────
|
|
31
|
+
/**
|
|
32
|
+
* The pre-fcbm location. Still READ (and merged from) so tunnels provisioned by
|
|
33
|
+
* an older build, or by a build whose $TMPDIR differed, are not stranded.
|
|
34
|
+
*
|
|
35
|
+
* Never written to, and never deleted: a still-running older MCP is reading it,
|
|
36
|
+
* and removing its entries would make it re-provision — the exact double-bill
|
|
37
|
+
* this bead exists to stop.
|
|
38
|
+
*/
|
|
39
|
+
export function getLegacyRegistryFilePath() {
|
|
40
|
+
return join(tmpdir(), 'debugg-ai-tunnels.json');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The registry path for this machine+user. Resolved per call (not cached at
|
|
44
|
+
* module load) so an override can be set before the registry is constructed.
|
|
45
|
+
*/
|
|
46
|
+
export function getRegistryFilePath() {
|
|
47
|
+
const override = process.env.DEBUGG_AI_TUNNEL_REGISTRY?.trim();
|
|
48
|
+
if (override)
|
|
49
|
+
return override;
|
|
50
|
+
return join(homedir(), '.debugg-ai', 'tunnels.json');
|
|
51
|
+
}
|
|
52
|
+
/** Paths already merged in this process — the legacy merge is a one-shot. */
|
|
53
|
+
const migratedPaths = new Set();
|
|
54
|
+
/**
|
|
55
|
+
* Per-path watermark: when we last wrote a complete view of this registry.
|
|
56
|
+
*
|
|
57
|
+
* The read-side legacy overlay needs to tell two cases apart that look identical
|
|
58
|
+
* in the file: an entry we have ALREADY considered and deliberately swept, and
|
|
59
|
+
* one an old-build MCP wrote after we swept. Resurrecting the first hands a dead
|
|
60
|
+
* tunnel to the next borrower (bead k34o); missing the second buys a duplicate.
|
|
61
|
+
* An entry's `lastAccessedAt` relative to our last write separates them.
|
|
62
|
+
*
|
|
63
|
+
* Ties and clock skew resolve toward NOT resurrecting, because the two mistakes
|
|
64
|
+
* are not equal: a dead entry breaks a run, a missed one costs an hour we were
|
|
65
|
+
* already spending before this path moved.
|
|
10
66
|
*/
|
|
11
|
-
|
|
12
|
-
import { tmpdir } from 'os';
|
|
13
|
-
import { join } from 'path';
|
|
67
|
+
const sweptAt = new Map();
|
|
14
68
|
// ── File-backed implementation (production) ───────────────────────────────────
|
|
15
|
-
|
|
16
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Both paths are parameters rather than module constants so tests can point at
|
|
71
|
+
* a throwaway directory. That matters more than it looks: the legacy path is a
|
|
72
|
+
* real machine-wide file that a running MCP may depend on, and `os.tmpdir()`
|
|
73
|
+
* does not observe a `TMPDIR` set inside a Jest realm — so a test that tried to
|
|
74
|
+
* redirect it by environment would silently read and REWRITE the developer's
|
|
75
|
+
* actual registry.
|
|
76
|
+
*/
|
|
77
|
+
export function createFileRegistry(registryFile = getRegistryFilePath(), legacyFile = getLegacyRegistryFilePath()) {
|
|
17
78
|
const store = {
|
|
18
79
|
read() {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
80
|
+
// Overlay the legacy registry on EVERY read, not just once at startup.
|
|
81
|
+
//
|
|
82
|
+
// Moving the path partitions us against every MCP still running the old
|
|
83
|
+
// build, which keeps writing to tmpdir() — i.e. for the length of the
|
|
84
|
+
// rollout this change would REINTRODUCE the split-registry double-billing
|
|
85
|
+
// it exists to fix. That window is not short: long-lived sessions here run
|
|
86
|
+
// for days, so a one-shot merge at process start would miss every tunnel
|
|
87
|
+
// an old build provisions afterwards.
|
|
88
|
+
//
|
|
89
|
+
// Reading both and preferring the fresher record closes the direction that
|
|
90
|
+
// matters — new builds see old builds' tunnels and borrow them instead of
|
|
91
|
+
// buying duplicates. The reverse (old builds seeing ours) cannot be fixed
|
|
92
|
+
// from this side without dual-writing, which would hand the old code a file
|
|
93
|
+
// it may prune on our behalf; it resolves as old sessions exit.
|
|
94
|
+
//
|
|
95
|
+
// Cost is one extra existsSync + small JSON read per lookup, against a
|
|
96
|
+
// 1-hour minimum charge for getting it wrong.
|
|
97
|
+
return overlayLegacy(readRegistryFile(registryFile), legacyFile, registryFile);
|
|
27
98
|
},
|
|
28
99
|
write(data) {
|
|
29
|
-
const tmp = `${
|
|
100
|
+
const tmp = `${registryFile}.${process.pid}.tmp`;
|
|
30
101
|
try {
|
|
102
|
+
// 0o700: the registry names every local port this user is exposing.
|
|
103
|
+
// Also covers a first run where ~/.debugg-ai does not exist yet, and a
|
|
104
|
+
// later run where someone removed it.
|
|
105
|
+
mkdirSync(dirname(registryFile), { recursive: true, mode: 0o700 });
|
|
31
106
|
writeFileSync(tmp, JSON.stringify(data));
|
|
32
|
-
|
|
107
|
+
// Same directory as the target, so the rename is same-filesystem and
|
|
108
|
+
// therefore actually atomic.
|
|
109
|
+
renameSync(tmp, registryFile);
|
|
110
|
+
// We have just published a complete view; anything older in the legacy
|
|
111
|
+
// file has already been considered (and possibly swept) by us.
|
|
112
|
+
sweptAt.set(registryFile, Date.now());
|
|
33
113
|
}
|
|
34
114
|
catch {
|
|
35
115
|
// best-effort
|
|
@@ -42,8 +122,87 @@ export function createFileRegistry() {
|
|
|
42
122
|
return pruneRegistryData(store, opts);
|
|
43
123
|
},
|
|
44
124
|
};
|
|
125
|
+
mergeLegacyRegistry(store, registryFile, legacyFile);
|
|
45
126
|
return store;
|
|
46
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* One-shot merge of the legacy tmpdir() registry into the stable one.
|
|
130
|
+
*
|
|
131
|
+
* Merge, not move: an entry only wins if this path has nothing for that port or
|
|
132
|
+
* has something older. A tunnel that both files know about keeps whichever
|
|
133
|
+
* record was touched most recently, which is the one whose `lastAccessedAt`
|
|
134
|
+
* actually reflects use.
|
|
135
|
+
*/
|
|
136
|
+
function mergeLegacyRegistry(store, registryFile, legacyFile) {
|
|
137
|
+
if (legacyFile === registryFile || migratedPaths.has(registryFile))
|
|
138
|
+
return;
|
|
139
|
+
migratedPaths.add(registryFile);
|
|
140
|
+
const legacy = readRegistryFile(legacyFile);
|
|
141
|
+
const ports = Object.keys(legacy);
|
|
142
|
+
if (ports.length === 0)
|
|
143
|
+
return;
|
|
144
|
+
const current = store.read();
|
|
145
|
+
let merged = 0;
|
|
146
|
+
for (const port of ports) {
|
|
147
|
+
const entry = legacy[port];
|
|
148
|
+
if (!isRegistryEntry(entry))
|
|
149
|
+
continue;
|
|
150
|
+
const mine = current[port];
|
|
151
|
+
if (!mine || entry.lastAccessedAt > mine.lastAccessedAt) {
|
|
152
|
+
current[port] = entry;
|
|
153
|
+
merged++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (merged > 0)
|
|
157
|
+
store.write(current);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Merge the legacy registry over `current` in memory, fresher record winning.
|
|
161
|
+
*
|
|
162
|
+
* Read-side only — never writes. An old-build MCP that provisions a tunnel after
|
|
163
|
+
* our one-shot migration ran is otherwise invisible to us, and we would buy a
|
|
164
|
+
* duplicate for a port it already has covered.
|
|
165
|
+
*/
|
|
166
|
+
function overlayLegacy(current, legacyFile, registryFile) {
|
|
167
|
+
if (legacyFile === registryFile)
|
|
168
|
+
return current;
|
|
169
|
+
const watermark = sweptAt.get(registryFile) ?? 0;
|
|
170
|
+
const legacy = readRegistryFile(legacyFile);
|
|
171
|
+
for (const [port, entry] of Object.entries(legacy)) {
|
|
172
|
+
if (!isRegistryEntry(entry))
|
|
173
|
+
continue;
|
|
174
|
+
// Older than our last complete write => we already saw it and chose not to
|
|
175
|
+
// keep it. Bringing it back would undo a prune and re-borrow a dead tunnel.
|
|
176
|
+
if (entry.lastAccessedAt <= watermark)
|
|
177
|
+
continue;
|
|
178
|
+
const mine = current[port];
|
|
179
|
+
if (!mine || entry.lastAccessedAt > mine.lastAccessedAt)
|
|
180
|
+
current[port] = entry;
|
|
181
|
+
}
|
|
182
|
+
return current;
|
|
183
|
+
}
|
|
184
|
+
function readRegistryFile(file) {
|
|
185
|
+
try {
|
|
186
|
+
if (!existsSync(file))
|
|
187
|
+
return {};
|
|
188
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
189
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
190
|
+
return {};
|
|
191
|
+
return parsed;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function isRegistryEntry(value) {
|
|
198
|
+
const e = value;
|
|
199
|
+
return (!!e &&
|
|
200
|
+
typeof e === 'object' &&
|
|
201
|
+
typeof e.tunnelId === 'string' &&
|
|
202
|
+
typeof e.port === 'number' &&
|
|
203
|
+
typeof e.ownerPid === 'number' &&
|
|
204
|
+
typeof e.lastAccessedAt === 'number');
|
|
205
|
+
}
|
|
47
206
|
// ── In-memory implementation (tests / injectable) ─────────────────────────────
|
|
48
207
|
export function createInMemoryRegistry(isPidAliveImpl) {
|
|
49
208
|
let data = {};
|