@debugg-ai/debugg-ai-mcp 4.4.1 → 5.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/README.md +9 -7
- package/dist/handlers/probePageHandler.js +2 -2
- package/dist/handlers/runTestSuiteHandler.js +5 -5
- package/dist/handlers/testPageChangesHandler.js +25 -25
- package/dist/handlers/triggerCrawlHandler.js +3 -3
- package/dist/index.js +1 -1
- package/dist/services/caddy/caddyProxy.js +7 -6
- package/dist/services/index.js +0 -9
- package/dist/services/tunnel/debuggTransport.js +9 -9
- package/dist/services/tunnel/protocol/constants.js +3 -3
- package/dist/services/tunnel/transport.js +28 -9
- package/dist/services/{ngrok → tunnel}/tunnelFaultInjection.js +9 -15
- package/dist/services/{ngrok → tunnel}/tunnelManager.js +65 -131
- package/dist/services/{ngrok → tunnel}/tunnelRegistry.js +2 -2
- package/dist/services/tunnels.js +64 -46
- package/dist/tools/testPageChanges.js +1 -1
- package/dist/utils/imageUtils.js +2 -1
- package/dist/utils/localReachability.js +62 -341
- package/dist/utils/telemetry.js +4 -4
- package/dist/utils/tunnelContext.js +11 -9
- package/dist/utils/tunnelDisposition.js +51 -69
- package/dist/utils/tunnelDomains.js +30 -21
- package/dist/utils/urlParser.js +5 -6
- package/package.json +2 -3
- package/dist/services/ngrok/error.js +0 -3
- package/dist/services/ngrok/ngrokAgentInspector.js +0 -210
- package/dist/services/ngrok/ngrokAgentSession.js +0 -49
- package/dist/services/ngrok/types.js +0 -1
- package/dist/services/tunnel/ngrokTransport.js +0 -165
|
@@ -1,70 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What to do with a tunnel whose health probe just failed.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A teardown has to be backed by PROOF that the tunnel is already gone — not
|
|
5
|
+
* by a probe result that a transient flake produces just as readily as a real
|
|
6
|
+
* death. Tearing down a live tunnel is not free: the next call has to
|
|
7
|
+
* re-provision, re-handshake and re-point Caddy, and it does that while the
|
|
8
|
+
* user's actual problem (a dev server that is down, bound to the wrong
|
|
9
|
+
* interface, or still starting) is untouched. It converts "your app isn't
|
|
10
|
+
* answering" into "your app isn't answering AND your tunnel churned".
|
|
11
|
+
*
|
|
12
|
+
* (Under ngrok the same rule had a sharper edge — its minimum billing unit was
|
|
13
|
+
* one hour per tunnel, so a needless teardown cost two billed hours. We own
|
|
14
|
+
* the tunnel server now, so the cost is latency and churn rather than money.
|
|
15
|
+
* The policy is unchanged; only the size of the bill is.)
|
|
8
16
|
*
|
|
9
17
|
* All four tool handlers probe tunnel health before handing the URL to the remote
|
|
10
|
-
* browser, and all four used to gate eviction on
|
|
11
|
-
*
|
|
18
|
+
* browser, and all four used to gate eviction on the error code being set AT ALL,
|
|
19
|
+
* falling back to stopTunnel() otherwise. Both halves were wrong:
|
|
12
20
|
*
|
|
13
|
-
* -
|
|
21
|
+
* - an error code is not a verdict. UPSTREAM_REFUSED means the tunnel is ALIVE
|
|
14
22
|
* and its upstream refused the connection — the tunnel is literally what
|
|
15
|
-
* served us that error. Evicting on it orphans a
|
|
16
|
-
*
|
|
17
|
-
*
|
|
23
|
+
* served us that error. Evicting on it orphans a working tunnel and makes
|
|
24
|
+
* the next call provision a replacement, to work around a dev server that
|
|
25
|
+
* was the actual problem.
|
|
18
26
|
* - the stopTunnel() fallback tore down an OWNED tunnel on ANY probe failure,
|
|
19
|
-
* including
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* including a transient connection-level flake (bead k6yq — measured at
|
|
28
|
+
* roughly 1 run in 5 before the retry ladder landed, and still reachable
|
|
29
|
+
* whenever that ladder exhausts).
|
|
22
30
|
*
|
|
23
31
|
* Hence an explicit allowlist of codes that prove the ENDPOINT ITSELF is gone.
|
|
24
32
|
* Everything else leaves both the tunnel and the shared registry entry completely
|
|
25
33
|
* untouched and just reports TunnelTrafficBlocked: the user fixes their dev
|
|
26
|
-
* server and the next call reuses the same tunnel
|
|
27
|
-
*
|
|
28
|
-
* Bead kmzb, confirmed live on 2026-07-27: probeTunnelHealth cannot currently
|
|
29
|
-
* produce ANY ngrokErrorCode against this edge. `curl` and a raw node:https
|
|
30
|
-
* request both get `404 + ERR_NGROK_3200` from a hostname ngrok no longer routes,
|
|
31
|
-
* but undici's fetch negotiates HTTP/2 and the edge answers with a GOAWAY, which
|
|
32
|
-
* surfaces as UND_ERR_SOCKET / NETWORK_ERROR with no code at all. So in practice
|
|
33
|
-
* every probe failure takes the leave-it-alone branch today. The allowlist is the
|
|
34
|
-
* policy that governs the moment a code CAN be produced — whether that is kmzb
|
|
35
|
-
* forcing HTTP/1.1 for the probe, or a caller passing the marker recorded by a
|
|
36
|
-
* run's own evidence (bead 4bui, the one live-confirmed source of ERR_NGROK_*).
|
|
34
|
+
* server and the next call reuses the same tunnel.
|
|
37
35
|
*/
|
|
38
|
-
import { tunnelManager } from '../services/
|
|
36
|
+
import { tunnelManager } from '../services/tunnel/tunnelManager.js';
|
|
39
37
|
import { Logger } from './logger.js';
|
|
40
38
|
const logger = new Logger({ module: 'tunnelDisposition' });
|
|
41
39
|
/**
|
|
42
|
-
*
|
|
43
|
-
* costs nothing and
|
|
44
|
-
*
|
|
45
|
-
* Inclusion criterion — the code must be served BY THE EDGE ABOUT A HOSTNAME IT
|
|
46
|
-
* NO LONGER ROUTES, i.e. it is impossible to receive it from a tunnel that is
|
|
47
|
-
* still up. Anything describing the upstream, the agent, or the connection is a
|
|
48
|
-
* live tunnel reporting on something else, and must NOT be here.
|
|
40
|
+
* Tunnel-server error markers that PROVE the endpoint no longer exists, so
|
|
41
|
+
* evicting it costs nothing and keeping it costs the next call a failed run.
|
|
49
42
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
43
|
+
* Inclusion criterion — the marker must be served BY THE EDGE ABOUT A TUNNEL IT
|
|
44
|
+
* CANNOT ROUTE, i.e. it is impossible to receive it from a tunnel that is still
|
|
45
|
+
* up. Anything describing the upstream, the client, or the connection is a live
|
|
46
|
+
* tunnel reporting on something else, and must NOT be here.
|
|
53
47
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
48
|
+
* DEBUGG_TUNNEL_OFFLINE — no client is connected. Confirmed across
|
|
49
|
+
* probeTunnelHealth's retry ladder before it gets here, because the server
|
|
50
|
+
* renders it after a 5s grace window that a slow reconnect can outlast.
|
|
51
|
+
* DEBUGG_TUNNEL_UNKNOWN — unknown or revoked tunnel id. Definitive.
|
|
57
52
|
*
|
|
58
53
|
* Deliberately EXCLUDED, and the sharpest case of all:
|
|
59
54
|
*
|
|
60
|
-
*
|
|
61
|
-
* The TUNNEL IS ALIVE; it is the thing that generated the error page.
|
|
62
|
-
* on
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* user's server is back.
|
|
55
|
+
* DEBUGG_TUNNEL_UPSTREAM_REFUSED — the client could not dial the local app.
|
|
56
|
+
* The TUNNEL IS ALIVE; it is the thing that generated the error page.
|
|
57
|
+
* Evicting on it throws away a working tunnel to work around a dev server
|
|
58
|
+
* that is down, bound to the wrong interface, or still starting up. Leave it
|
|
59
|
+
* be; it will serve the very next request once the user's server is back.
|
|
66
60
|
*
|
|
67
|
-
* This set is pinned by a test. Adding a
|
|
61
|
+
* This set is pinned by a test. Adding a marker has to be a deliberate act with
|
|
68
62
|
* evidence behind it, not a silent default to teardown.
|
|
69
63
|
*
|
|
70
64
|
* The frozen ARRAY is the source of truth, not a frozen Set: Object.freeze does
|
|
@@ -73,31 +67,19 @@ const logger = new Logger({ module: 'tunnelDisposition' });
|
|
|
73
67
|
* guarantee; the lookup Set is derived from it and kept private.
|
|
74
68
|
*/
|
|
75
69
|
const ENDPOINT_GONE_CODES = Object.freeze([
|
|
76
|
-
'ERR_NGROK_3200',
|
|
77
|
-
// The debugg tunnel server's equivalents (bead debugg_ai_mcp-xkoh.1.2 §10).
|
|
78
|
-
// Both are served BY THE EDGE ABOUT A HOSTNAME IT DOES NOT ROUTE, which is
|
|
79
|
-
// the inclusion criterion above:
|
|
80
|
-
// DEBUGG_TUNNEL_OFFLINE — no client is connected. Confirmed across
|
|
81
|
-
// probeTunnelHealth's retry ladder before it gets here, because the
|
|
82
|
-
// server renders it after a 5s grace window that a slow reconnect can
|
|
83
|
-
// outlast.
|
|
84
|
-
// DEBUGG_TUNNEL_UNKNOWN — unknown or revoked tunnel id. Definitive.
|
|
85
|
-
// DEBUGG_TUNNEL_UPSTREAM_REFUSED is deliberately absent: like ERR_NGROK_8012
|
|
86
|
-
// it means the TUNNEL served us that page and is alive, while the user's dev
|
|
87
|
-
// server is what refused.
|
|
88
70
|
'DEBUGG_TUNNEL_OFFLINE',
|
|
89
71
|
'DEBUGG_TUNNEL_UNKNOWN',
|
|
90
72
|
]);
|
|
91
73
|
const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
|
|
92
74
|
/** The allowlist, as an immutable list. Read-only by construction. */
|
|
93
|
-
export const
|
|
75
|
+
export const ENDPOINT_GONE_TUNNEL_CODES = ENDPOINT_GONE_CODES;
|
|
94
76
|
/**
|
|
95
|
-
* True only when the
|
|
77
|
+
* True only when the marker proves the endpoint is gone. Unset / unknown codes are
|
|
96
78
|
* NOT proof of anything, so they answer false: the default is always to keep the
|
|
97
|
-
* tunnel we
|
|
79
|
+
* tunnel we already have.
|
|
98
80
|
*/
|
|
99
|
-
export function isEndpointGone(
|
|
100
|
-
return !!
|
|
81
|
+
export function isEndpointGone(tunnelErrorCode) {
|
|
82
|
+
return !!tunnelErrorCode && ENDPOINT_GONE_LOOKUP.has(tunnelErrorCode);
|
|
101
83
|
}
|
|
102
84
|
/**
|
|
103
85
|
* Decide — once, in one place — what an unhealthy tunnel health probe does to the
|
|
@@ -114,8 +96,8 @@ export function isEndpointGone(ngrokErrorCode) {
|
|
|
114
96
|
* tunnel half retired along with cross-process borrowing.
|
|
115
97
|
* Anything else → nothing at all. The caller still returns
|
|
116
98
|
* TunnelTrafficBlocked, so the user is told; we simply do
|
|
117
|
-
* not
|
|
118
|
-
*
|
|
99
|
+
* not tear down a tunnel on a verdict this probe cannot
|
|
100
|
+
* actually deliver.
|
|
119
101
|
*
|
|
120
102
|
* Never throws and never awaits the eviction: a cleanup decision must not be able
|
|
121
103
|
* to fail or slow down the error response the caller is about to return.
|
|
@@ -124,12 +106,12 @@ export function disposeUnhealthyTunnel(args) {
|
|
|
124
106
|
const { health, tunnelId, originalUrl } = args;
|
|
125
107
|
if (!tunnelId)
|
|
126
108
|
return;
|
|
127
|
-
if (!isEndpointGone(health.
|
|
128
|
-
logger.info(`Tunnel ${tunnelId} failed its health probe (${health.code}${health.
|
|
129
|
-
'but nothing proves the endpoint is gone — keeping it. Tearing down a live tunnel
|
|
130
|
-
'
|
|
109
|
+
if (!isEndpointGone(health.tunnelErrorCode)) {
|
|
110
|
+
logger.info(`Tunnel ${tunnelId} failed its health probe (${health.code}${health.tunnelErrorCode ? ` ${health.tunnelErrorCode}` : ''}) ` +
|
|
111
|
+
'but nothing proves the endpoint is gone — keeping it. Tearing down a live tunnel makes the next ' +
|
|
112
|
+
'call re-provision and re-handshake for nothing, while the next call can reuse this one as is.');
|
|
131
113
|
return;
|
|
132
114
|
}
|
|
133
|
-
logger.warn(`Tunnel ${tunnelId} (${originalUrl}) reported ${health.
|
|
115
|
+
logger.warn(`Tunnel ${tunnelId} (${originalUrl}) reported ${health.tunnelErrorCode} — the endpoint is gone, evicting it.`);
|
|
134
116
|
tunnelManager.markTunnelDead(tunnelId).catch((err) => logger.warn(`Failed to evict dead tunnel ${tunnelId}: ${err}`));
|
|
135
117
|
}
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The hostnames a tunnel can live on
|
|
2
|
+
* The hostnames a tunnel URL can live on.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `replaceTunnelUrls`: it is what keeps a tunnel hostname out of every tool
|
|
8
|
-
* response, so a domain it does not know is a URL that leaks to the caller.
|
|
4
|
+
* What this is FOR: `replaceTunnelUrls` reads this list to keep a tunnel
|
|
5
|
+
* hostname out of every tool response, so a domain it does not know is a URL
|
|
6
|
+
* that leaks to the caller.
|
|
9
7
|
*
|
|
10
8
|
* A provision response may name a domain we do not ship (the backend moving
|
|
11
|
-
* `tunnel.debugg.ai` elsewhere, or a
|
|
9
|
+
* `tunnel.debugg.ai` elsewhere, or a local dev host), so domains can be
|
|
12
10
|
* registered at runtime — that is what lets the server move without an MCP
|
|
13
11
|
* release, the same reasoning as `relayUrl`.
|
|
14
12
|
*
|
|
13
|
+
* ┌─ DO NOT DELETE `ngrok.debugg.ai` AS DEAD CODE ─────────────────────────────┐
|
|
14
|
+
* │ This client can no longer CREATE an ngrok tunnel — the ngrok transport was │
|
|
15
|
+
* │ deleted. `ngrok.debugg.ai` stays here purely as a REWRITE TARGET, because │
|
|
16
|
+
* │ a backend response about a historical run (evidence, screenshots, an │
|
|
17
|
+
* │ execution's stored targetUrl) can still carry one of those hostnames. It │
|
|
18
|
+
* │ costs one entry in a list; removing it means those URLs leak to the caller │
|
|
19
|
+
* │ verbatim. Retire it when no stored run references it any more — bead │
|
|
20
|
+
* │ debugg_ai_mcp-xkoh.6.6, which also drops the *.ngrok.debugg.ai DNS. │
|
|
21
|
+
* │ Locked by __tests__/utils/tunnelDomains.test.ts. │
|
|
22
|
+
* └────────────────────────────────────────────────────────────────────────────┘
|
|
23
|
+
*
|
|
15
24
|
* Imports NOTHING, deliberately: utils/urlParser.ts, utils/localReachability.ts
|
|
16
|
-
* and services/
|
|
25
|
+
* and services/tunnel/tunnelManager.ts all depend on it, and it must never be
|
|
17
26
|
* the module that creates a cycle.
|
|
18
27
|
*/
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
]);
|
|
23
|
-
const domains = new
|
|
28
|
+
const LIVE_DOMAIN = 'tunnel.debugg.ai';
|
|
29
|
+
/** Rewrite-only. See the box above before touching this. */
|
|
30
|
+
const LEGACY_DOMAINS = ['ngrok.debugg.ai'];
|
|
31
|
+
const BUILT_IN_DOMAINS = Object.freeze([LIVE_DOMAIN, ...LEGACY_DOMAINS]);
|
|
32
|
+
const domains = new Set(BUILT_IN_DOMAINS);
|
|
24
33
|
/**
|
|
25
34
|
* A tunnel domain must be a lowercase DNS name of at least THREE labels.
|
|
26
35
|
*
|
|
@@ -44,25 +53,25 @@ export function isValidTunnelDomain(domain) {
|
|
|
44
53
|
* every provision. Returns false for a domain that fails validation, so a
|
|
45
54
|
* caller can turn that into an error rather than silently trusting it.
|
|
46
55
|
*/
|
|
47
|
-
export function registerTunnelDomain(domain
|
|
56
|
+
export function registerTunnelDomain(domain) {
|
|
48
57
|
if (!isValidTunnelDomain(domain))
|
|
49
58
|
return false;
|
|
50
|
-
domains.
|
|
59
|
+
domains.add(domain);
|
|
51
60
|
return true;
|
|
52
61
|
}
|
|
53
62
|
/** Every known domain, built-in and registered. */
|
|
54
63
|
export function knownTunnelDomains() {
|
|
55
|
-
return [...domains
|
|
64
|
+
return [...domains].map((domain) => ({ domain }));
|
|
56
65
|
}
|
|
57
66
|
/** The known domain a host sits under, or undefined. Accepts a host or a full URL. */
|
|
58
67
|
export function tunnelDomainFor(hostOrUrl) {
|
|
59
68
|
const host = hostOf(hostOrUrl);
|
|
60
69
|
if (!host)
|
|
61
70
|
return undefined;
|
|
62
|
-
for (const
|
|
71
|
+
for (const domain of domains) {
|
|
63
72
|
// A tunnel host is always `<label>.<domain>` — never the bare domain.
|
|
64
73
|
if (host.length > domain.length + 1 && host.endsWith(`.${domain}`)) {
|
|
65
|
-
return { domain
|
|
74
|
+
return { domain };
|
|
66
75
|
}
|
|
67
76
|
}
|
|
68
77
|
return undefined;
|
|
@@ -85,7 +94,7 @@ export function extractTunnelIdFromHost(hostOrUrl) {
|
|
|
85
94
|
* short lists and the callers are not hot loops.
|
|
86
95
|
*/
|
|
87
96
|
export function tunnelUrlPattern() {
|
|
88
|
-
const alternatives = [...domains
|
|
97
|
+
const alternatives = [...domains].map(escapeRegExp).join('|');
|
|
89
98
|
return new RegExp(`https?:\\/\\/[^\\s/"']+\\.(?:${alternatives})`, 'g');
|
|
90
99
|
}
|
|
91
100
|
function hostOf(hostOrUrl) {
|
|
@@ -108,6 +117,6 @@ function escapeRegExp(value) {
|
|
|
108
117
|
/** Test hook: drop every runtime-registered domain, keeping the built-ins. */
|
|
109
118
|
export function _resetTunnelDomainsForTests() {
|
|
110
119
|
domains.clear();
|
|
111
|
-
for (const
|
|
112
|
-
domains.
|
|
120
|
+
for (const domain of BUILT_IN_DOMAINS)
|
|
121
|
+
domains.add(domain);
|
|
113
122
|
}
|
package/dist/utils/urlParser.js
CHANGED
|
@@ -92,10 +92,9 @@ export function isLocalhostUrl(urlString) {
|
|
|
92
92
|
* Used to sanitize backend responses that contain internal tunnel URLs before
|
|
93
93
|
* returning them to callers who only know the original localhost address.
|
|
94
94
|
*
|
|
95
|
-
* Covers EVERY known tunnel domain (utils/tunnelDomains.ts),
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* the caller.
|
|
95
|
+
* Covers EVERY known tunnel domain (utils/tunnelDomains.ts), including the
|
|
96
|
+
* retired `ngrok.debugg.ai` one a historical run's stored URLs still carry: a
|
|
97
|
+
* domain this does not know is a hostname that leaks to the caller.
|
|
99
98
|
*/
|
|
100
99
|
export function replaceTunnelUrls(value, localhostOrigin) {
|
|
101
100
|
if (typeof value === 'string') {
|
|
@@ -116,7 +115,7 @@ export function replaceTunnelUrls(value, localhostOrigin) {
|
|
|
116
115
|
/**
|
|
117
116
|
* Re-point a REUSED tunnel at the current caller's path (bead zmc9).
|
|
118
117
|
*
|
|
119
|
-
* A tunnel is origin-scoped: its `tunnelUrl` (scheme://host from
|
|
118
|
+
* A tunnel is origin-scoped: its `tunnelUrl` (scheme://host from the transport) forwards
|
|
120
119
|
* every path. The path is request-scoped. On reuse we must compose the reused
|
|
121
120
|
* tunnel's ORIGIN with THIS request's path/search/hash — never replay the
|
|
122
121
|
* path-bearing `publicUrl` baked in by whichever call created the tunnel.
|
|
@@ -138,7 +137,7 @@ export function retargetTunnelUrl(tunnelOrigin, requestedUrl) {
|
|
|
138
137
|
/**
|
|
139
138
|
* Generate a tunneled URL for a localhost URL
|
|
140
139
|
*/
|
|
141
|
-
export function generateTunnelUrl(originalUrl, tunnelId, tunnelDomain
|
|
140
|
+
export function generateTunnelUrl(originalUrl, tunnelId, tunnelDomain) {
|
|
142
141
|
try {
|
|
143
142
|
const parsed = parseUrl(originalUrl);
|
|
144
143
|
if (!parsed.isLocalhost) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@debugg-ai/debugg-ai-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"caddy": "2.11.3",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"lint": "eslint .",
|
|
21
|
-
"build": "tsc && shx chmod +x dist/index.js",
|
|
21
|
+
"build": "shx rm -rf dist && tsc && shx chmod +x dist/index.js",
|
|
22
22
|
"prepare": "npm run build",
|
|
23
23
|
"watch": "tsc --watch",
|
|
24
24
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
@@ -61,7 +61,6 @@
|
|
|
61
61
|
"@radically-straightforward/caddy": "^2.0.12",
|
|
62
62
|
"axios": "^1.9.0",
|
|
63
63
|
"mkdirp": "^3.0.1",
|
|
64
|
-
"ngrok": "^5.0.0-beta.2",
|
|
65
64
|
"posthog-node": "^5.26.0",
|
|
66
65
|
"uuid": "^11.1.0",
|
|
67
66
|
"winston": "^3.15.0",
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local ngrok agent inspector (bead lc62).
|
|
3
|
-
*
|
|
4
|
-
* Every ngrok agent process serves a local inspection API on 127.0.0.1:4040,
|
|
5
|
-
* incrementing to 4041, 4042, ... when the port is taken. `GET /api/tunnels`
|
|
6
|
-
* answers with the tunnels THAT AGENT currently has open:
|
|
7
|
-
*
|
|
8
|
-
* {"tunnels":[{"public_url":"https://x.ngrok.debugg.ai",
|
|
9
|
-
* "config":{"addr":"http://127.0.0.1:3011"}}],"uri":"/api/tunnels"}
|
|
10
|
-
*
|
|
11
|
-
* This is the only free, honest source of tunnel liveness we have. It is
|
|
12
|
-
* loopback: no DNS, no TLS, no ngrok edge, no undici, and — critically — no
|
|
13
|
-
* request to the tunnel's PUBLIC url. Probing the public url for a reuse
|
|
14
|
-
* decision is the k6yq / z15n / kmzb failure class: the edge answers undici
|
|
15
|
-
* over HTTP/2 and the response is lost to a concurrent GOAWAY, so a live tunnel
|
|
16
|
-
* reads as dead and gets replaced for two billed hours.
|
|
17
|
-
*
|
|
18
|
-
* ── The safety property, and why it is structural rather than a policy ──
|
|
19
|
-
*
|
|
20
|
-
* A tunnel MISSING from this API is not proof it is gone: the agent that owns
|
|
21
|
-
* it may be on a port we did not scan, or may not be answering. Only a
|
|
22
|
-
* REACHABLE agent that does not list a tunnel is evidence, and even that is
|
|
23
|
-
* scoped to that one agent.
|
|
24
|
-
*
|
|
25
|
-
* Rather than encode that asymmetry as a tri-state ("live" / "gone" /
|
|
26
|
-
* "unknown") that some future caller could mishandle, the inspector returns
|
|
27
|
-
* only what it POSITIVELY OBSERVED, and TunnelManager uses it in an
|
|
28
|
-
* ADD-ONLY way: reconciliation can create or refresh a registry entry, never
|
|
29
|
-
* delete one. An unreachable agent therefore contributes an empty list and
|
|
30
|
-
* changes nothing versus today's behaviour, and no failure of this module can
|
|
31
|
-
* ever cost a re-provision. That is a guarantee about the shape of the data
|
|
32
|
-
* flow, not a rule someone has to remember.
|
|
33
|
-
*
|
|
34
|
-
* Adoption is filtered to `*.ngrok.debugg.ai`. A developer's own unrelated
|
|
35
|
-
* ngrok tunnels share this agent API and must never be touched by us.
|
|
36
|
-
*/
|
|
37
|
-
import { request as httpRequest } from 'node:http';
|
|
38
|
-
import { Logger } from '../../utils/logger.js';
|
|
39
|
-
import { getFaultModeFromEnv } from './tunnelFaultInjection.js';
|
|
40
|
-
const logger = new Logger({ module: 'ngrokAgentInspector' });
|
|
41
|
-
/** Only tunnels on this suffix are ours to adopt. */
|
|
42
|
-
const OWNED_TUNNEL_SUFFIX = '.ngrok.debugg.ai';
|
|
43
|
-
/** ngrok's web-inspector base port, and how far it walks when 4040 is busy. */
|
|
44
|
-
const FIRST_INSPECTOR_PORT = 4040;
|
|
45
|
-
const INSPECTOR_PORT_COUNT = 10;
|
|
46
|
-
/**
|
|
47
|
-
* Parse one agent's `/api/tunnels` body into the tunnels we are allowed to own.
|
|
48
|
-
*
|
|
49
|
-
* Tolerant by construction — every unexpected shape drops the entry rather than
|
|
50
|
-
* throwing, because a parse failure must degrade to "learned nothing".
|
|
51
|
-
*
|
|
52
|
-
* ngrok reports the same tunnel more than once when it exposes several protos,
|
|
53
|
-
* so results are de-duplicated by tunnelId; first hostname wins.
|
|
54
|
-
*/
|
|
55
|
-
export function parseAgentTunnels(body) {
|
|
56
|
-
let parsed;
|
|
57
|
-
try {
|
|
58
|
-
parsed = JSON.parse(body);
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return [];
|
|
62
|
-
}
|
|
63
|
-
const raw = parsed?.tunnels;
|
|
64
|
-
if (!Array.isArray(raw))
|
|
65
|
-
return [];
|
|
66
|
-
const seen = new Set();
|
|
67
|
-
const out = [];
|
|
68
|
-
for (const item of raw) {
|
|
69
|
-
const publicUrl = item?.public_url;
|
|
70
|
-
const addr = item?.config?.addr;
|
|
71
|
-
if (typeof publicUrl !== 'string' || typeof addr !== 'string')
|
|
72
|
-
continue;
|
|
73
|
-
const tunnelId = tunnelIdFromPublicUrl(publicUrl);
|
|
74
|
-
if (!tunnelId || seen.has(tunnelId))
|
|
75
|
-
continue;
|
|
76
|
-
const port = portFromAgentAddr(addr);
|
|
77
|
-
if (port === undefined)
|
|
78
|
-
continue;
|
|
79
|
-
seen.add(tunnelId);
|
|
80
|
-
out.push({ tunnelId, publicUrl, port });
|
|
81
|
-
}
|
|
82
|
-
return out;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Subdomain of a `*.ngrok.debugg.ai` public url, or undefined for anything
|
|
86
|
-
* else — including a developer's personal ngrok tunnels, which share this
|
|
87
|
-
* agent API and are none of our business.
|
|
88
|
-
*/
|
|
89
|
-
function tunnelIdFromPublicUrl(publicUrl) {
|
|
90
|
-
let host;
|
|
91
|
-
try {
|
|
92
|
-
host = new URL(publicUrl).hostname.toLowerCase();
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
if (!host.endsWith(OWNED_TUNNEL_SUFFIX))
|
|
98
|
-
return undefined;
|
|
99
|
-
const sub = host.slice(0, -OWNED_TUNNEL_SUFFIX.length);
|
|
100
|
-
// A bare or multi-label subdomain is not a tunnelId we minted.
|
|
101
|
-
return sub && !sub.includes('.') ? sub : undefined;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Local port out of an agent `config.addr`, which ngrok reports in several
|
|
105
|
-
* shapes depending on how connect() was called: `http://127.0.0.1:3011`,
|
|
106
|
-
* `https://localhost:3443`, or a bare `localhost:3011`.
|
|
107
|
-
*/
|
|
108
|
-
export function portFromAgentAddr(addr) {
|
|
109
|
-
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(addr) ? addr : `tcp://${addr}`;
|
|
110
|
-
try {
|
|
111
|
-
const port = new URL(withScheme).port;
|
|
112
|
-
if (port) {
|
|
113
|
-
const n = Number(port);
|
|
114
|
-
return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
/* fall through */
|
|
119
|
-
}
|
|
120
|
-
return undefined;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* GET one agent's /api/tunnels over loopback HTTP/1.1.
|
|
124
|
-
*
|
|
125
|
-
* `agent: false` so the socket is closed rather than pooled — this runs once
|
|
126
|
-
* per process and must not leave a keep-alive handle behind.
|
|
127
|
-
*/
|
|
128
|
-
function fetchAgentTunnelsOverHttp(port, timeoutMs) {
|
|
129
|
-
return new Promise((resolve) => {
|
|
130
|
-
let settled = false;
|
|
131
|
-
const done = (value) => {
|
|
132
|
-
if (settled)
|
|
133
|
-
return;
|
|
134
|
-
settled = true;
|
|
135
|
-
resolve(value);
|
|
136
|
-
};
|
|
137
|
-
const req = httpRequest({ host: '127.0.0.1', port, path: '/api/tunnels', method: 'GET', agent: false, timeout: timeoutMs }, (res) => {
|
|
138
|
-
if (res.statusCode !== 200) {
|
|
139
|
-
res.resume();
|
|
140
|
-
return done(undefined);
|
|
141
|
-
}
|
|
142
|
-
let body = '';
|
|
143
|
-
res.setEncoding('utf8');
|
|
144
|
-
// 256KB ceiling: something answering on 4040 that is not an ngrok agent
|
|
145
|
-
// must not be able to stream us out of memory.
|
|
146
|
-
res.on('data', (chunk) => {
|
|
147
|
-
if (body.length < 262144)
|
|
148
|
-
body += chunk;
|
|
149
|
-
});
|
|
150
|
-
res.on('end', () => done(body));
|
|
151
|
-
res.on('error', () => done(undefined));
|
|
152
|
-
});
|
|
153
|
-
req.on('timeout', () => { req.destroy(); done(undefined); });
|
|
154
|
-
// Nothing is listening on most of these ports; ECONNREFUSED is the norm.
|
|
155
|
-
req.on('error', () => done(undefined));
|
|
156
|
-
req.end();
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Scans the local agents. Ports are probed in parallel — nearly all of them
|
|
161
|
-
* refuse instantly, so the wall cost is one timeout at worst.
|
|
162
|
-
*/
|
|
163
|
-
export function createLocalAgentInspector(opts = {}) {
|
|
164
|
-
const ports = opts.ports ?? Array.from({ length: INSPECTOR_PORT_COUNT }, (_, i) => FIRST_INSPECTOR_PORT + i);
|
|
165
|
-
const timeoutMs = opts.timeoutMs ?? 500;
|
|
166
|
-
const fetchOne = opts.fetchAgentTunnels ?? fetchAgentTunnelsOverHttp;
|
|
167
|
-
return {
|
|
168
|
-
async listLiveTunnels() {
|
|
169
|
-
const faults = opts.faultMode !== undefined ? opts.faultMode : getFaultModeFromEnv();
|
|
170
|
-
if (faults?.inspectorUnreachable) {
|
|
171
|
-
logger.debug('[fault-inject] ngrok agent inspector forced unreachable');
|
|
172
|
-
return [];
|
|
173
|
-
}
|
|
174
|
-
if (faults?.inspectorAdoptPort !== undefined) {
|
|
175
|
-
const port = faults.inspectorAdoptPort;
|
|
176
|
-
logger.debug(`[fault-inject] ngrok agent inspector reporting a synthetic tunnel on port ${port}`);
|
|
177
|
-
return [{
|
|
178
|
-
tunnelId: `fault-adopt-${port}`,
|
|
179
|
-
publicUrl: `https://fault-adopt-${port}${OWNED_TUNNEL_SUFFIX}`,
|
|
180
|
-
port,
|
|
181
|
-
}];
|
|
182
|
-
}
|
|
183
|
-
const bodies = await Promise.all(ports.map((port) => fetchOne(port, timeoutMs).catch(() => undefined)));
|
|
184
|
-
const seen = new Set();
|
|
185
|
-
const found = [];
|
|
186
|
-
for (const body of bodies) {
|
|
187
|
-
if (!body)
|
|
188
|
-
continue;
|
|
189
|
-
for (const tunnel of parseAgentTunnels(body)) {
|
|
190
|
-
if (seen.has(tunnel.tunnelId))
|
|
191
|
-
continue;
|
|
192
|
-
seen.add(tunnel.tunnelId);
|
|
193
|
-
found.push(tunnel);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return found;
|
|
197
|
-
},
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
/** Learns nothing, ever. The default under NODE_ENV=test. */
|
|
201
|
-
export const noopInspector = {
|
|
202
|
-
listLiveTunnels: async () => [],
|
|
203
|
-
};
|
|
204
|
-
/**
|
|
205
|
-
* Mirrors getDefaultRegistry(): tests get the inert inspector so no unit test
|
|
206
|
-
* can reach the network, production gets the real loopback scan.
|
|
207
|
-
*/
|
|
208
|
-
export function getDefaultInspector() {
|
|
209
|
-
return process.env.NODE_ENV === 'test' ? noopInspector : createLocalAgentInspector();
|
|
210
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ngrok agent session bootstrap (bead pqgj).
|
|
3
|
-
*
|
|
4
|
-
* ngrok's connect() calls its internal getProcess() and then IMMEDIATELY asks
|
|
5
|
-
* the agent to open a tunnel. But getProcess() resolves as soon as the agent
|
|
6
|
-
* prints its local API address, which — measured live against agent v3.38.0 —
|
|
7
|
-
* is ~293ms BEFORE the client session is established:
|
|
8
|
-
*
|
|
9
|
-
* [ 110ms] msg="starting web service" addr=127.0.0.1:4043 <- getProcess resolves
|
|
10
|
-
* [ 403ms] msg="client session established" <- ~293ms later
|
|
11
|
-
*
|
|
12
|
-
* Tunnelling inside that window returns 503 "a successful ngrok tunnel session
|
|
13
|
-
* has not yet been established". That 503 is retriable and ngrok retries it
|
|
14
|
-
* internally — but connectRetry() pins opts.name on the first call and reuses
|
|
15
|
-
* it, so its own retry collides with the tunnel record the 503 left behind:
|
|
16
|
-
* 400 "invalid tunnel configuration — tunnel already exists". 400 is not
|
|
17
|
-
* retriable, so it surfaces to us. That is bead pqgj's 100% attempt-1 failure.
|
|
18
|
-
*
|
|
19
|
-
* Fix: start the agent and wait for its session BEFORE anyone tunnels.
|
|
20
|
-
* getProcess() memoizes on a module-level promise, so ngrok's own connect()
|
|
21
|
-
* then reuses the very agent we started and never re-spawns it.
|
|
22
|
-
*
|
|
23
|
-
* getProcess is not in ngrok's package export map ({".": "./index.js"}), hence
|
|
24
|
-
* the resolved deep require. It is isolated in this module so the reach into
|
|
25
|
-
* another package's internals is explicit, greppable, and mockable — and so a
|
|
26
|
-
* version bump that moves it fails HERE, loudly, instead of silently spawning
|
|
27
|
-
* something unexpected. Callers treat a throw as "no pre-warm available" and
|
|
28
|
-
* fall back to their connect retry ladder.
|
|
29
|
-
*/
|
|
30
|
-
import { createRequire } from 'node:module';
|
|
31
|
-
import { dirname, join } from 'node:path';
|
|
32
|
-
/**
|
|
33
|
-
* Spawn (or reuse) the ngrok agent, wiring the session callbacks.
|
|
34
|
-
*
|
|
35
|
-
* Resolves once the agent's local API is up — NOT once the session is live.
|
|
36
|
-
* Callers await the session via `onStatusChange('connected')`.
|
|
37
|
-
*
|
|
38
|
-
* @throws if ngrok's internals cannot be reached (version bump, stubbed module).
|
|
39
|
-
*/
|
|
40
|
-
export const startAgentSession = async (opts) => {
|
|
41
|
-
const require = createRequire(import.meta.url);
|
|
42
|
-
const ngrokEntry = require.resolve('ngrok');
|
|
43
|
-
const processModulePath = join(dirname(ngrokEntry), 'src', 'process.js');
|
|
44
|
-
const { getProcess } = require(processModulePath);
|
|
45
|
-
if (typeof getProcess !== 'function') {
|
|
46
|
-
throw new Error(`ngrok getProcess() not found at ${processModulePath}`);
|
|
47
|
-
}
|
|
48
|
-
await getProcess(opts);
|
|
49
|
-
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|