@debugg-ai/debugg-ai-mcp 3.7.2 → 3.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/handlers/probePageHandler.js +10 -1
- package/dist/handlers/testPageChangesHandler.js +10 -3
- package/dist/handlers/triggerCrawlHandler.js +10 -1
- package/dist/services/ngrok/tunnelManager.js +46 -5
- package/dist/utils/tunnelContext.js +8 -2
- package/dist/utils/urlParser.js +22 -0
- package/package.json +1 -1
|
@@ -149,7 +149,16 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
149
149
|
},
|
|
150
150
|
};
|
|
151
151
|
if (tunneled.tunnelId) {
|
|
152
|
-
|
|
152
|
+
const deadPort = extractLocalhostPort(tunneled.originalUrl);
|
|
153
|
+
if (health.ngrokErrorCode && typeof deadPort === 'number') {
|
|
154
|
+
// Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
|
|
155
|
+
// so the next call re-provisions instead of re-borrowing the corpse
|
|
156
|
+
// (bead k34o) — plain stopTunnel leaves a borrowed entry to re-poison.
|
|
157
|
+
tunnelManager.markTunnelDead(deadPort, tunneled.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunneled.tunnelId}: ${err}`));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
|
|
161
|
+
}
|
|
153
162
|
}
|
|
154
163
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
155
164
|
}
|
|
@@ -256,10 +256,17 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
256
256
|
};
|
|
257
257
|
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
258
258
|
// Tear down the broken tunnel so a subsequent call doesn't reuse it.
|
|
259
|
-
// stopTunnel handles both owned (ngrok disconnect + key revoke) and
|
|
260
|
-
// borrowed (just drops local ref) cases.
|
|
261
259
|
if (ctx.tunnelId) {
|
|
262
|
-
|
|
260
|
+
const deadPort = extractLocalhostPort(ctx.originalUrl);
|
|
261
|
+
if (health.ngrokErrorCode && typeof deadPort === 'number') {
|
|
262
|
+
// Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry
|
|
263
|
+
// so the next call re-provisions instead of re-borrowing the corpse.
|
|
264
|
+
// Plain stopTunnel leaves a borrowed entry to re-poison (bead k34o).
|
|
265
|
+
tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
|
|
269
|
+
}
|
|
263
270
|
}
|
|
264
271
|
// keyId is consumed by stopTunnel's revoke path; clear so the
|
|
265
272
|
// outer finally block doesn't double-revoke.
|
|
@@ -138,7 +138,16 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
138
138
|
};
|
|
139
139
|
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.ngrokErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
140
140
|
if (ctx.tunnelId) {
|
|
141
|
-
|
|
141
|
+
const deadPort = extractLocalhostPort(ctx.originalUrl);
|
|
142
|
+
if (health.ngrokErrorCode && typeof deadPort === 'number') {
|
|
143
|
+
// Tunnel-level dead (ERR_NGROK_*): evict the SHARED registry entry so
|
|
144
|
+
// the next call re-provisions instead of re-borrowing the corpse — plain
|
|
145
|
+
// stopTunnel leaves a borrowed entry to re-poison (bead k34o).
|
|
146
|
+
tunnelManager.markTunnelDead(deadPort, ctx.tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${ctx.tunnelId}: ${err}`));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
tunnelManager.stopTunnel(ctx.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${ctx.tunnelId}: ${err}`));
|
|
150
|
+
}
|
|
142
151
|
}
|
|
143
152
|
keyId = undefined;
|
|
144
153
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { Logger } from '../../utils/logger.js';
|
|
20
20
|
import { Telemetry, TelemetryEvents } from '../../utils/telemetry.js';
|
|
21
|
-
import { isLocalhostUrl, extractLocalhostPort, generateTunnelUrl } from '../../utils/urlParser.js';
|
|
21
|
+
import { isLocalhostUrl, extractLocalhostPort, generateTunnelUrl, retargetTunnelUrl } from '../../utils/urlParser.js';
|
|
22
22
|
import { v4 as uuidv4 } from 'uuid';
|
|
23
23
|
import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
|
|
24
24
|
import { getDefaultRegistry, } from './tunnelRegistry.js';
|
|
@@ -148,6 +148,42 @@ class TunnelManager {
|
|
|
148
148
|
}
|
|
149
149
|
return existing;
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Evict a tunnel that a health probe PROVED dead (e.g. ERR_NGROK_3200) so no
|
|
153
|
+
* session borrows the corpse again (bead k34o).
|
|
154
|
+
*
|
|
155
|
+
* OWNED: delegate to stopTunnel — it already removes the registry entry,
|
|
156
|
+
* disconnects, revokes the key, and resets the agent. (Self-heals after one
|
|
157
|
+
* failure, which already worked.)
|
|
158
|
+
*
|
|
159
|
+
* BORROWED (the actual gap): stopTunnel only drops our local ref and leaves the
|
|
160
|
+
* SHARED registry entry, so every other session keeps re-borrowing the dead
|
|
161
|
+
* tunnel for up to the 30-min freshness TTL. Here we also evict the shared
|
|
162
|
+
* entry — guarded by tunnelId so a replacement another session just provisioned
|
|
163
|
+
* for the same port is never removed. Best-effort, never throws.
|
|
164
|
+
*/
|
|
165
|
+
async markTunnelDead(port, tunnelId) {
|
|
166
|
+
const local = this.activeTunnels.get(tunnelId);
|
|
167
|
+
if (local?.isOwned) {
|
|
168
|
+
await this.stopTunnel(tunnelId).catch(() => { });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Borrowed or no longer local — drop any local ref, then evict the shared entry.
|
|
172
|
+
if (local?.autoShutoffTimer)
|
|
173
|
+
clearTimeout(local.autoShutoffTimer);
|
|
174
|
+
this.activeTunnels.delete(tunnelId);
|
|
175
|
+
try {
|
|
176
|
+
const registry = this.reg.read();
|
|
177
|
+
if (registry[String(port)]?.tunnelId === tunnelId) {
|
|
178
|
+
delete registry[String(port)];
|
|
179
|
+
this.reg.write(registry);
|
|
180
|
+
logger.info(`Evicted dead borrowed tunnel ${tunnelId} for port ${port} from shared registry`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// best-effort — a failed eviction just means the next call re-probes and re-evicts
|
|
185
|
+
}
|
|
186
|
+
}
|
|
151
187
|
touchTunnel(tunnelId) {
|
|
152
188
|
const tunnelInfo = this.activeTunnels.get(tunnelId);
|
|
153
189
|
if (!tunnelInfo)
|
|
@@ -262,9 +298,11 @@ class TunnelManager {
|
|
|
262
298
|
// 1. Check local in-process map (handles owned + borrowed with liveness check)
|
|
263
299
|
const existing = this.getTunnelForPort(port);
|
|
264
300
|
if (existing) {
|
|
265
|
-
|
|
301
|
+
// Bead zmc9: retarget to THIS caller's path; publicUrl carries the creator's.
|
|
302
|
+
const url_ = retargetTunnelUrl(existing.tunnelUrl, url);
|
|
303
|
+
logger.info(`Reusing existing tunnel for port ${port}: ${url_}`);
|
|
266
304
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'reused' });
|
|
267
|
-
return { url:
|
|
305
|
+
return { url: url_, tunnelId: existing.tunnelId, isLocalhost: true };
|
|
268
306
|
}
|
|
269
307
|
// 2. Deduplicate concurrent creation requests for the same port
|
|
270
308
|
const pending = this.pendingTunnels.get(port);
|
|
@@ -277,7 +315,8 @@ class TunnelManager {
|
|
|
277
315
|
revokeKey().catch((err) => logger.warn(`Failed to revoke redundant key while joining pending tunnel for port ${port}:`, err));
|
|
278
316
|
}
|
|
279
317
|
const info = await pending;
|
|
280
|
-
|
|
318
|
+
// Bead zmc9: retarget to THIS caller's path, not the in-flight creator's.
|
|
319
|
+
return { url: retargetTunnelUrl(info.tunnelUrl, url), tunnelId: info.tunnelId, isLocalhost: true };
|
|
281
320
|
}
|
|
282
321
|
// 3. Check cross-process registry — another MCP instance may own a tunnel.
|
|
283
322
|
// Borrow only if the entry is fresh (PID alive AND touched within
|
|
@@ -303,7 +342,9 @@ class TunnelManager {
|
|
|
303
342
|
this.reg.write(registry);
|
|
304
343
|
this.resetTunnelTimer(borrowed);
|
|
305
344
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'borrowed' });
|
|
306
|
-
|
|
345
|
+
// Bead zmc9: retarget to THIS caller's path; regEntry.publicUrl carries the
|
|
346
|
+
// 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 };
|
|
307
348
|
}
|
|
308
349
|
// 4. Create a new tunnel (this process becomes the owner)
|
|
309
350
|
const creationPromise = this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - sanitizing backend responses so callers only ever see the original URL
|
|
8
8
|
*/
|
|
9
9
|
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
10
|
-
import { isLocalhostUrl, replaceTunnelUrls, extractLocalhostPort } from './urlParser.js';
|
|
10
|
+
import { isLocalhostUrl, replaceTunnelUrls, extractLocalhostPort, retargetTunnelUrl } from './urlParser.js';
|
|
11
11
|
// ─── URL resolution ──────────────────────────────────────────────────────────
|
|
12
12
|
/**
|
|
13
13
|
* Resolve tool input to a concrete URL string.
|
|
@@ -43,7 +43,13 @@ export function findExistingTunnel(ctx) {
|
|
|
43
43
|
if (!existing)
|
|
44
44
|
return null;
|
|
45
45
|
tunnelManager.touchTunnel(existing.tunnelId);
|
|
46
|
-
|
|
46
|
+
// Bead zmc9: retarget to THIS caller's path — never replay existing.publicUrl,
|
|
47
|
+
// which carries the path of whichever call created the (port-keyed) tunnel.
|
|
48
|
+
return {
|
|
49
|
+
...ctx,
|
|
50
|
+
tunnelId: existing.tunnelId,
|
|
51
|
+
targetUrl: retargetTunnelUrl(existing.tunnelUrl, ctx.originalUrl),
|
|
52
|
+
};
|
|
47
53
|
}
|
|
48
54
|
/**
|
|
49
55
|
* Create (or reuse) a tunnel for a localhost URL.
|
package/dist/utils/urlParser.js
CHANGED
|
@@ -107,6 +107,28 @@ export function replaceTunnelUrls(value, localhostOrigin) {
|
|
|
107
107
|
}
|
|
108
108
|
return value;
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Re-point a REUSED tunnel at the current caller's path (bead zmc9).
|
|
112
|
+
*
|
|
113
|
+
* A tunnel is origin-scoped: its `tunnelUrl` (scheme://host from ngrok) forwards
|
|
114
|
+
* every path. The path is request-scoped. On reuse we must compose the reused
|
|
115
|
+
* tunnel's ORIGIN with THIS request's path/search/hash — never replay the
|
|
116
|
+
* path-bearing `publicUrl` baked in by whichever call created the tunnel.
|
|
117
|
+
*
|
|
118
|
+
* @param tunnelOrigin the reused tunnel's path-free origin (TunnelInfo.tunnelUrl)
|
|
119
|
+
* @param requestedUrl the localhost URL the current caller asked for
|
|
120
|
+
*/
|
|
121
|
+
export function retargetTunnelUrl(tunnelOrigin, requestedUrl) {
|
|
122
|
+
try {
|
|
123
|
+
const origin = new URL(tunnelOrigin);
|
|
124
|
+
const parsed = parseUrl(requestedUrl);
|
|
125
|
+
return `${origin.protocol}//${origin.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Never fabricate a path: fall back to the bare origin, not a foreign path.
|
|
129
|
+
return tunnelOrigin;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
110
132
|
/**
|
|
111
133
|
* Generate a tunneled URL for a localhost URL
|
|
112
134
|
*/
|