@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.
- package/CHANGELOG.md +42 -0
- package/README.md +37 -1
- package/dist/handlers/probePageHandler.js +252 -139
- package/dist/handlers/runTestSuiteHandler.js +70 -51
- package/dist/handlers/testPageChangesHandler.js +32 -1
- package/dist/handlers/triggerCrawlHandler.js +26 -1
- package/dist/services/caddy/caddyProxy.js +611 -0
- package/dist/services/caddy/portLock.js +321 -0
- package/dist/services/ngrok/tunnelManager.js +526 -625
- package/dist/services/ngrok/tunnelRegistry.js +57 -70
- package/dist/services/verdictAdapter.js +2 -0
- package/dist/utils/telemetry.js +16 -0
- package/dist/utils/tunnelContext.js +52 -15
- package/dist/utils/tunnelDisposition.js +9 -17
- package/package.json +3 -1
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cross-process tunnel registry.
|
|
2
|
+
* Cross-process tunnel registry — WRITE-MOSTLY OBSERVABILITY ONLY.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Under the old per-port model this registry was load-bearing for
|
|
5
|
+
* correctness: it was how a second MCP instance on the same machine
|
|
6
|
+
* discovered and BORROWED an existing tunnel instead of provisioning a
|
|
7
|
+
* duplicate for the same port. That borrowing mechanism (and everything that
|
|
8
|
+
* existed only to make it safe — freshness TTLs, PID-reuse defenses,
|
|
9
|
+
* adopt/reconcile against the local ngrok agent) is retired outright by
|
|
10
|
+
* docs/local-tunnel-multiplexer-architecture-2026-07-31.md §4: "No sharing,
|
|
11
|
+
* ever, at any granularity. Each session key gets its own Caddy instance and
|
|
12
|
+
* its own ngrok tunnel."
|
|
13
|
+
*
|
|
14
|
+
* What remains is a diagnostic view: `RegistryEntry` rows, keyed by
|
|
15
|
+
* `tunnelId` (not port — a port no longer identifies anything unique once
|
|
16
|
+
* one Caddy instance can be repointed across many ports, and one HTTP-mode
|
|
17
|
+
* process can host many session keys sharing one port namespace). Nothing in
|
|
18
|
+
* `TunnelManager` reads this registry to make a reuse/borrow decision
|
|
19
|
+
* anymore; it only writes to it (best-effort) so `~/.debugg-ai/tunnels.json`
|
|
20
|
+
* stays useful for a human debugging "what tunnels does this machine have
|
|
21
|
+
* open." An over-eager prune or a `$TMPDIR`-split process (bead `fcbm`) can
|
|
22
|
+
* therefore only corrupt the diagnostic view now, never correctness — a
|
|
23
|
+
* deliberate, accepted downgrade in what this file is trusted for (§6).
|
|
6
24
|
*
|
|
7
25
|
* The file registry uses an atomic rename-write so concurrent processes never
|
|
8
26
|
* see a partial JSON file. All operations are best-effort — errors are
|
|
@@ -14,27 +32,24 @@
|
|
|
14
32
|
* the process was LAUNCHED. Under launchd it is a per-user
|
|
15
33
|
* `/var/folders/<...>/T`; a shell with a scrubbed environment gets `/tmp`; the
|
|
16
34
|
* Docker image gets `/tmp`. Two MCPs started differently therefore kept two
|
|
17
|
-
* SEPARATE registries
|
|
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.
|
|
35
|
+
* SEPARATE registries and never saw each other's diagnostic rows.
|
|
20
36
|
*
|
|
21
37
|
* So the path is pinned to `~/.debugg-ai/tunnels.json`, which is the same file
|
|
22
38
|
* for the same user no matter how the process was started, with a
|
|
23
39
|
* `DEBUGG_AI_TUNNEL_REGISTRY` override for containers that mount a shared
|
|
24
40
|
* volume elsewhere. Anything found at the legacy tmpdir() path is merged in
|
|
25
|
-
* once, so
|
|
41
|
+
* once, so rows already written on the old path stay visible.
|
|
26
42
|
*/
|
|
27
43
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
28
44
|
import { homedir, tmpdir } from 'os';
|
|
29
45
|
import { dirname, join } from 'path';
|
|
30
46
|
// ── File location ─────────────────────────────────────────────────────────────
|
|
31
47
|
/**
|
|
32
|
-
* The pre-fcbm location. Still READ (and merged from) so
|
|
33
|
-
*
|
|
48
|
+
* The pre-fcbm location. Still READ (and merged from) so rows written by an
|
|
49
|
+
* older build, or by a build whose $TMPDIR differed, are not stranded.
|
|
34
50
|
*
|
|
35
|
-
* Never written to, and never deleted: a still-running older MCP
|
|
36
|
-
*
|
|
37
|
-
* this bead exists to stop.
|
|
51
|
+
* Never written to, and never deleted: a still-running older MCP may be
|
|
52
|
+
* reading it.
|
|
38
53
|
*/
|
|
39
54
|
export function getLegacyRegistryFilePath() {
|
|
40
55
|
return join(tmpdir(), 'debugg-ai-tunnels.json');
|
|
@@ -55,14 +70,9 @@ const migratedPaths = new Set();
|
|
|
55
70
|
* Per-path watermark: when we last wrote a complete view of this registry.
|
|
56
71
|
*
|
|
57
72
|
* The read-side legacy overlay needs to tell two cases apart that look identical
|
|
58
|
-
* in the file:
|
|
59
|
-
* one an old-build MCP wrote after we swept.
|
|
60
|
-
*
|
|
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.
|
|
73
|
+
* in the file: a row we have ALREADY considered and deliberately swept, and
|
|
74
|
+
* one an old-build MCP wrote after we swept. A `lastAccessedAt` relative to
|
|
75
|
+
* our last write separates them.
|
|
66
76
|
*/
|
|
67
77
|
const sweptAt = new Map();
|
|
68
78
|
// ── File-backed implementation (production) ───────────────────────────────────
|
|
@@ -77,31 +87,15 @@ const sweptAt = new Map();
|
|
|
77
87
|
export function createFileRegistry(registryFile = getRegistryFilePath(), legacyFile = getLegacyRegistryFilePath()) {
|
|
78
88
|
const store = {
|
|
79
89
|
read() {
|
|
80
|
-
// Overlay the legacy registry on EVERY read, not just once at startup
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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.
|
|
90
|
+
// Overlay the legacy registry on EVERY read, not just once at startup —
|
|
91
|
+
// see mergeLegacyRegistry()'s doc comment for why a one-shot merge at
|
|
92
|
+
// process start is not enough during a rollout.
|
|
97
93
|
return overlayLegacy(readRegistryFile(registryFile), legacyFile, registryFile);
|
|
98
94
|
},
|
|
99
95
|
write(data) {
|
|
100
96
|
const tmp = `${registryFile}.${process.pid}.tmp`;
|
|
101
97
|
try {
|
|
102
|
-
// 0o700: the registry names every local
|
|
103
|
-
// Also covers a first run where ~/.debugg-ai does not exist yet, and a
|
|
104
|
-
// later run where someone removed it.
|
|
98
|
+
// 0o700: the registry names every local tunnel this user has open.
|
|
105
99
|
mkdirSync(dirname(registryFile), { recursive: true, mode: 0o700 });
|
|
106
100
|
writeFileSync(tmp, JSON.stringify(data));
|
|
107
101
|
// Same directory as the target, so the rename is same-filesystem and
|
|
@@ -128,28 +122,27 @@ export function createFileRegistry(registryFile = getRegistryFilePath(), legacyF
|
|
|
128
122
|
/**
|
|
129
123
|
* One-shot merge of the legacy tmpdir() registry into the stable one.
|
|
130
124
|
*
|
|
131
|
-
* Merge, not move:
|
|
132
|
-
* has something older. A tunnel
|
|
133
|
-
* record was touched most recently
|
|
134
|
-
* actually reflects use.
|
|
125
|
+
* Merge, not move: a row only wins if this path has nothing for that
|
|
126
|
+
* tunnelId or has something older. A tunnel both files know about keeps
|
|
127
|
+
* whichever record was touched most recently.
|
|
135
128
|
*/
|
|
136
129
|
function mergeLegacyRegistry(store, registryFile, legacyFile) {
|
|
137
130
|
if (legacyFile === registryFile || migratedPaths.has(registryFile))
|
|
138
131
|
return;
|
|
139
132
|
migratedPaths.add(registryFile);
|
|
140
133
|
const legacy = readRegistryFile(legacyFile);
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
134
|
+
const ids = Object.keys(legacy);
|
|
135
|
+
if (ids.length === 0)
|
|
143
136
|
return;
|
|
144
137
|
const current = store.read();
|
|
145
138
|
let merged = 0;
|
|
146
|
-
for (const
|
|
147
|
-
const entry = legacy[
|
|
139
|
+
for (const id of ids) {
|
|
140
|
+
const entry = legacy[id];
|
|
148
141
|
if (!isRegistryEntry(entry))
|
|
149
142
|
continue;
|
|
150
|
-
const mine = current[
|
|
143
|
+
const mine = current[id];
|
|
151
144
|
if (!mine || entry.lastAccessedAt > mine.lastAccessedAt) {
|
|
152
|
-
current[
|
|
145
|
+
current[id] = entry;
|
|
153
146
|
merged++;
|
|
154
147
|
}
|
|
155
148
|
}
|
|
@@ -158,26 +151,23 @@ function mergeLegacyRegistry(store, registryFile, legacyFile) {
|
|
|
158
151
|
}
|
|
159
152
|
/**
|
|
160
153
|
* 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.
|
|
154
|
+
* Read-side only — never writes.
|
|
165
155
|
*/
|
|
166
156
|
function overlayLegacy(current, legacyFile, registryFile) {
|
|
167
157
|
if (legacyFile === registryFile)
|
|
168
158
|
return current;
|
|
169
159
|
const watermark = sweptAt.get(registryFile) ?? 0;
|
|
170
160
|
const legacy = readRegistryFile(legacyFile);
|
|
171
|
-
for (const [
|
|
161
|
+
for (const [id, entry] of Object.entries(legacy)) {
|
|
172
162
|
if (!isRegistryEntry(entry))
|
|
173
163
|
continue;
|
|
174
|
-
// Older than our last complete write => we already saw it and chose not
|
|
175
|
-
// keep it
|
|
164
|
+
// Older than our last complete write => we already saw it and chose not
|
|
165
|
+
// to keep it (or it belongs to a tunnel that no longer exists).
|
|
176
166
|
if (entry.lastAccessedAt <= watermark)
|
|
177
167
|
continue;
|
|
178
|
-
const mine = current[
|
|
168
|
+
const mine = current[id];
|
|
179
169
|
if (!mine || entry.lastAccessedAt > mine.lastAccessedAt)
|
|
180
|
-
current[
|
|
170
|
+
current[id] = entry;
|
|
181
171
|
}
|
|
182
172
|
return current;
|
|
183
173
|
}
|
|
@@ -199,7 +189,7 @@ function isRegistryEntry(value) {
|
|
|
199
189
|
return (!!e &&
|
|
200
190
|
typeof e === 'object' &&
|
|
201
191
|
typeof e.tunnelId === 'string' &&
|
|
202
|
-
typeof e.
|
|
192
|
+
typeof e.sessionKey === 'string' &&
|
|
203
193
|
typeof e.ownerPid === 'number' &&
|
|
204
194
|
typeof e.lastAccessedAt === 'number');
|
|
205
195
|
}
|
|
@@ -243,20 +233,17 @@ function checkPid(pid) {
|
|
|
243
233
|
* Shared prune logic — read, filter, write back. Used by both the file-backed
|
|
244
234
|
* and in-memory implementations so the eviction policy lives in one place.
|
|
245
235
|
*
|
|
246
|
-
* Eviction rule: drop entries
|
|
247
|
-
*
|
|
248
|
-
*
|
|
236
|
+
* Eviction rule: drop entries whose owner PID is dead. That's the whole
|
|
237
|
+
* rule now — see the `prune()` doc comment on `RegistryStore` for why the
|
|
238
|
+
* old freshness window is gone.
|
|
249
239
|
*/
|
|
250
|
-
function pruneRegistryData(store,
|
|
251
|
-
const now = opts.nowMs ?? Date.now();
|
|
240
|
+
function pruneRegistryData(store, _opts) {
|
|
252
241
|
const data = store.read();
|
|
253
242
|
const next = {};
|
|
254
243
|
let pruned = 0;
|
|
255
|
-
for (const [
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (aliveAndFresh) {
|
|
259
|
-
next[port] = entry;
|
|
244
|
+
for (const [id, entry] of Object.entries(data)) {
|
|
245
|
+
if (store.isPidAlive(entry.ownerPid)) {
|
|
246
|
+
next[id] = entry;
|
|
260
247
|
}
|
|
261
248
|
else {
|
|
262
249
|
pruned++;
|
|
@@ -80,5 +80,7 @@ export function adaptVerdict(execution, opts = {}) {
|
|
|
80
80
|
if (evidence?.loginError && typeof evidence.loginError === 'object') {
|
|
81
81
|
relay.loginError = evidence.loginError;
|
|
82
82
|
}
|
|
83
|
+
if (typeof evidence?.report === 'string' && evidence.report)
|
|
84
|
+
relay.report = evidence.report;
|
|
83
85
|
return relay;
|
|
84
86
|
}
|
package/dist/utils/telemetry.js
CHANGED
|
@@ -56,6 +56,22 @@ export const TelemetryEvents = {
|
|
|
56
56
|
TUNNEL_PROVISIONED: 'tunnel.provisioned',
|
|
57
57
|
TUNNEL_PROVISION_RETRY: 'tunnel.provision_retry',
|
|
58
58
|
TUNNEL_STOPPED: 'tunnel.stopped',
|
|
59
|
+
// services/ngrok/tunnelManager.ts — the session tunnel's Caddy instance
|
|
60
|
+
// crash-respawned onto a DIFFERENT local proxy port (sticky-port reclaim
|
|
61
|
+
// failed). The existing ngrok tunnel is now dialing a dead port, so the
|
|
62
|
+
// whole session tunnel is evicted and must be recreated on the next call
|
|
63
|
+
// (see docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.3).
|
|
64
|
+
TUNNEL_EVICTED_PORT_CHANGED: 'tunnel.evicted_port_changed',
|
|
65
|
+
// services/ngrok/tunnelManager.ts — stopTunnel()'s unconditional-removal
|
|
66
|
+
// contract (§2.3): map state is always removed before cleanup I/O runs, so
|
|
67
|
+
// a partial failure here (ngrok disconnect / caddy.stop() / key revoke)
|
|
68
|
+
// never leaves stale-but-discoverable state. Fired once per failed step.
|
|
69
|
+
TUNNEL_TEARDOWN_PARTIAL_FAILURE: 'tunnel.teardown_partial_failure',
|
|
59
70
|
TEMPLATE_LOOKUP: 'template.lookup',
|
|
60
71
|
PROJECT_LOOKUP: 'project.lookup',
|
|
72
|
+
// services/caddy/portLock.ts — maxHoldMs is an OBSERVABILITY-ONLY watchdog
|
|
73
|
+
// (see docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.4/§4):
|
|
74
|
+
// it never force-releases the lock, it only surfaces that a generation has
|
|
75
|
+
// been held unusually long.
|
|
76
|
+
PORT_LOCK_MAX_HOLD_EXCEEDED: 'port_lock.max_hold_exceeded',
|
|
61
77
|
};
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Centralizes:
|
|
5
5
|
* - resolving user input url to a concrete URL
|
|
6
6
|
* - creating / reusing ngrok tunnels after the backend returns a tunnelKey
|
|
7
|
+
* - acquiring/releasing this session's shared Caddy port route (§2.4)
|
|
7
8
|
* - sanitizing backend responses so callers only ever see the original URL
|
|
8
9
|
*/
|
|
9
|
-
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
10
|
-
import { isLocalhostUrl, replaceTunnelUrls,
|
|
10
|
+
import { tunnelManager, getSessionKey } from '../services/ngrok/tunnelManager.js';
|
|
11
|
+
import { isLocalhostUrl, replaceTunnelUrls, retargetTunnelUrl, extractLocalhostPort } from './urlParser.js';
|
|
11
12
|
// ─── URL resolution ──────────────────────────────────────────────────────────
|
|
12
13
|
/**
|
|
13
14
|
* Resolve tool input to a concrete URL string.
|
|
@@ -27,24 +28,22 @@ export function buildContext(originalUrl) {
|
|
|
27
28
|
}
|
|
28
29
|
// ─── Tunnel creation ─────────────────────────────────────────────────────────
|
|
29
30
|
/**
|
|
30
|
-
* Check whether
|
|
31
|
-
* If found, touches its timer and
|
|
32
|
-
*
|
|
31
|
+
* Check whether this SESSION already has a tunnel (§2.1 — one ngrok tunnel
|
|
32
|
+
* per session key, not per local port). If found, touches its timer and
|
|
33
|
+
* returns an enriched context retargeted at this caller's own path. Returns
|
|
34
|
+
* null for public URLs or when this session has no tunnel yet.
|
|
33
35
|
*
|
|
34
36
|
* Call this BEFORE provisioning a new key — if it returns a context, skip the provision.
|
|
35
37
|
*/
|
|
36
38
|
export function findExistingTunnel(ctx) {
|
|
37
39
|
if (!ctx.isLocalhost)
|
|
38
40
|
return null;
|
|
39
|
-
const
|
|
40
|
-
if (!port)
|
|
41
|
-
return null;
|
|
42
|
-
const existing = tunnelManager.getTunnelForPort(port);
|
|
41
|
+
const existing = tunnelManager.getSessionTunnelInfo(getSessionKey());
|
|
43
42
|
if (!existing)
|
|
44
43
|
return null;
|
|
45
44
|
tunnelManager.touchTunnel(existing.tunnelId);
|
|
46
|
-
// Bead zmc9: retarget to THIS caller's path — never replay
|
|
47
|
-
//
|
|
45
|
+
// Bead zmc9: retarget to THIS caller's path — never replay another call's
|
|
46
|
+
// path baked into a previously-returned URL for this same session tunnel.
|
|
48
47
|
return {
|
|
49
48
|
...ctx,
|
|
50
49
|
tunnelId: existing.tunnelId,
|
|
@@ -52,22 +51,60 @@ export function findExistingTunnel(ctx) {
|
|
|
52
51
|
};
|
|
53
52
|
}
|
|
54
53
|
/**
|
|
55
|
-
* Create (or reuse)
|
|
54
|
+
* Create (or reuse) this session's tunnel (§2.1) for a localhost URL.
|
|
56
55
|
*
|
|
57
56
|
* Call this AFTER the backend returns a `tunnelKey` and `tunnelId`.
|
|
58
57
|
* No-op for public URLs.
|
|
59
58
|
*
|
|
60
59
|
* @param ctx - Context built from `buildContext()`
|
|
61
60
|
* @param tunnelKey - Auth token from the backend (short-lived ngrok key)
|
|
62
|
-
* @param tunnelId - ID to use as the ngrok subdomain
|
|
61
|
+
* @param tunnelId - ID to use as the ngrok subdomain (only takes effect the
|
|
62
|
+
* first time this session creates a tunnel)
|
|
63
63
|
* @param keyId - Backend key ID; stored on the tunnel so it is revoked on stop
|
|
64
64
|
* @param revokeKey - Callback that revokes the backend key (called when tunnel stops)
|
|
65
65
|
*/
|
|
66
66
|
export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey) {
|
|
67
67
|
if (!ctx.isLocalhost)
|
|
68
68
|
return ctx;
|
|
69
|
-
const
|
|
70
|
-
return { ...ctx, tunnelId:
|
|
69
|
+
const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey);
|
|
70
|
+
return { ...ctx, tunnelId: info.tunnelId, targetUrl: retargetTunnelUrl(info.tunnelUrl, ctx.originalUrl) };
|
|
71
|
+
}
|
|
72
|
+
// ─── Port route lock (§2.4) ──────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Acquire this session's shared Caddy route for `ctx`'s port, blocking until
|
|
75
|
+
* Caddy is CONFIRMED pointed at it (§2.4/§3.1 of the architecture doc). Every
|
|
76
|
+
* localhost-targeting handler must call this immediately after
|
|
77
|
+
* `findExistingTunnel`/`ensureTunnel` and BEFORE `probeTunnelHealth` — probing
|
|
78
|
+
* before the repoint is confirmed would probe whatever port happened to be
|
|
79
|
+
* active a moment ago, not the port this call actually wants.
|
|
80
|
+
*
|
|
81
|
+
* No-op (returns `ctx` unchanged) for public URLs and for any ctx that never
|
|
82
|
+
* got a `tunnelId` (dev mode, or a public URL that never provisioned a
|
|
83
|
+
* tunnel) — there is no shared route to serialize in either case.
|
|
84
|
+
*/
|
|
85
|
+
export async function acquirePortRoute(ctx, opts) {
|
|
86
|
+
if (!ctx.isLocalhost || !ctx.tunnelId)
|
|
87
|
+
return ctx;
|
|
88
|
+
const info = tunnelManager.getTunnelInfo(ctx.tunnelId);
|
|
89
|
+
if (!info) {
|
|
90
|
+
throw new Error(`acquirePortRoute: no TunnelInfo for tunnel ${ctx.tunnelId} (session tunnel vanished?)`);
|
|
91
|
+
}
|
|
92
|
+
const port = extractLocalhostPort(ctx.originalUrl);
|
|
93
|
+
if (port === undefined) {
|
|
94
|
+
throw new Error(`acquirePortRoute: could not extract a port from localhost URL: ${ctx.originalUrl}`);
|
|
95
|
+
}
|
|
96
|
+
const isHttpsLocal = ctx.originalUrl.startsWith('https:');
|
|
97
|
+
const routeLock = await info.portLock.acquire({ port, isHttpsLocal }, opts);
|
|
98
|
+
return { ...ctx, routeLock };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Release this request's claim on the session's shared Caddy route, if it
|
|
102
|
+
* holds one. Call from the handler's existing `finally` block — safe/no-op
|
|
103
|
+
* when `ctx.routeLock` was never set (public URL, dev mode, or the call never
|
|
104
|
+
* reached `acquirePortRoute`).
|
|
105
|
+
*/
|
|
106
|
+
export function releasePortRoute(ctx) {
|
|
107
|
+
ctx.routeLock?.release();
|
|
71
108
|
}
|
|
72
109
|
/**
|
|
73
110
|
* Stop the tunnel associated with a context (fire-and-forget safe).
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
* run's own evidence (bead 4bui, the one live-confirmed source of ERR_NGROK_*).
|
|
37
37
|
*/
|
|
38
38
|
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
39
|
-
import { extractLocalhostPort } from './urlParser.js';
|
|
40
39
|
import { Logger } from './logger.js';
|
|
41
40
|
const logger = new Logger({ module: 'tunnelDisposition' });
|
|
42
41
|
/**
|
|
@@ -91,10 +90,13 @@ export function isEndpointGone(ngrokErrorCode) {
|
|
|
91
90
|
* them (run_test_suite had already drifted: it evicted on every failure and never
|
|
92
91
|
* got bead k34o's shared-registry eviction at all).
|
|
93
92
|
*
|
|
94
|
-
* Endpoint proven gone → markTunnelDead:
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
93
|
+
* Endpoint proven gone → markTunnelDead: disconnects the tunnel and revokes its
|
|
94
|
+
* key. Under the per-session-tunnel model (§4 of
|
|
95
|
+
* docs/local-tunnel-multiplexer-architecture-2026-07-31.md)
|
|
96
|
+
* every tunnel is created — never borrowed — by this
|
|
97
|
+
* process, so there is no separate shared-registry
|
|
98
|
+
* adoption record left to evict; bead k34o's borrowed-
|
|
99
|
+
* tunnel half retired along with cross-process borrowing.
|
|
98
100
|
* Anything else → nothing at all. The caller still returns
|
|
99
101
|
* TunnelTrafficBlocked, so the user is told; we simply do
|
|
100
102
|
* not spend two billed hours acting on a verdict this
|
|
@@ -113,16 +115,6 @@ export function disposeUnhealthyTunnel(args) {
|
|
|
113
115
|
'(1-hour minimum down, another up) and the next call reuses this one for free.');
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// markTunnelDead is keyed by port. Without one we cannot evict the shared
|
|
119
|
-
// entry safely, and a blind stopTunnel is exactly the teardown this module
|
|
120
|
-
// exists to prevent — so keep the tunnel and say why.
|
|
121
|
-
logger.warn(`Tunnel ${tunnelId} reported ${health.ngrokErrorCode} but no port could be parsed from ${originalUrl} — ` +
|
|
122
|
-
'leaving it in place rather than risking a teardown of a live tunnel.');
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
logger.warn(`Tunnel ${tunnelId} on port ${port} reported ${health.ngrokErrorCode} — the endpoint is gone, evicting it ` +
|
|
126
|
-
'so no session re-borrows the corpse (bead k34o).');
|
|
127
|
-
tunnelManager.markTunnelDead(port, tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
|
|
118
|
+
logger.warn(`Tunnel ${tunnelId} (${originalUrl}) reported ${health.ngrokErrorCode} — the endpoint is gone, evicting it.`);
|
|
119
|
+
tunnelManager.markTunnelDead(tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
|
|
128
120
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@debugg-ai/debugg-ai-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"caddy": "2.11.3",
|
|
6
7
|
"bin": {
|
|
7
8
|
"debugg-ai-mcp": "dist/index.js"
|
|
8
9
|
},
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"changelog": "https://github.com/debugg-ai/debugg-ai-mcp/CHANGELOG.md",
|
|
58
59
|
"dependencies": {
|
|
59
60
|
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
61
|
+
"@radically-straightforward/caddy": "^2.0.12",
|
|
60
62
|
"axios": "^1.9.0",
|
|
61
63
|
"mkdirp": "^3.0.1",
|
|
62
64
|
"ngrok": "^5.0.0-beta.2",
|