@debugg-ai/debugg-ai-mcp 3.9.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,45 @@
1
1
  /**
2
2
  * Tunnel Management Service
3
3
  *
4
- * Manages per-port ngrok tunnels with two layers of reuse:
4
+ * ONE ngrok tunnel per SESSION KEY (§2.1 of
5
+ * docs/local-tunnel-multiplexer-architecture-2026-07-31.md) — not one per
6
+ * local port. A session's tunnel dials a local Caddy instance
7
+ * (services/caddy/caddyProxy.ts) that holds exactly one dynamic upstream,
8
+ * repointed via Caddy's admin API immediately before each tool dispatch
9
+ * under a per-session PortLock (services/caddy/portLock.ts) that serializes
10
+ * different-port calls but not same-port ones.
5
11
  *
6
- * 1. Within-process activeTunnels map, 55-min auto-shutoff timer.
7
- * 2. Cross-process — file-backed RegistryStore so a second MCP instance
8
- * on the same machine borrows an existing tunnel instead
9
- * of provisioning a new one for the same port.
12
+ * This retires the entire cross-process "borrow another MCP's tunnel"
13
+ * mechanism the previous per-port design needed (registry-mediated adoption,
14
+ * PID-liveness/freshness checks, re-adoption from the local ngrok agent) —
15
+ * see §4's "Same-machine multi-process / multi-session sharing" decision:
16
+ * "No sharing, ever, at any granularity." Every tunnel this process holds is
17
+ * one it created; `services/ngrok/tunnelRegistry.ts` is now write-mostly
18
+ * observability, not a correctness dependency.
10
19
  *
11
- * Lifecycle:
12
- * - Owned tunnels (isOwned=true) : this process created them; it disconnects
13
- * and revokes the key on stop.
14
- * - Borrowed tunnels (isOwned=false): another process owns them; on stop we
15
- * only remove the local reference.
16
- * - Auto-shutoff timer checks the shared registry before firing: if another
17
- * process recently touched the entry the timer resets instead of stopping.
20
+ * Session identity (§2.1): stdio has exactly one session key for its whole
21
+ * process life. HTTP transport derives a distinct key per caller from the
22
+ * request-scoped bearer token (utils/requestContext.ts), because a bare
23
+ * module singleton serving many HTTP callers on one process must not let two
24
+ * different callers share one Caddy route — that would be a cross-tenant
25
+ * correctness bug, not just a cost one.
26
+ *
27
+ * A single, permanent, named exception: `run_test_suite` is fire-and-forget
28
+ * (no poll loop, no bounded window this process can hold a lock over), so it
29
+ * gets its own dedicated per-call tunnel via `acquireDedicatedTunnel()` that
30
+ * bypasses Caddy/PortLock entirely — see §2.3.
18
31
  */
19
32
  import { Logger } from '../../utils/logger.js';
20
33
  import { Telemetry, TelemetryEvents } from '../../utils/telemetry.js';
21
- import { isLocalhostUrl, extractLocalhostPort, generateTunnelUrl, retargetTunnelUrl } from '../../utils/urlParser.js';
34
+ import { extractLocalhostPort, generateTunnelUrl } from '../../utils/urlParser.js';
35
+ import { currentApiKey } from '../../utils/requestContext.js';
36
+ import { createHash } from 'node:crypto';
22
37
  import { v4 as uuidv4 } from 'uuid';
23
38
  import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
24
39
  import { getDefaultRegistry, } from './tunnelRegistry.js';
25
40
  import { startAgentSession } from './ngrokAgentSession.js';
26
- import { getDefaultInspector } from './ngrokAgentInspector.js';
41
+ import { createCaddyProxy, isDockerEnv, } from '../caddy/caddyProxy.js';
42
+ import { PortLock } from '../caddy/portLock.js';
27
43
  let ngrokModule = null;
28
44
  async function getNgrok() {
29
45
  if (!ngrokModule) {
@@ -38,63 +54,71 @@ async function getNgrok() {
38
54
  }
39
55
  /**
40
56
  * Reset the cached ngrok module so the next connect() bootstraps a fresh agent.
41
- * Called when the last owned tunnel is disconnected and the agent process may have died.
57
+ * Called when the last tunnel is disconnected and the agent process may have died.
42
58
  */
43
59
  function resetNgrokModule() {
44
60
  ngrokModule = null;
45
61
  }
46
62
  const logger = new Logger({ module: 'tunnelManager' });
63
+ // ── Session identity (§2.1) ────────────────────────────────────────────────────
64
+ /**
65
+ * Derives this call's session key. stdio: `currentApiKey()` is always
66
+ * unset (nothing on the stdio path ever calls
67
+ * `utils/requestContext.ts`'s `runWithApiKey`), so every stdio call
68
+ * legitimately collapses onto the fixed `'stdio'` key — that IS "one
69
+ * process = one caller for its whole life" (§2.1), not a fallback failure.
70
+ *
71
+ * HTTP: every request MUST carry a bearer token by the time it reaches tunnel
72
+ * logic — `httpServer.ts` 401s on a missing token before ever calling
73
+ * `runWithApiKey` — so `currentApiKey()` returning undefined while
74
+ * `DEBUGGAI_MCP_TRANSPORT=http` is genuinely anomalous: it means two
75
+ * different HTTP callers could collapse onto the same session key and get
76
+ * routed into each other's local dev server. That specific case is logged
77
+ * loudly so it surfaces in practice.
78
+ *
79
+ * NOTE — this deliberately deviates from the architecture doc's §2.1
80
+ * pseudocode, which logs `logger.error` on EVERY `!apiKey` fallback with no
81
+ * way to tell "expected stdio call" apart from "HTTP call with isolation
82
+ * broken" (both read as `currentApiKey() === undefined`). Following the doc
83
+ * literally would fire an ERROR log on every single stdio tool call — a
84
+ * regression, not a safety net. `DEBUGGAI_MCP_TRANSPORT` (already read by
85
+ * index.ts to choose stdio vs HTTP at startup) is the signal that lets the
86
+ * two cases be told apart; using it here is a bug fix over the doc's literal
87
+ * text, not a simplification of its intent (§6's "no-API-key fallback"
88
+ * finding is still tracked — the loud log now actually only fires for the
89
+ * case it was meant to catch).
90
+ */
91
+ export function getSessionKey() {
92
+ const apiKey = currentApiKey();
93
+ if (apiKey) {
94
+ return `http:${createHash('sha256').update(apiKey).digest('hex').slice(0, 16)}`;
95
+ }
96
+ const transportMode = (process.env.DEBUGGAI_MCP_TRANSPORT || 'stdio').toLowerCase();
97
+ if (transportMode === 'http') {
98
+ logger.error('getSessionKey(): HTTP transport reached tunnel logic with no API key in request context — ' +
99
+ 'falling back to a shared key. This MUST NOT be reachable on an authenticated HTTP path; ' +
100
+ 'if it fires, tunnel isolation between callers is broken.');
101
+ }
102
+ return 'stdio';
103
+ }
47
104
  // ── TunnelManager ─────────────────────────────────────────────────────────────
48
105
  class TunnelManager {
49
106
  reg;
50
107
  activeTunnels = new Map();
51
- pendingTunnels = new Map();
108
+ directTunnels = new Map();
109
+ /** sessionKey -> tunnelId, for the fast "already have a tunnel" path. */
110
+ sessionTunnels = new Map();
111
+ /** sessionKey -> in-flight creation, so concurrent first calls for a fresh
112
+ * session key join one creation instead of each minting their own (§2.3's
113
+ * cold-start TOCTOU fix — see ensureSessionTunnel()). */
114
+ pendingSessionTunnels = new Map();
52
115
  initialized = false;
53
116
  /**
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.
117
+ * Idle window before a tunnel auto-shuts-off. Public so timer tests can run
118
+ * in milliseconds instead of 55 minutes, matching the `connectBackoffMs` /
119
+ * `agentSessionStarter` precedent.
61
120
  */
62
121
  idleTimeoutMs = 55 * 60 * 1000;
63
- /**
64
- * Bead `3th`: registry-entry freshness window. An entry not touched within
65
- * this many ms is treated as stale even if its owner PID is alive — defends
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.
80
- */
81
- get registryFreshnessTtlMs() {
82
- return this.idleTimeoutMs;
83
- }
84
- /**
85
- * Bead `mdp`: prune-on-startup eviction window. Entries older than this OR
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.
94
- */
95
- get registryPruneThresholdMs() {
96
- return this.idleTimeoutMs;
97
- }
98
122
  /**
99
123
  * Backoff schedule (ms) between ngrok.connect() retry attempts. Bead ixh.
100
124
  * Exposed on the class so tests can override with short delays without
@@ -115,30 +139,23 @@ class TunnelManager {
115
139
  */
116
140
  agentSessionTimeoutMs = 5000;
117
141
  /**
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.
142
+ * §2.2/§2.3: one fresh `CaddyProxy` instance PER SESSION KEY, never a
143
+ * process-wide singleton. Overridable so tests drive a fake proxy instead
144
+ * of spawning a real `caddy` process.
120
145
  */
121
- tunnelInspector = getDefaultInspector();
146
+ caddyFactory = createCaddyProxy;
122
147
  /** Whether the ngrok agent's client session is established (bead pqgj). */
123
148
  agentSessionReady = false;
124
149
  /** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
125
150
  agentSessionPromise = null;
126
- /** Memoized one-shot reconcile against the local ngrok agents (bead lc62). */
127
- reconcilePromise = null;
128
151
  constructor(reg = getDefaultRegistry()) {
129
152
  this.reg = reg;
130
- // Bead `mdp`: sweep stale entries on startup so the registry doesn't grow
131
- // unboundedly across MCP processes that exited without stopAllTunnels
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.
153
+ // Bead `mdp`: sweep dead-owner entries on startup so the (now purely
154
+ // diagnostic §4) registry doesn't grow unboundedly across MCP
155
+ // processes that exited without stopAllTunnels (SIGKILL / crash).
156
+ // Best-effort — no-op registries don't actually prune.
140
157
  try {
141
- const result = this.reg.prune({ staleAfterMs: this.registryPruneThresholdMs });
158
+ const result = this.reg.prune();
142
159
  if (result.pruned > 0) {
143
160
  logger.info(`Pruned ${result.pruned} stale registry entries on startup (${result.remaining} remaining)`);
144
161
  }
@@ -147,145 +164,191 @@ class TunnelManager {
147
164
  logger.warn(`Registry prune-on-startup failed (non-fatal): ${err}`);
148
165
  }
149
166
  }
167
+ // ── Public API — session tunnels ───────────────────────────────────────────
150
168
  /**
151
- * Bead `3th`: freshness check used at borrow sites. Returns true if the
152
- * entry is BOTH owner-alive AND touched recently enough to trust.
169
+ * The single entry point for "get me the tunnel for my session," replacing
170
+ * `processUrl()`/`processPerPort()`. Idempotent per session key: the first
171
+ * caller creates, everyone else — concurrent or sequential — reuses.
172
+ *
173
+ * §2.3's cold-start TOCTOU fix: the read (`sessionTunnels.get`) and the
174
+ * eventual write (`sessionTunnels.set`) are separated by several `await`
175
+ * points (spawning Caddy, connecting ngrok). Two near-simultaneous first
176
+ * calls for the same fresh session key — exactly what an orchestrating
177
+ * agent produces (an initial navigate fired alongside an initial probe) —
178
+ * would otherwise both observe a miss and each mint their own tunnel,
179
+ * silently defeating "one tunnel per session" at the moment most likely to
180
+ * have concurrent calls. `pendingSessionTunnels` closes that window: the
181
+ * claim (steps 2-3 below) is entirely synchronous relative to each other,
182
+ * so whichever call runs its synchronous prefix first wins the map slot,
183
+ * and the other necessarily observes it on its own synchronous prefix.
153
184
  */
154
- isEntryUsable(entry, nowMs = Date.now()) {
155
- return (this.reg.isPidAlive(entry.ownerPid) &&
156
- (nowMs - entry.lastAccessedAt) <= this.registryFreshnessTtlMs);
157
- }
158
- // ── Public API ──────────────────────────────────────────────────────────────
159
- async processUrl(url, authToken, specificTunnelId, keyId, revokeKey) {
160
- if (!isLocalhostUrl(url)) {
161
- return { url, isLocalhost: false };
185
+ async ensureSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
186
+ // 1. Fast path: a fully-created tunnel already exists for this session key.
187
+ const existingId = this.sessionTunnels.get(sessionKey);
188
+ if (existingId) {
189
+ const info = this.activeTunnels.get(existingId);
190
+ if (info) {
191
+ this.touchTunnel(info.tunnelId);
192
+ return info;
193
+ }
162
194
  }
195
+ // 2. A creation is already in flight for this session key — join it
196
+ // rather than starting a second one.
197
+ const inFlight = this.pendingSessionTunnels.get(sessionKey);
198
+ if (inFlight)
199
+ return inFlight;
200
+ // 3. First caller for this session key: claim the slot BEFORE any await.
201
+ const creation = this.createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey)
202
+ .finally(() => { this.pendingSessionTunnels.delete(sessionKey); });
203
+ this.pendingSessionTunnels.set(sessionKey, creation);
204
+ return creation;
205
+ }
206
+ /** Cheap peek: an already-created session tunnel, or undefined. Never
207
+ * provisions anything — used by callers that want to skip a backend key
208
+ * provision step when reuse is possible (utils/tunnelContext.ts's
209
+ * findExistingTunnel, mirroring the old getTunnelForPort's role). */
210
+ getSessionTunnelInfo(sessionKey) {
211
+ const tunnelId = this.sessionTunnels.get(sessionKey);
212
+ return tunnelId ? this.activeTunnels.get(tunnelId) : undefined;
213
+ }
214
+ getTunnelInfo(tunnelId) {
215
+ return this.activeTunnels.get(tunnelId);
216
+ }
217
+ getActiveTunnels() {
218
+ return Array.from(this.activeTunnels.values());
219
+ }
220
+ // ── Public API — the run_test_suite exception (§2.3) ───────────────────────
221
+ /**
222
+ * Used ONLY by runTestSuiteHandler.ts. Bypasses Caddy/PortLock entirely —
223
+ * dials ngrok straight at the app, exactly like today's per-port
224
+ * createTunnel(). Governed by the same idleTimeoutMs auto-shutoff as any
225
+ * other tunnel. This is a deliberate, scoped exception to "one tunnel per
226
+ * session" (§2.3) — not a smuggled-in legacy fallback — forced by
227
+ * run_test_suite's async execution model: it is fire-and-forget (no poll
228
+ * loop, no bounded window this process can hold a lock over), so holding
229
+ * the shared session lock for "the whole call" would give it no protection
230
+ * at all — the lock would release back to contention seconds after
231
+ * triggering a suite that goes on to use the port for possibly many more
232
+ * minutes.
233
+ *
234
+ * A session that calls both a Caddy-routed tool AND run_test_suite pays for
235
+ * 2 tunnels for that session — honest and bounded, flagged in §6.
236
+ */
237
+ async acquireDedicatedTunnel(url, authToken, keyId, revokeKey) {
238
+ await this.ensureInitialized();
163
239
  const port = extractLocalhostPort(url);
164
240
  if (!port) {
165
- throw new Error(`Could not extract port from localhost URL: ${url}`);
241
+ throw new Error(`acquireDedicatedTunnel: could not extract port from localhost URL: ${url}`);
242
+ }
243
+ const tunnelId = uuidv4();
244
+ const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`;
245
+ const isHttpsLocal = url.startsWith('https:');
246
+ const inDocker = isDockerEnv();
247
+ // NOTE: this intentionally does NOT reuse caddyProxy.ts's
248
+ // resolveDialAddress() — that function builds Caddy's JSON `dial` field,
249
+ // which is always a bare `host:port` (Caddy conveys TLS-ness via its
250
+ // separate `transport` field, never a URL scheme in `dial`). ngrok's
251
+ // own `connect({ addr })` option is a different consumer with a
252
+ // different format: it DOES need a `https://` scheme prefix for an
253
+ // HTTPS local target (see the original tunnelManager.ts:696-701, which
254
+ // this path preserves byte-for-byte since it dials the app directly,
255
+ // exactly like the pre-cutover per-port createTunnel()). Reusing
256
+ // resolveDialAddress() here would silently drop that scheme and break
257
+ // HTTPS dedicated tunnels.
258
+ const dockerHost = 'host.docker.internal';
259
+ let localAddr;
260
+ if (isHttpsLocal) {
261
+ localAddr = inDocker ? `https://${dockerHost}:${port}` : `https://localhost:${port}`;
262
+ }
263
+ else {
264
+ localAddr = inDocker ? `${dockerHost}:${port}` : `127.0.0.1:${port}`;
265
+ }
266
+ logger.info(`Creating dedicated tunnel for localhost:${port} (domain: ${tunnelDomain}) — ` +
267
+ 'run_test_suite exception, bypasses Caddy');
268
+ const faultMode = getFaultModeFromEnv();
269
+ const faults = new FaultInjector(faultMode);
270
+ const trace = new TunnelTrace();
271
+ trace.emit('acquireDedicatedTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
272
+ try {
273
+ const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults);
274
+ const now = Date.now();
275
+ const info = {
276
+ tunnelId, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
277
+ };
278
+ this.directTunnels.set(tunnelId, info);
279
+ this.writeRegistryEntry(tunnelId, `dedicated:${tunnelId}`, tunnelUrl, -1);
280
+ this.armIdleTimer(info);
281
+ trace.emit('acquireDedicatedTunnel.success', { tunnelId, tunnelUrl });
282
+ logger.info(`Dedicated tunnel created: ${tunnelUrl} -> localhost:${port}`);
283
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created-dedicated' });
284
+ return { url: generateTunnelUrl(url, tunnelId), tunnelId };
166
285
  }
167
- if (!authToken) {
168
- throw new Error('Auth token required to create tunnel for localhost URL');
286
+ catch (error) {
287
+ const msg = error instanceof Error ? error.message : 'Unknown error';
288
+ trace.emit('acquireDedicatedTunnel.fail', { message: msg.slice(0, 200) });
289
+ logger.warn(`Tunnel lifecycle trace (fail path):\n${trace.format()}`);
290
+ if (msg.includes('authtoken')) {
291
+ throw new Error(`Failed to create tunnel: invalid auth token. ${msg}`);
292
+ }
293
+ throw new Error(`Failed to create tunnel: ${msg}`);
169
294
  }
170
- const tunnelId = specificTunnelId || uuidv4();
171
- return this.processPerPort(url, port, authToken, tunnelId, keyId, revokeKey);
172
295
  }
296
+ // ── Public API — lifecycle / teardown ──────────────────────────────────────
173
297
  /**
174
- * Return an active tunnel for the given local port, or undefined.
175
- * For borrowed tunnels, evicts the entry if the owning process has died.
298
+ * Evict a tunnel that a health probe PROVED dead (e.g. ERR_NGROK_3200) —
299
+ * simplifies to a plain delegate now that every tunnel is created (never
300
+ * borrowed) by this process: there is no shared-registry adoption record
301
+ * to also evict (bead k34o's second half retired along with borrowing,
302
+ * §4). Drops the `port` parameter — eviction is no longer port-scoped.
176
303
  */
177
- getTunnelForPort(port) {
178
- const existing = this.findTunnelByPort(port);
179
- if (!existing)
180
- return undefined;
181
- if (!existing.isOwned) {
182
- // Verify the owning process is still alive AND the entry is fresh
183
- // (lastAccessedAt within registryFreshnessTtlMs — defends against
184
- // PID-reuse per bead 3th).
185
- const entry = this.reg.read()[String(port)];
186
- if (!entry || !this.isEntryUsable(entry)) {
187
- this.activeTunnels.delete(existing.tunnelId);
188
- const reason = !entry
189
- ? 'no registry entry'
190
- : !this.reg.isPidAlive(entry.ownerPid)
191
- ? `owner PID ${entry.ownerPid} dead`
192
- : `entry stale (last accessed ${Math.round((Date.now() - entry.lastAccessedAt) / 1000)}s ago)`;
193
- logger.info(`Evicted stale borrowed tunnel ${existing.tunnelId} (${reason})`);
194
- return undefined;
195
- }
196
- }
197
- return existing;
304
+ async markTunnelDead(tunnelId) {
305
+ await this.stopTunnel(tunnelId);
198
306
  }
199
307
  /**
200
- * Evict a tunnel that a health probe PROVED dead (e.g. ERR_NGROK_3200) so no
201
- * session borrows the corpse again (bead k34o).
202
- *
203
- * OWNED: delegate to stopTunnel it already removes the registry entry,
204
- * disconnects, revokes the key, and resets the agent. (Self-heals after one
205
- * failure, which already worked.)
206
- *
207
- * BORROWED (the actual gap): stopTunnel only drops our local ref and leaves the
208
- * SHARED registry entry, so every other session keeps re-borrowing the dead
209
- * tunnel for up to the 30-min freshness TTL. Here we also evict the shared
210
- * entry — guarded by tunnelId so a replacement another session just provisioned
211
- * for the same port is never removed. Best-effort, never throws.
308
+ * `stopTunnel()`'s ordering is a load-bearing contract, not an
309
+ * implementation detail (§2.3): map removal is UNCONDITIONAL and happens
310
+ * before any cleanup I/O, so a downstream cleanup failure (ngrok
311
+ * disconnect, `caddy.stop()`, key revoke) can never leave a
312
+ * live-looking-but-actually-dead `TunnelInfo` behind for the next call to
313
+ * find — the exact failure mode the `onPortChanged`-triggered eviction
314
+ * path (see createSessionTunnel) exists to avoid re-creating. Because this
315
+ * never throws (failures are caught inside `Promise.allSettled`, not
316
+ * propagated), callers including a queued lock waiter promoted against a
317
+ * Caddy instance mid-teardown never need a defensive `.catch()` of their
318
+ * own.
212
319
  */
213
- async markTunnelDead(port, tunnelId) {
214
- const local = this.activeTunnels.get(tunnelId);
215
- if (local?.isOwned) {
216
- await this.stopTunnel(tunnelId).catch(() => { });
320
+ async stopTunnel(tunnelId) {
321
+ const info = this.activeTunnels.get(tunnelId);
322
+ if (info) {
323
+ await this.stopSessionTunnel(info);
217
324
  return;
218
325
  }
219
- // Borrowed or no longer local — drop any local ref, then evict the shared entry.
220
- if (local?.autoShutoffTimer)
221
- clearTimeout(local.autoShutoffTimer);
222
- this.activeTunnels.delete(tunnelId);
223
- try {
224
- const registry = this.reg.read();
225
- if (registry[String(port)]?.tunnelId === tunnelId) {
226
- delete registry[String(port)];
227
- this.reg.write(registry);
228
- logger.info(`Evicted dead borrowed tunnel ${tunnelId} for port ${port} from shared registry`);
229
- }
230
- }
231
- catch {
232
- // best-effort — a failed eviction just means the next call re-probes and re-evicts
326
+ const direct = this.directTunnels.get(tunnelId);
327
+ if (direct) {
328
+ await this.stopDirectTunnel(direct);
329
+ return;
233
330
  }
331
+ logger.warn(`Tunnel ${tunnelId} not found for cleanup`);
234
332
  }
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
- */
333
+ async stopAllTunnels() {
334
+ const ids = [...this.activeTunnels.keys(), ...this.directTunnels.keys()];
335
+ await Promise.all(ids.map((id) => this.stopTunnel(id).catch((err) => logger.error(`Failed to stop tunnel ${id}:`, err))));
336
+ logger.info(`Stopped ${ids.length} tunnel(s)`);
337
+ }
338
+ /** Refresh a tunnel's idle timer (and its diagnostic registry row) —
339
+ * called on every reuse so an in-use tunnel never auto-shuts-off. */
255
340
  touchTunnel(tunnelId) {
256
- const tunnelInfo = this.activeTunnels.get(tunnelId);
257
- if (!tunnelInfo)
341
+ const info = this.activeTunnels.get(tunnelId);
342
+ if (info) {
343
+ this.touchRegistryEntry(tunnelId);
344
+ this.armIdleTimer(info);
258
345
  return;
259
- try {
260
- const registry = this.reg.read();
261
- const key = String(tunnelInfo.port);
262
- const entry = registry[key];
263
- if (entry?.tunnelId === tunnelInfo.tunnelId) {
264
- entry.lastAccessedAt = Date.now();
265
- this.reg.write(registry);
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
- }
273
346
  }
274
- catch {
275
- // best-effort
347
+ const direct = this.directTunnels.get(tunnelId);
348
+ if (direct) {
349
+ this.touchRegistryEntry(tunnelId);
350
+ this.armIdleTimer(direct);
276
351
  }
277
- this.resetTunnelTimer(tunnelInfo);
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
352
  }
290
353
  touchTunnelByUrl(url) {
291
354
  const tunnelId = this.extractTunnelId(url);
@@ -300,70 +363,6 @@ class TunnelManager {
300
363
  const match = url.match(/https?:\/\/([^.]+)\.ngrok\.debugg\.ai/);
301
364
  return match ? match[1] : null;
302
365
  }
303
- getTunnelInfo(tunnelId) {
304
- return this.activeTunnels.get(tunnelId);
305
- }
306
- getActiveTunnels() {
307
- return Array.from(this.activeTunnels.values());
308
- }
309
- async stopTunnel(tunnelId) {
310
- const tunnelInfo = this.activeTunnels.get(tunnelId);
311
- if (!tunnelInfo) {
312
- logger.warn(`Tunnel ${tunnelId} not found for cleanup`);
313
- return;
314
- }
315
- if (tunnelInfo.autoShutoffTimer) {
316
- clearTimeout(tunnelInfo.autoShutoffTimer);
317
- }
318
- this.activeTunnels.delete(tunnelId);
319
- if (!tunnelInfo.isOwned) {
320
- // Borrowed — just drop the local reference; owner manages the real tunnel
321
- logger.info(`Released borrowed tunnel reference: ${tunnelInfo.publicUrl}`);
322
- Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'released', isOwned: false });
323
- return;
324
- }
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.
331
- try {
332
- const registry = this.reg.read();
333
- const key = String(tunnelInfo.port);
334
- if (registry[key]?.tunnelId === tunnelInfo.tunnelId) {
335
- delete registry[key];
336
- this.reg.write(registry);
337
- }
338
- }
339
- catch {
340
- // best-effort
341
- }
342
- try {
343
- const ngrok = await getNgrok();
344
- await ngrok.disconnect(tunnelInfo.tunnelUrl);
345
- logger.info(`Cleaned up tunnel: ${tunnelInfo.publicUrl}`);
346
- }
347
- catch (error) {
348
- logger.warn(`ngrok.disconnect failed for tunnel ${tunnelId} (already cleaned up):`, error);
349
- }
350
- // If no owned tunnels remain, the ngrok agent process may have exited.
351
- // Reset module + init state so the next connect() bootstraps a fresh agent.
352
- const hasOwnedTunnels = Array.from(this.activeTunnels.values()).some(t => t.isOwned);
353
- if (!hasOwnedTunnels) {
354
- logger.info('No owned tunnels remain — resetting ngrok module for fresh init on next request');
355
- resetNgrokModule();
356
- this.initialized = false;
357
- }
358
- if (tunnelInfo.revokeKey) {
359
- tunnelInfo.revokeKey().catch((err) => logger.warn(`Failed to revoke key for tunnel ${tunnelId}:`, err));
360
- }
361
- }
362
- async stopAllTunnels() {
363
- const ids = Array.from(this.activeTunnels.keys());
364
- await Promise.all(ids.map((id) => this.stopTunnel(id).catch((err) => logger.error(`Failed to stop tunnel ${id}:`, err))));
365
- logger.info(`Stopped ${ids.length} tunnel(s)`);
366
- }
367
366
  getTunnelStatus(tunnelId) {
368
367
  const tunnel = this.activeTunnels.get(tunnelId);
369
368
  if (!tunnel)
@@ -385,363 +384,301 @@ class TunnelManager {
385
384
  }
386
385
  return statuses;
387
386
  }
388
- // ── Per-port tunnel ─────────────────────────────────────────────────────────
389
- async processPerPort(url, port, authToken, tunnelId, keyId, revokeKey) {
390
- // 1. Check local in-process map (handles owned + borrowed with liveness check)
391
- const existing = this.getTunnelForPort(port);
392
- if (existing) {
393
- // Bead zmc9: retarget to THIS caller's path; publicUrl carries the creator's.
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);
401
- logger.info(`Reusing existing tunnel for port ${port}: ${url_}`);
402
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'reused' });
403
- return { url: url_, tunnelId: existing.tunnelId, isLocalhost: true };
404
- }
405
- // 2. Deduplicate concurrent creation requests for the same port
406
- const pending = this.pendingTunnels.get(port);
407
- if (pending) {
408
- // Bead 7qh Finding 2: our minted tunnelKey/keyId are now redundant — the
409
- // in-flight call owns the tunnel for this port. Revoke our key up-front
410
- // so it doesn't orphan on the backend. Failures are swallowed: we can't
411
- // let cleanup break the join.
412
- if (revokeKey) {
413
- revokeKey().catch((err) => logger.warn(`Failed to revoke redundant key while joining pending tunnel for port ${port}:`, err));
414
- }
415
- const info = await pending;
416
- // Bead zmc9: retarget to THIS caller's path, not the in-flight creator's.
417
- return { url: retargetTunnelUrl(info.tunnelUrl, url), tunnelId: info.tunnelId, isLocalhost: true };
418
- }
419
- // 3. Check cross-process registry — another MCP instance may own a tunnel.
420
- // Borrow only if the entry is fresh (PID alive AND touched within
421
- // registryFreshnessTtlMs — defends against PID-reuse, bead 3th).
422
- const registry = this.reg.read();
423
- const regEntry = registry[String(port)];
424
- if (regEntry && this.isEntryUsable(regEntry)) {
425
- const borrowed = this.borrowRegistryEntry(regEntry, url, registry);
426
- // Bead zmc9: retarget to THIS caller's path; regEntry.publicUrl carries the
427
- // owning PID's creating-call path — replaying it is the cross-session poison.
428
- return { url: retargetTunnelUrl(borrowed.tunnelUrl, url), tunnelId: borrowed.tunnelId, isLocalhost: true };
429
- }
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);
435
- this.pendingTunnels.set(port, creationPromise);
436
- let tunnelInfo;
437
- try {
438
- tunnelInfo = await creationPromise;
439
- }
440
- finally {
441
- this.pendingTunnels.delete(port);
442
- }
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;
387
+ // ── Session tunnel creation ─────────────────────────────────────────────────
388
+ async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
389
+ await this.ensureInitialized();
390
+ const tunnelId = specificTunnelId ?? uuidv4();
391
+ const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`; // UNCHANGED scheme, minted ONCE per session now
392
+ const caddy = this.caddyFactory(); // NEW instance per session key never a process-wide singleton
393
+ const { localOrigin, adminPort } = await caddy.ensureStarted();
394
+ logger.info(`Creating session tunnel (domain: ${tunnelDomain}, session: ${sessionKey})`);
395
+ // Bead 42g: fault injection + trace. Only active when NODE_ENV !== 'production'
396
+ // AND DEBUGG_TUNNEL_FAULT_MODE env var is set. Zero overhead when disabled.
397
+ const faultMode = getFaultModeFromEnv();
398
+ const faults = new FaultInjector(faultMode);
399
+ const trace = new TunnelTrace();
400
+ trace.emit('createSessionTunnel.start', { tunnelId, sessionKey, hasFaultMode: !!faultMode });
469
401
  try {
470
- this.reg.write(registry);
471
- }
472
- catch {
473
- // best-effort
402
+ // ngrok's own dial target is now always plain loopback HTTP to Caddy —
403
+ // no HTTPS/Docker complexity on this leg at all (Caddy runs in the same
404
+ // host/container as the MCP server). That matrix moved entirely into
405
+ // caddyProxy.setUpstream(), invoked per-dispatch, not per-tunnel-creation.
406
+ const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults);
407
+ const now = Date.now();
408
+ const portLock = new PortLock((t) => caddy.setUpstream(t));
409
+ caddy.onPortChanged(() => {
410
+ // A crash-triggered respawn landed on a DIFFERENT local proxy port
411
+ // (sticky-port reclaim failed). The existing ngrok tunnel is now
412
+ // dialing a dead port — nothing Caddy-internal can fix this; the
413
+ // whole session tunnel must be torn down and recreated on the next
414
+ // call. stopTunnel() never throws (its unconditional-removal
415
+ // contract, above), so this needs no defensive .catch() of its own.
416
+ logger.error(`Caddy proxy port changed under session ${sessionKey} — evicting tunnel ${tunnelId}`);
417
+ Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, { tunnelId, sessionKey });
418
+ void this.stopTunnel(tunnelId);
419
+ });
420
+ const info = {
421
+ tunnelId, sessionKey, tunnelUrl, createdAt: now, lastAccessedAt: now,
422
+ keyId, revokeKey, caddy, portLock,
423
+ };
424
+ this.activeTunnels.set(tunnelId, info);
425
+ this.sessionTunnels.set(sessionKey, tunnelId);
426
+ this.writeRegistryEntry(tunnelId, sessionKey, tunnelUrl, adminPort);
427
+ this.armIdleTimer(info);
428
+ trace.emit('createSessionTunnel.success', { tunnelId, tunnelUrl });
429
+ logger.info(`Session tunnel created: ${tunnelUrl} (session ${sessionKey})`);
430
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created' });
431
+ return info;
474
432
  }
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);
433
+ catch (error) {
434
+ const msg = error instanceof Error ? error.message : 'Unknown error';
435
+ trace.emit('createSessionTunnel.fail', { message: msg.slice(0, 200) });
436
+ // Bead 42g: when the trace captured meaningful timing info, log it at
437
+ // WARN so operators can post-mortem. Keeping it out of the thrown error
438
+ // text so we don't leak internals to users.
439
+ logger.warn(`Tunnel lifecycle trace (fail path):\n${trace.format()}`);
440
+ // Never leak a Caddy process on connect failure nothing else will
441
+ // ever stop() an instance that never made it into activeTunnels.
442
+ await caddy.stop().catch(() => { });
443
+ if (msg.includes('authtoken')) {
444
+ throw new Error(`Failed to create tunnel: invalid auth token. ${msg}`);
445
+ }
446
+ throw new Error(`Failed to create tunnel: ${msg}`);
501
447
  }
502
- return this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
503
448
  }
449
+ // ── Shared connect-retry ladder (KEPT byte-for-byte — bead ixh/pqgj/42g/fhg) ─
504
450
  /**
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.
451
+ * Bead ixh: 3-attempt retry for ngrok.connect transient failures.
452
+ * - Attempt 1: fresh connect
453
+ * - Attempt 2: after 500ms backoff, reset the ngrok agent module and retry
454
+ * (existing "agent died" recovery path)
455
+ * - Attempt 3: after 1500ms backoff, retry with the already-reset agent
456
+ * Auth-token errors short-circuit at any attempt — no point looping.
509
457
  *
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.
458
+ * Parameterized by `localAddr` rather than computing it internally: the
459
+ * session-tunnel path (createSessionTunnel) always dials Caddy's fixed
460
+ * local origin; the dedicated-tunnel path (acquireDedicatedTunnel) dials
461
+ * the app directly via the isHttpsLocal/inDocker matrix. Both need the
462
+ * IDENTICAL retry/backoff/fault-injection/agent-prewarm behavior, so it
463
+ * lives here once.
519
464
  */
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}`);
465
+ async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults) {
466
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
467
+ const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
468
+ const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
469
+ const connectOpts = {
470
+ proto: 'http',
471
+ addr: localAddr,
472
+ hostname: tunnelDomain,
473
+ authtoken: authToken,
474
+ };
475
+ // Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
476
+ // agent's ~293ms not-ready window (which poisons the tunnel name via
477
+ // ngrok's own name-reusing internal retry and surfaces as
478
+ // "invalid tunnel configuration").
479
+ await this.ensureAgentSession(authToken, trace);
480
+ let lastError;
481
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
482
+ trace.emit('connect.attempt.start', { attempt });
483
+ // Optional fault-injected delay before each attempt.
484
+ const delayMs = faults.delayMsForAttempt();
485
+ if (delayMs > 0) {
486
+ trace.emit('connect.fault.delay', { attempt, delayMs });
487
+ await sleep(delayMs);
488
+ }
489
+ try {
490
+ const ngrok = await getNgrok();
491
+ // Fault-inject a synthetic failure BEFORE ngrok.connect runs so we
492
+ // can simulate connect-layer failures without hitting the real API.
493
+ if (faults.shouldFailConnect()) {
494
+ trace.emit('connect.fault.inject', { attempt, mode: 'fail-connect-N' });
495
+ throw new Error(`[fault-inject] synthetic connect failure (attempt ${attempt})`);
547
496
  }
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.');
497
+ const url = faults.shouldReturnEmptyUrl() ? '' : await ngrok.connect(connectOpts);
498
+ if (!url) {
499
+ trace.emit('connect.attempt.empty-url', { attempt });
500
+ throw new Error(`ngrok.connect() returned empty URL (attempt ${attempt})`);
552
501
  }
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;
560
- }
561
- findTunnelByPort(port) {
562
- for (const tunnel of this.activeTunnels.values()) {
563
- if (tunnel.port === port)
564
- return tunnel;
565
- }
566
- return undefined;
567
- }
568
- async createTunnel(originalUrl, port, tunnelId, authToken, keyId, revokeKey) {
569
- await this.ensureInitialized();
570
- const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`;
571
- logger.info(`Creating tunnel for localhost:${port} (domain: ${tunnelDomain})`);
572
- const isHttpsLocal = originalUrl.startsWith('https:');
573
- const inDocker = process.env.DOCKER_CONTAINER === 'true';
574
- const dockerHost = 'host.docker.internal';
575
- // Bead fhg: force IPv4 loopback when running against localhost. ngrok's
576
- // default resolution of a bare port or "localhost" can pick IPv6 [::1]
577
- // first on macOS/modern OSes, but most dev servers (Next.js, Vite) bind
578
- // only to 127.0.0.1 — resulting in ngrok connect:refused + ERR_NGROK_8012
579
- // on the browser side with no actionable error back to the MCP caller.
580
- let localAddr;
581
- if (isHttpsLocal) {
582
- localAddr = inDocker ? `https://${dockerHost}:${port}` : `https://localhost:${port}`;
583
- }
584
- else {
585
- localAddr = inDocker ? `${dockerHost}:${port}` : `127.0.0.1:${port}`;
586
- }
587
- // Bead ixh: 3-attempt retry for ngrok.connect transient failures. Previously
588
- // only retried ONCE (with agent reset), which is insufficient against real
589
- // ngrok / network flakes (client-reported incident 2026-04-24).
590
- // - Attempt 1: fresh connect
591
- // - Attempt 2: after 500ms backoff, reset the ngrok agent module and retry
592
- // (existing "agent died" recovery path)
593
- // - Attempt 3: after 1500ms backoff, retry with the already-reset agent
594
- // Auth-token errors short-circuit at any attempt — no point looping.
595
- // Bead 42g: fault injection + trace. Only active when NODE_ENV !== 'production'
596
- // AND DEBUGG_TUNNEL_FAULT_MODE env var is set. Zero overhead when disabled.
597
- const faultMode = getFaultModeFromEnv();
598
- const faults = new FaultInjector(faultMode);
599
- const trace = new TunnelTrace();
600
- trace.emit('createTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
601
- const connectWithRetry = async () => {
602
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
603
- const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
604
- const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
605
- const connectOpts = {
606
- proto: 'http',
607
- addr: localAddr,
608
- hostname: tunnelDomain,
609
- authtoken: authToken,
610
- };
611
- // Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
612
- // agent's ~293ms not-ready window (which poisons the tunnel name via
613
- // ngrok's own name-reusing internal retry and surfaces as
614
- // "invalid tunnel configuration").
615
- await this.ensureAgentSession(authToken, trace);
616
- let lastError;
617
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
618
- trace.emit('connect.attempt.start', { attempt });
619
- // Optional fault-injected delay before each attempt.
620
- const delayMs = faults.delayMsForAttempt();
621
- if (delayMs > 0) {
622
- trace.emit('connect.fault.delay', { attempt, delayMs });
623
- await sleep(delayMs);
624
- }
625
- try {
626
- const ngrok = await getNgrok();
627
- // Fault-inject a synthetic failure BEFORE ngrok.connect runs so we
628
- // can simulate connect-layer failures without hitting the real API.
629
- if (faults.shouldFailConnect()) {
630
- trace.emit('connect.fault.inject', { attempt, mode: 'fail-connect-N' });
631
- throw new Error(`[fault-inject] synthetic connect failure (attempt ${attempt})`);
632
- }
633
- const url = faults.shouldReturnEmptyUrl() ? '' : await ngrok.connect(connectOpts);
634
- if (!url) {
635
- trace.emit('connect.attempt.empty-url', { attempt });
636
- throw new Error(`ngrok.connect() returned empty URL (attempt ${attempt})`);
637
- }
638
- trace.emit('connect.attempt.success', { attempt });
639
- if (attempt > 1) {
640
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
641
- attempt,
642
- outcome: 'success',
643
- stage: 'ngrok_connect',
644
- });
645
- }
646
- return url;
502
+ trace.emit('connect.attempt.success', { attempt });
503
+ if (attempt > 1) {
504
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
505
+ attempt,
506
+ outcome: 'success',
507
+ stage: 'ngrok_connect',
508
+ });
647
509
  }
648
- catch (err) {
649
- lastError = err;
650
- const msg = err instanceof Error ? err.message : String(err);
651
- trace.emit('connect.attempt.fail', { attempt, message: msg.slice(0, 200) });
652
- // Auth-class errors are non-retryable retrying with the same token
653
- // would loop. Let the outer catch classify the message.
654
- if (/authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
655
- trace.emit('connect.giving-up', { reason: 'auth-error' });
656
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
657
- attempt,
658
- outcome: 'giving-up',
659
- stage: 'ngrok_connect',
660
- reason: 'auth-error',
661
- });
662
- throw err;
663
- }
664
- const isLastAttempt = attempt >= MAX_ATTEMPTS;
510
+ return url;
511
+ }
512
+ catch (err) {
513
+ lastError = err;
514
+ const msg = err instanceof Error ? err.message : String(err);
515
+ trace.emit('connect.attempt.fail', { attempt, message: msg.slice(0, 200) });
516
+ // Auth-class errors are non-retryable — retrying with the same token
517
+ // would loop. Let the outer catch classify the message.
518
+ if (/authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
519
+ trace.emit('connect.giving-up', { reason: 'auth-error' });
665
520
  Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
666
521
  attempt,
667
- outcome: isLastAttempt ? 'giving-up' : 'will-retry',
522
+ outcome: 'giving-up',
668
523
  stage: 'ngrok_connect',
524
+ reason: 'auth-error',
669
525
  });
670
- if (isLastAttempt) {
671
- trace.emit('connect.giving-up', { reason: 'max-attempts' });
672
- throw err;
673
- }
674
- // Between attempt 1→2, do an agent-reset (covers the "agent died"
675
- // failure mode that used to be the only retried case). Between 2→3,
676
- // just wait — the reset already happened.
677
- if (attempt === 1) {
678
- logger.warn(`ngrok.connect() failed (attempt 1/${MAX_ATTEMPTS}), resetting agent: ${msg}`);
679
- trace.emit('agent.reset');
680
- resetNgrokModule();
681
- this.initialized = false;
682
- await this.ensureInitialized();
683
- }
684
- else {
685
- logger.warn(`ngrok.connect() failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
686
- }
687
- const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
688
- trace.emit('connect.backoff', { attempt, backoffMs });
689
- await sleep(backoffMs);
526
+ throw err;
527
+ }
528
+ const isLastAttempt = attempt >= MAX_ATTEMPTS;
529
+ Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
530
+ attempt,
531
+ outcome: isLastAttempt ? 'giving-up' : 'will-retry',
532
+ stage: 'ngrok_connect',
533
+ });
534
+ if (isLastAttempt) {
535
+ trace.emit('connect.giving-up', { reason: 'max-attempts' });
536
+ throw err;
690
537
  }
538
+ // Between attempt 1→2, do an agent-reset (covers the "agent died"
539
+ // failure mode that used to be the only retried case). Between 2→3,
540
+ // just wait — the reset already happened.
541
+ if (attempt === 1) {
542
+ logger.warn(`ngrok.connect() failed (attempt 1/${MAX_ATTEMPTS}), resetting agent: ${msg}`);
543
+ trace.emit('agent.reset');
544
+ resetNgrokModule();
545
+ this.initialized = false;
546
+ await this.ensureInitialized();
547
+ }
548
+ else {
549
+ logger.warn(`ngrok.connect() failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
550
+ }
551
+ const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
552
+ trace.emit('connect.backoff', { attempt, backoffMs });
553
+ await sleep(backoffMs);
691
554
  }
692
- // Unreachable (loop always returns or throws), but satisfy TS
693
- throw lastError ?? new Error('connectWithRetry: exhausted attempts without error');
694
- };
555
+ }
556
+ // Unreachable (loop always returns or throws), but satisfy TS
557
+ throw lastError ?? new Error('connectWithRetry: exhausted attempts without error');
558
+ }
559
+ // ── Teardown internals ──────────────────────────────────────────────────────
560
+ async stopSessionTunnel(info) {
561
+ // Unconditional, synchronous, BEFORE any cleanup I/O. A partial failure
562
+ // below can never leave stale-but-discoverable state — the next call for
563
+ // this session key always sees a clean miss and rebuilds from scratch.
564
+ this.activeTunnels.delete(info.tunnelId);
565
+ if (this.sessionTunnels.get(info.sessionKey) === info.tunnelId) {
566
+ this.sessionTunnels.delete(info.sessionKey);
567
+ }
568
+ if (info.autoShutoffTimer)
569
+ clearTimeout(info.autoShutoffTimer);
570
+ this.removeRegistryEntry(info.tunnelId);
571
+ const results = await Promise.allSettled([
572
+ this.disconnectNgrok(info.tunnelUrl),
573
+ info.caddy.stop(),
574
+ info.revokeKey ? info.revokeKey() : Promise.resolve(),
575
+ ]);
576
+ results.forEach((r, i) => {
577
+ if (r.status === 'rejected') {
578
+ // Logged and telemetered, never rethrown and never blocks/reverts the
579
+ // removal above — state is already gone by the time this runs.
580
+ logger.warn(`stopTunnel(${info.tunnelId}) cleanup step ${i} failed (state already removed): ${r.reason}`);
581
+ Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
582
+ }
583
+ });
584
+ this.maybeResetNgrokModule();
585
+ logger.info(`Cleaned up session tunnel: ${info.tunnelUrl}`);
586
+ Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: info.tunnelId, reason: 'stopped' });
587
+ }
588
+ async stopDirectTunnel(info) {
589
+ // Same unconditional-removal contract as stopSessionTunnel, above.
590
+ this.directTunnels.delete(info.tunnelId);
591
+ if (info.autoShutoffTimer)
592
+ clearTimeout(info.autoShutoffTimer);
593
+ this.removeRegistryEntry(info.tunnelId);
594
+ const results = await Promise.allSettled([
595
+ this.disconnectNgrok(info.tunnelUrl),
596
+ info.revokeKey ? info.revokeKey() : Promise.resolve(),
597
+ ]);
598
+ results.forEach((r, i) => {
599
+ if (r.status === 'rejected') {
600
+ logger.warn(`stopTunnel(${info.tunnelId}) dedicated-tunnel cleanup step ${i} failed (state already removed): ${r.reason}`);
601
+ Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
602
+ }
603
+ });
604
+ this.maybeResetNgrokModule();
605
+ logger.info(`Cleaned up dedicated tunnel: ${info.tunnelUrl}`);
606
+ Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: info.tunnelId, reason: 'stopped' });
607
+ }
608
+ async disconnectNgrok(tunnelUrl) {
609
+ const ngrok = await getNgrok();
610
+ await ngrok.disconnect(tunnelUrl);
611
+ }
612
+ /** If no tunnels of any kind remain, the ngrok agent process may have
613
+ * exited. Reset module + init state so the next connect() bootstraps a
614
+ * fresh agent. */
615
+ maybeResetNgrokModule() {
616
+ if (this.activeTunnels.size === 0 && this.directTunnels.size === 0) {
617
+ logger.info('No tunnels remain — resetting ngrok module for fresh init on next request');
618
+ resetNgrokModule();
619
+ this.initialized = false;
620
+ }
621
+ }
622
+ // ── Idle timer (KEPT — minus the retired cross-process extension branch) ───
623
+ /**
624
+ * Bead y7x6/lc62's cross-process "another process touched the registry
625
+ * entry, so extend instead of shutting down" branch is retired along with
626
+ * borrowing (§4/§5.1): every tunnel now has exactly one process that could
627
+ * ever be using it, so there is nothing else to check for before shutting
628
+ * an idle one down. The core mechanism — arm a timer, clear+rearm on
629
+ * touch, stop on expiry — is otherwise unchanged.
630
+ */
631
+ armIdleTimer(entry) {
632
+ if (entry.autoShutoffTimer)
633
+ clearTimeout(entry.autoShutoffTimer);
634
+ entry.lastAccessedAt = Date.now();
635
+ entry.autoShutoffTimer = setTimeout(async () => {
636
+ logger.info(`Auto-shutting down tunnel ${entry.tunnelId} after inactivity`);
637
+ Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: entry.tunnelId, reason: 'auto-shutoff' });
638
+ await this.stopTunnel(entry.tunnelId).catch((err) => logger.error(`Failed to auto-shutdown tunnel ${entry.tunnelId}:`, err));
639
+ }, this.idleTimeoutMs);
640
+ }
641
+ // ── Registry writes (§4: write-mostly observability, best-effort) ──────────
642
+ writeRegistryEntry(tunnelId, sessionKey, tunnelUrl, caddyAdminPort) {
695
643
  try {
696
- const tunnelUrl = await connectWithRetry();
697
- const publicUrl = generateTunnelUrl(originalUrl, tunnelId);
698
- const now = Date.now();
699
- const tunnelInfo = {
644
+ const registry = this.reg.read();
645
+ registry[tunnelId] = {
700
646
  tunnelId,
701
- originalUrl,
647
+ sessionKey,
648
+ publicUrl: tunnelUrl,
702
649
  tunnelUrl,
703
- publicUrl,
704
- port,
705
- createdAt: now,
706
- lastAccessedAt: now,
707
- isOwned: true,
708
- keyId,
709
- revokeKey,
650
+ caddyAdminPort,
651
+ ownerPid: process.pid,
652
+ lastAccessedAt: Date.now(),
710
653
  };
711
- this.activeTunnels.set(tunnelId, tunnelInfo);
712
- // Register in shared cross-process registry
713
- try {
714
- const registry = this.reg.read();
715
- registry[String(port)] = {
716
- tunnelId,
717
- publicUrl,
718
- tunnelUrl,
719
- port,
720
- ownerPid: process.pid,
721
- lastAccessedAt: now,
722
- };
654
+ this.reg.write(registry);
655
+ }
656
+ catch {
657
+ // best-effort nothing reads this for correctness anymore
658
+ }
659
+ }
660
+ touchRegistryEntry(tunnelId) {
661
+ try {
662
+ const registry = this.reg.read();
663
+ if (registry[tunnelId]) {
664
+ registry[tunnelId].lastAccessedAt = Date.now();
723
665
  this.reg.write(registry);
724
666
  }
725
- catch {
726
- // best-effort
727
- }
728
- this.resetTunnelTimer(tunnelInfo);
729
- trace.emit('createTunnel.success', { tunnelId, publicUrl });
730
- logger.info(`Tunnel created: ${publicUrl} → localhost:${port}`);
731
- Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'created' });
732
- return tunnelInfo;
733
667
  }
734
- catch (error) {
735
- const msg = error instanceof Error ? error.message : 'Unknown error';
736
- trace.emit('createTunnel.fail', { message: msg.slice(0, 200) });
737
- // Bead 42g: when the trace captured meaningful timing info, log it at
738
- // WARN so operators can post-mortem. Keeping it out of the thrown error
739
- // text so we don't leak internals to users.
740
- logger.warn(`Tunnel lifecycle trace (fail path):\n${trace.format()}`);
741
- if (msg.includes('authtoken')) {
742
- throw new Error(`Failed to create tunnel: invalid auth token. ${msg}`);
668
+ catch {
669
+ // best-effort
670
+ }
671
+ }
672
+ removeRegistryEntry(tunnelId) {
673
+ try {
674
+ const registry = this.reg.read();
675
+ if (registry[tunnelId]) {
676
+ delete registry[tunnelId];
677
+ this.reg.write(registry);
743
678
  }
744
- throw new Error(`Failed to create tunnel: ${msg}`);
679
+ }
680
+ catch {
681
+ // best-effort
745
682
  }
746
683
  }
747
684
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -812,42 +749,6 @@ class TunnelManager {
812
749
  this.initialized = true;
813
750
  }
814
751
  }
815
- resetTunnelTimer(tunnelInfo) {
816
- if (tunnelInfo.autoShutoffTimer)
817
- clearTimeout(tunnelInfo.autoShutoffTimer);
818
- tunnelInfo.lastAccessedAt = Date.now();
819
- tunnelInfo.autoShutoffTimer = setTimeout(async () => {
820
- // For owned tunnels: if another process recently touched the registry entry,
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.
831
- if (tunnelInfo.isOwned) {
832
- try {
833
- const entry = this.reg.read()[String(tunnelInfo.port)];
834
- if (entry &&
835
- entry.tunnelId === tunnelInfo.tunnelId &&
836
- Date.now() - entry.lastAccessedAt < this.idleTimeoutMs) {
837
- logger.info(`Tunnel ${tunnelInfo.tunnelId} accessed by another process — extending lifetime`);
838
- this.resetTunnelTimer(tunnelInfo);
839
- return;
840
- }
841
- }
842
- catch {
843
- // best-effort; proceed with shutoff
844
- }
845
- }
846
- logger.info(`Auto-shutting down tunnel ${tunnelInfo.tunnelId} after inactivity`);
847
- Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'auto-shutoff', isOwned: tunnelInfo.isOwned });
848
- await this.stopTunnel(tunnelInfo.tunnelId).catch((err) => logger.error(`Failed to auto-shutdown tunnel ${tunnelInfo.tunnelId}:`, err));
849
- }, this.idleTimeoutMs);
850
- }
851
752
  }
852
753
  const tunnelManager = new TunnelManager();
853
754
  export { tunnelManager };