@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.
@@ -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
- TUNNEL_TIMEOUT_MS = 55 * 60 * 1000;
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
- REGISTRY_FRESHNESS_TTL_MS = 30 * 60 * 1000;
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
- REGISTRY_PRUNE_THRESHOLD_MS = 60 * 60 * 1000;
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.REGISTRY_PRUNE_THRESHOLD_MS });
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.REGISTRY_FRESHNESS_TTL_MS);
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 REGISTRY_FRESHNESS_TTL_MS — defends against
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 entry = registry[String(tunnelInfo.port)];
196
- if (entry) {
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
- delete registry[String(tunnelInfo.port)];
245
- this.reg.write(registry);
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.TUNNEL_TIMEOUT_MS - now),
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
- // REGISTRY_FRESHNESS_TTL_MS — defends against PID-reuse, bead 3th).
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
- logger.info(`Borrowing tunnel from PID ${regEntry.ownerPid} for port ${port}: ${regEntry.publicUrl}`);
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(regEntry.tunnelUrl, url), tunnelId: regEntry.tunnelId, isLocalhost: true };
428
+ return { url: retargetTunnelUrl(borrowed.tunnelUrl, url), tunnelId: borrowed.tunnelId, isLocalhost: true };
348
429
  }
349
- // 4. Create a new tunnel (this process becomes the owner)
350
- const creationPromise = this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
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
- return { url: tunnelInfo.publicUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true };
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 && Date.now() - entry.lastAccessedAt < this.TUNNEL_TIMEOUT_MS) {
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.TUNNEL_TIMEOUT_MS);
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
- import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
12
- import { tmpdir } from 'os';
13
- import { join } from 'path';
67
+ const sweptAt = new Map();
14
68
  // ── File-backed implementation (production) ───────────────────────────────────
15
- const REGISTRY_FILE = join(tmpdir(), 'debugg-ai-tunnels.json');
16
- export function createFileRegistry() {
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
- try {
20
- if (!existsSync(REGISTRY_FILE))
21
- return {};
22
- return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
23
- }
24
- catch {
25
- return {};
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 = `${REGISTRY_FILE}.${process.pid}.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
- renameSync(tmp, REGISTRY_FILE);
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 = {};