@debugg-ai/debugg-ai-mcp 4.3.0 → 4.4.1
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 +14 -7
- package/dist/handlers/runTestSuiteHandler.js +6 -5
- package/dist/handlers/testPageChangesHandler.js +15 -7
- package/dist/handlers/triggerCrawlHandler.js +7 -6
- package/dist/services/ngrok/tunnelManager.js +157 -161
- package/dist/services/tunnel/debuggTransport.js +439 -0
- package/dist/services/tunnel/ngrokTransport.js +165 -0
- package/dist/services/tunnel/probeRegistry.js +52 -0
- package/dist/services/tunnel/protocol/codec.js +327 -0
- package/dist/services/tunnel/protocol/conformance.js +171 -0
- package/dist/services/tunnel/protocol/constants.js +178 -0
- package/dist/services/tunnel/protocol/errors.js +51 -0
- package/dist/services/tunnel/protocol/handshake.js +134 -0
- package/dist/services/tunnel/protocol/index.js +17 -0
- package/dist/services/tunnel/protocol/session.js +668 -0
- package/dist/services/tunnel/protocol/stream.js +109 -0
- package/dist/services/tunnel/protocol/transport.js +106 -0
- package/dist/services/tunnel/transport.js +15 -0
- package/dist/services/tunnels.js +71 -3
- package/dist/utils/localReachability.js +160 -3
- package/dist/utils/tunnelContext.js +6 -3
- package/dist/utils/tunnelDisposition.js +16 -1
- package/dist/utils/tunnelDomains.js +113 -0
- package/dist/utils/urlParser.js +8 -2
- package/package.json +3 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — one multiplexed stream as a node Duplex.
|
|
3
|
+
*
|
|
4
|
+
* A TunnelStream is the byte pipe for exactly one inbound browser connection.
|
|
5
|
+
* Both ends pipe it straight at a socket, so being a real Duplex (and honouring
|
|
6
|
+
* backpressure in both directions) is the whole point:
|
|
7
|
+
*
|
|
8
|
+
* server: socket.pipe(stream).pipe(socket)
|
|
9
|
+
* client: stream.pipe(net.connect(caddyLoopbackPort)).pipe(stream)
|
|
10
|
+
*
|
|
11
|
+
* Writes become DATA frames of at most MAX_DATA_PAYLOAD, gated by the stream's
|
|
12
|
+
* remaining credit. end() sends END. destroy() sends RST.
|
|
13
|
+
*
|
|
14
|
+
* All protocol state lives in the session; this class only forwards. The one
|
|
15
|
+
* piece of cleverness is the read() override, which is how the session learns
|
|
16
|
+
* how many bytes the application has actually consumed and therefore how much
|
|
17
|
+
* credit it may give back.
|
|
18
|
+
*/
|
|
19
|
+
import { Duplex } from 'node:stream';
|
|
20
|
+
import { ErrorCode, MAX_DATA_PAYLOAD } from './constants.js';
|
|
21
|
+
export class TunnelStream extends Duplex {
|
|
22
|
+
/** The stream id the server allocated. */
|
|
23
|
+
id;
|
|
24
|
+
/**
|
|
25
|
+
* Whitelisted OPEN metadata. Informational only — NEVER a dial target
|
|
26
|
+
* (see the SSRF note on bead debugg_ai_mcp-xkoh.1.2).
|
|
27
|
+
*/
|
|
28
|
+
metadata;
|
|
29
|
+
host;
|
|
30
|
+
pendingResetCode;
|
|
31
|
+
pushedTotal = 0;
|
|
32
|
+
reportedConsumed = 0;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
super({
|
|
35
|
+
// One frame's worth of writable buffer: enough to keep the wire busy,
|
|
36
|
+
// little enough that a piped producer is held back rather than drained.
|
|
37
|
+
writableHighWaterMark: MAX_DATA_PAYLOAD,
|
|
38
|
+
readableHighWaterMark: MAX_DATA_PAYLOAD,
|
|
39
|
+
allowHalfOpen: true,
|
|
40
|
+
});
|
|
41
|
+
this.id = options.id;
|
|
42
|
+
this.metadata = options.metadata;
|
|
43
|
+
this.host = options.host;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Abort both directions with RST(code). Defaults to ErrorCode.RESET. Use
|
|
47
|
+
* ErrorCode.UPSTREAM_UNREACHABLE when the local target could not be dialled,
|
|
48
|
+
* so the server can render DEBUGG_TUNNEL_UPSTREAM_REFUSED.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately does not raise a local 'error': the side that chose to reset
|
|
51
|
+
* already knows, and an unhandled 'error' on a Duplex throws.
|
|
52
|
+
*/
|
|
53
|
+
reset(code = ErrorCode.RESET) {
|
|
54
|
+
this.pendingResetCode = code;
|
|
55
|
+
this.destroy();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Hand the session's inbound bytes to the readable side.
|
|
59
|
+
*
|
|
60
|
+
* Never call push() directly: in flowing mode node delivers a pushed chunk
|
|
61
|
+
* straight to the 'data' listener without it ever entering the buffer or going
|
|
62
|
+
* through read(), so consumption has to be reconciled right here. Missing it
|
|
63
|
+
* means credit is never returned and a transfer larger than one window
|
|
64
|
+
* deadlocks — which is exactly what it did.
|
|
65
|
+
*/
|
|
66
|
+
deliver(chunk) {
|
|
67
|
+
this.pushedTotal += chunk.length;
|
|
68
|
+
const accepted = this.push(chunk);
|
|
69
|
+
this.syncConsumed();
|
|
70
|
+
return accepted;
|
|
71
|
+
}
|
|
72
|
+
/** The peer half-closed: no more inbound bytes on this stream. */
|
|
73
|
+
deliverEnd() {
|
|
74
|
+
this.push(null);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Bytes that have left the readable buffer are bytes the application consumed,
|
|
78
|
+
* and those are what the session may grant back as WINDOW. Credit is never
|
|
79
|
+
* given for bytes still sitting in the buffer.
|
|
80
|
+
*/
|
|
81
|
+
syncConsumed() {
|
|
82
|
+
const consumed = this.pushedTotal - this.readableLength;
|
|
83
|
+
const delta = consumed - this.reportedConsumed;
|
|
84
|
+
if (delta <= 0)
|
|
85
|
+
return;
|
|
86
|
+
this.reportedConsumed = consumed;
|
|
87
|
+
this.host.streamConsumed(this, delta);
|
|
88
|
+
}
|
|
89
|
+
read(size) {
|
|
90
|
+
const chunk = super.read(size);
|
|
91
|
+
this.syncConsumed();
|
|
92
|
+
return chunk;
|
|
93
|
+
}
|
|
94
|
+
_read() {
|
|
95
|
+
// Nothing to pull: the session pushes as DATA frames arrive, and flow
|
|
96
|
+
// control — not this callback — is what limits how much can be in flight.
|
|
97
|
+
}
|
|
98
|
+
_write(chunk, encoding, cb) {
|
|
99
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
100
|
+
this.host.streamWrite(this, buf, cb);
|
|
101
|
+
}
|
|
102
|
+
_final(cb) {
|
|
103
|
+
this.host.streamFinal(this, cb);
|
|
104
|
+
}
|
|
105
|
+
_destroy(err, cb) {
|
|
106
|
+
this.host.streamDestroy(this, err, this.pendingResetCode);
|
|
107
|
+
cb(err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — transport seam.
|
|
3
|
+
*
|
|
4
|
+
* TunnelSession never touches a websocket directly. It talks to a FrameTransport,
|
|
5
|
+
* which is what lets the unit tests drive a session frame by frame (and with fake
|
|
6
|
+
* timers) while the integration tests run the same session over a real `ws`
|
|
7
|
+
* socket. `ws` is referenced only structurally here, so this module keeps its
|
|
8
|
+
* "node: built-ins only" property and stays embeddable in the tunnel server.
|
|
9
|
+
*/
|
|
10
|
+
/** RFC 6455: the connection dropped without a close handshake. */
|
|
11
|
+
const ABNORMAL_CLOSURE = 1006;
|
|
12
|
+
/** ws hands out Buffer, ArrayBuffer, or an array of Buffers for a fragmented message. */
|
|
13
|
+
function toBytes(data) {
|
|
14
|
+
if (Buffer.isBuffer(data))
|
|
15
|
+
return data;
|
|
16
|
+
if (data instanceof ArrayBuffer)
|
|
17
|
+
return new Uint8Array(data);
|
|
18
|
+
if (ArrayBuffer.isView(data))
|
|
19
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
20
|
+
if (Array.isArray(data) && data.every((part) => Buffer.isBuffer(part)))
|
|
21
|
+
return Buffer.concat(data);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function reasonToString(reason) {
|
|
25
|
+
if (typeof reason === 'string')
|
|
26
|
+
return reason;
|
|
27
|
+
if (Buffer.isBuffer(reason))
|
|
28
|
+
return reason.toString('utf8');
|
|
29
|
+
if (reason instanceof ArrayBuffer)
|
|
30
|
+
return Buffer.from(reason).toString('utf8');
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Adapt a ws WebSocket (client or server side) to a FrameTransport.
|
|
35
|
+
*
|
|
36
|
+
* Responsibilities: normalise inbound data to a Uint8Array, reject text messages
|
|
37
|
+
* as invalid, and make sure the session is told exactly once that the socket is
|
|
38
|
+
* gone — whichever of 'close' and 'error' happens to fire, and in whichever
|
|
39
|
+
* order.
|
|
40
|
+
*/
|
|
41
|
+
export function webSocketTransport(ws) {
|
|
42
|
+
let handlers;
|
|
43
|
+
let closed = false;
|
|
44
|
+
let socketError;
|
|
45
|
+
const reportClose = (code, reason) => {
|
|
46
|
+
if (closed)
|
|
47
|
+
return;
|
|
48
|
+
closed = true;
|
|
49
|
+
handlers?.onClose(code, reason);
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
send(frame, cb) {
|
|
53
|
+
if (closed) {
|
|
54
|
+
cb(new Error('websocket is closed'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
ws.send(frame, (err) => cb(err ?? null));
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
cb(err);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
close(code, reason) {
|
|
65
|
+
try {
|
|
66
|
+
ws.close(code, reason);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* already closing; the close event still settles the session */
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
terminate() {
|
|
73
|
+
try {
|
|
74
|
+
ws.terminate();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* nothing left to tear down */
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
setHandlers(next) {
|
|
81
|
+
handlers = next;
|
|
82
|
+
ws.on('message', (data, isBinary) => {
|
|
83
|
+
if (closed)
|
|
84
|
+
return;
|
|
85
|
+
if (!isBinary) {
|
|
86
|
+
next.onInvalidMessage('text message');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const bytes = toBytes(data);
|
|
90
|
+
if (!bytes) {
|
|
91
|
+
next.onInvalidMessage('message was not binary data');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
next.onFrame(bytes);
|
|
95
|
+
});
|
|
96
|
+
ws.on('error', (err) => {
|
|
97
|
+
// ws emits 'close' after 'error' for a live socket, so hold the reason
|
|
98
|
+
// and let the close path report it — unless no close ever arrives.
|
|
99
|
+
socketError = err;
|
|
100
|
+
});
|
|
101
|
+
ws.on('close', (code, reason) => {
|
|
102
|
+
reportClose(code || ABNORMAL_CLOSURE, reasonToString(reason) || socketError?.message || '');
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelTransport — the one step of tunnel creation that differs between ngrok
|
|
3
|
+
* and the debugg tunnel server.
|
|
4
|
+
*
|
|
5
|
+
* STUB (bead debugg_ai_mcp-xkoh.5.3, phase 2.1). Types and signatures only; the
|
|
6
|
+
* implementations arrive in phase 4.1 (debugg_ai_mcp-xkoh.5.7).
|
|
7
|
+
*
|
|
8
|
+
* Everything above this seam is unchanged: handlers, utils/tunnelContext.ts,
|
|
9
|
+
* the per-session Caddy instance, PortLock, URL rewriting, the retry ladder,
|
|
10
|
+
* the idle timer and the session keying all behave exactly as they do today
|
|
11
|
+
* (docs/debugg-tunnel-server-design-2026-09-19.md §5). TunnelManager picks an
|
|
12
|
+
* implementation from the provision response and calls it where it calls
|
|
13
|
+
* ngrok.connect() today.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
package/dist/services/tunnels.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Called before executeWorkflow so the tunnel URL is known before execution starts.
|
|
5
5
|
*/
|
|
6
6
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
7
|
+
import { isValidTunnelDomain, registerTunnelDomain, } from '../utils/tunnelDomains.js';
|
|
7
8
|
/**
|
|
8
9
|
* Typed error thrown by provision() when the backend/ngrok path fails.
|
|
9
10
|
* Carries diagnostic fields a retry wrapper (bead 7nx) can use to decide
|
|
@@ -80,11 +81,29 @@ export function classifyProvisionError(err) {
|
|
|
80
81
|
}
|
|
81
82
|
const DEFAULT_BACKOFF_MS = [500, 1500, 3000];
|
|
82
83
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
84
|
+
/** The transports this client can actually drive, best first. */
|
|
85
|
+
export const SUPPORTED_TRANSPORTS = Object.freeze(['debugg', 'ngrok']);
|
|
86
|
+
/** A relay URL must be wss, except against a loopback backend (local dev, tests). */
|
|
87
|
+
function isAcceptableRelayUrl(relayUrl) {
|
|
88
|
+
let url;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(relayUrl);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (url.protocol === 'wss:')
|
|
96
|
+
return true;
|
|
97
|
+
if (url.protocol !== 'ws:')
|
|
98
|
+
return false;
|
|
99
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
100
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
101
|
+
}
|
|
83
102
|
export const createTunnelsService = (tx) => {
|
|
84
103
|
async function provision(purpose = 'workflow') {
|
|
85
104
|
let response;
|
|
86
105
|
try {
|
|
87
|
-
response = await tx.post('api/v1/tunnels/', { purpose });
|
|
106
|
+
response = await tx.post('api/v1/tunnels/', { purpose, transports: [...SUPPORTED_TRANSPORTS] });
|
|
88
107
|
}
|
|
89
108
|
catch (err) {
|
|
90
109
|
throw classifyProvisionError(err);
|
|
@@ -95,12 +114,61 @@ export const createTunnelsService = (tx) => {
|
|
|
95
114
|
retryable: false,
|
|
96
115
|
});
|
|
97
116
|
}
|
|
98
|
-
|
|
117
|
+
// No `transport` means an old backend that predates negotiation, which only
|
|
118
|
+
// ever hands out ngrok keys.
|
|
119
|
+
const transport = (response.transport ?? 'ngrok');
|
|
120
|
+
if (!SUPPORTED_TRANSPORTS.includes(transport)) {
|
|
121
|
+
throw new TunnelProvisionError({
|
|
122
|
+
message: `Tunnel provisioning selected transport "${response.transport}", which this client cannot speak ` +
|
|
123
|
+
`(it offered ${SUPPORTED_TRANSPORTS.join(', ')}). Update @debugg-ai/debugg-ai-mcp.`,
|
|
124
|
+
retryable: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const result = {
|
|
99
128
|
tunnelId: response.tunnelId,
|
|
100
129
|
tunnelKey: response.tunnelKey,
|
|
101
130
|
keyId: response.keyId,
|
|
102
131
|
expiresAt: response.expiresAt,
|
|
132
|
+
transport,
|
|
103
133
|
};
|
|
134
|
+
if (transport === 'debugg') {
|
|
135
|
+
// Every one of these is non-retryable: retrying cannot turn a malformed
|
|
136
|
+
// response into a usable one, and TunnelManager must never be handed a
|
|
137
|
+
// debugg token with nowhere to send it.
|
|
138
|
+
if (!response.relayUrl || !response.tunnelDomain) {
|
|
139
|
+
throw new TunnelProvisionError({
|
|
140
|
+
message: 'Tunnel provisioning selected the debugg transport but omitted relayUrl or tunnelDomain',
|
|
141
|
+
retryable: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (!isAcceptableRelayUrl(response.relayUrl)) {
|
|
145
|
+
throw new TunnelProvisionError({
|
|
146
|
+
message: `Tunnel provisioning returned a relayUrl that is not wss (${response.relayUrl}); ` +
|
|
147
|
+
'refusing to send the tunnel key over it',
|
|
148
|
+
retryable: false,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (!isValidTunnelDomain(response.tunnelDomain)) {
|
|
152
|
+
throw new TunnelProvisionError({
|
|
153
|
+
message: `Tunnel provisioning returned an unusable tunnelDomain (${response.tunnelDomain})`,
|
|
154
|
+
retryable: false,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Registering it here is what stops a tunnel URL leaking: everything that
|
|
158
|
+
// recognises or rewrites a tunnel hostname reads this same registry, so a
|
|
159
|
+
// domain the backend moves to is covered the moment it is handed to us.
|
|
160
|
+
registerTunnelDomain(response.tunnelDomain, 'debugg');
|
|
161
|
+
result.relayUrl = response.relayUrl;
|
|
162
|
+
result.tunnelDomain = response.tunnelDomain;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
async function revoke(provisionInfo) {
|
|
167
|
+
if (provisionInfo.transport === 'debugg') {
|
|
168
|
+
await tx.post(`api/v1/tunnels/${provisionInfo.tunnelId}/revoke/`, {});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await tx.post('api/v1/ngrok/revoke/', { ngrokKeyId: provisionInfo.keyId });
|
|
104
172
|
}
|
|
105
173
|
async function provisionWithRetry(opts = {}) {
|
|
106
174
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -144,5 +212,5 @@ export const createTunnelsService = (tx) => {
|
|
|
144
212
|
retryable: false,
|
|
145
213
|
});
|
|
146
214
|
}
|
|
147
|
-
return { provision, provisionWithRetry };
|
|
215
|
+
return { provision, provisionWithRetry, revoke };
|
|
148
216
|
};
|
|
@@ -17,6 +17,8 @@ import { createConnection } from 'node:net';
|
|
|
17
17
|
import { request as httpRequest } from 'node:http';
|
|
18
18
|
import { request as httpsRequest } from 'node:https';
|
|
19
19
|
import { Readable } from 'node:stream';
|
|
20
|
+
import { tunnelDomainFor } from './tunnelDomains.js';
|
|
21
|
+
import { getControlProbe } from '../services/tunnel/probeRegistry.js';
|
|
20
22
|
export async function probeLocalPort(port, opts = {}) {
|
|
21
23
|
const host = opts.host ?? '127.0.0.1';
|
|
22
24
|
const timeoutMs = opts.timeoutMs ?? 1500;
|
|
@@ -91,7 +93,16 @@ const TRANSIENT_CAUSE_CODES = new Set([
|
|
|
91
93
|
* other, and coupling them would make this module depend on policy it has no
|
|
92
94
|
* business knowing.
|
|
93
95
|
*/
|
|
94
|
-
const ENDPOINT_NOT_FOUND_CODES = new Set([
|
|
96
|
+
const ENDPOINT_NOT_FOUND_CODES = new Set([
|
|
97
|
+
'ERR_NGROK_3200',
|
|
98
|
+
// The debugg analogue. The server already holds a browser request for
|
|
99
|
+
// RECONNECT_GRACE_MS (5s) while a client reconnects before rendering this,
|
|
100
|
+
// but a reconnect that outlasts the grace window would otherwise evict a
|
|
101
|
+
// tunnel that is about to come back, so confirm it across the ladder too.
|
|
102
|
+
// DEBUGG_TUNNEL_UNKNOWN is deliberately NOT here: the id is gone, and
|
|
103
|
+
// re-probing cannot change that answer.
|
|
104
|
+
'DEBUGG_TUNNEL_OFFLINE',
|
|
105
|
+
]);
|
|
95
106
|
// ─ HTTP/1.1 probe transport (bead kmzb) ──────────────────────────────────────
|
|
96
107
|
/**
|
|
97
108
|
* `fetch`-shaped GET that speaks HTTP/1.1, because the global fetch cannot.
|
|
@@ -241,6 +252,16 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
|
|
|
241
252
|
}
|
|
242
253
|
async function probeOnce(tunnelUrl, opts, started) {
|
|
243
254
|
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
255
|
+
// A debugg tunnel hostname resolves only inside our VPC, so it cannot be
|
|
256
|
+
// fetched from the user's machine at all. Its health question goes over the
|
|
257
|
+
// tunnel's own control websocket instead, and the server answers it by
|
|
258
|
+
// making a REAL request through the public ingress path — same ALB, same
|
|
259
|
+
// nginx, same routing a browser takes. Everything below this branch (the
|
|
260
|
+
// retry ladder, the confirm-across-retries rule, the result shape the
|
|
261
|
+
// handlers render) is shared by both transports.
|
|
262
|
+
if (tunnelDomainFor(tunnelUrl)?.transport === 'debugg') {
|
|
263
|
+
return probeOverControlChannel(tunnelUrl, timeoutMs, started);
|
|
264
|
+
}
|
|
244
265
|
// Bead kmzb: HTTP/1.1, not the global fetch — see http1Fetch.
|
|
245
266
|
const fetchImpl = opts.fetchFn ?? http1Fetch;
|
|
246
267
|
const controller = new AbortController();
|
|
@@ -345,6 +366,118 @@ async function probeOnce(tunnelUrl, opts, started) {
|
|
|
345
366
|
};
|
|
346
367
|
}
|
|
347
368
|
}
|
|
369
|
+
// ─ Control-channel probe (debugg transport) ──────────────────────────────────
|
|
370
|
+
/**
|
|
371
|
+
* Ask the tunnel server to fetch `path` back through this tunnel and report
|
|
372
|
+
* what it got. Maps PROBE_RESULT onto the same TunnelHealthProbeResult the
|
|
373
|
+
* HTTP path produces — the mapping is pinned on bead debugg_ai_mcp-xkoh.1.2 §9
|
|
374
|
+
* and must not be re-derived here.
|
|
375
|
+
*/
|
|
376
|
+
async function probeOverControlChannel(tunnelUrl, timeoutMs, started) {
|
|
377
|
+
const elapsed = () => Date.now() - started;
|
|
378
|
+
const probe = getControlProbe(tunnelUrl);
|
|
379
|
+
if (!probe) {
|
|
380
|
+
// No live control channel in this process for that hostname, and the URL
|
|
381
|
+
// is unreachable any other way. The tunnel is not coming back on its own,
|
|
382
|
+
// so report it gone and let the disposition policy clear the leftovers.
|
|
383
|
+
return {
|
|
384
|
+
retryable: false,
|
|
385
|
+
result: {
|
|
386
|
+
healthy: false,
|
|
387
|
+
code: 'NGROK_ERROR',
|
|
388
|
+
ngrokErrorCode: 'DEBUGG_TUNNEL_UNKNOWN',
|
|
389
|
+
detail: 'no live debugg tunnel session in this process for this hostname',
|
|
390
|
+
elapsedMs: elapsed(),
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
let probeResult;
|
|
395
|
+
try {
|
|
396
|
+
probeResult = await probe(probePathOf(tunnelUrl), { timeoutMs });
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
// The probe API's contract is that it never throws. Belt and braces: a
|
|
400
|
+
// probe we could not run is not evidence of a fault.
|
|
401
|
+
return {
|
|
402
|
+
retryable: true,
|
|
403
|
+
result: {
|
|
404
|
+
healthy: false,
|
|
405
|
+
code: 'NETWORK_ERROR',
|
|
406
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
407
|
+
elapsedMs: elapsed(),
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const { status, marker, error } = probeResult;
|
|
412
|
+
if (marker) {
|
|
413
|
+
const notFound = ENDPOINT_NOT_FOUND_CODES.has(marker);
|
|
414
|
+
return {
|
|
415
|
+
retryable: notFound,
|
|
416
|
+
result: {
|
|
417
|
+
healthy: false,
|
|
418
|
+
status,
|
|
419
|
+
code: 'NGROK_ERROR',
|
|
420
|
+
ngrokErrorCode: marker,
|
|
421
|
+
detail: notFound
|
|
422
|
+
? `the tunnel server returned ${marker} — no client is connected for this tunnel`
|
|
423
|
+
: `the tunnel server returned ${marker} — tunnel established but traffic could not reach the dev server`,
|
|
424
|
+
elapsedMs: elapsed(),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (status !== undefined) {
|
|
429
|
+
if (status === 502 || status === 504) {
|
|
430
|
+
return {
|
|
431
|
+
retryable: false,
|
|
432
|
+
result: {
|
|
433
|
+
healthy: false,
|
|
434
|
+
status,
|
|
435
|
+
code: 'BAD_GATEWAY',
|
|
436
|
+
detail: `tunnel returned ${status} without an error marker — gateway is rejecting upstream`,
|
|
437
|
+
elapsedMs: elapsed(),
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// Any other status means traffic reached the dev server, which is healthy
|
|
442
|
+
// from the TUNNEL's point of view. A user's own 404 is a user concern.
|
|
443
|
+
return { retryable: false, result: { healthy: true, status, elapsedMs: elapsed() } };
|
|
444
|
+
}
|
|
445
|
+
if (error === 'TIMEOUT') {
|
|
446
|
+
// Not retried, for the same reason the HTTP path does not: a hanging
|
|
447
|
+
// tunnel must not cost three times the timeout budget.
|
|
448
|
+
return {
|
|
449
|
+
retryable: false,
|
|
450
|
+
result: {
|
|
451
|
+
healthy: false,
|
|
452
|
+
code: 'TIMEOUT',
|
|
453
|
+
detail: `tunnel health probe timed out after ${timeoutMs}ms`,
|
|
454
|
+
elapsedMs: elapsed(),
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
// Anything else (RESET, CLOSED, ...) is a connection-level failure. It is
|
|
459
|
+
// retried, because a session that is mid-reconnect reports exactly this and
|
|
460
|
+
// recovers within the ladder.
|
|
461
|
+
return {
|
|
462
|
+
retryable: true,
|
|
463
|
+
result: {
|
|
464
|
+
healthy: false,
|
|
465
|
+
code: 'NETWORK_ERROR',
|
|
466
|
+
detail: `tunnel control channel reported ${error ?? 'an unknown failure'}`,
|
|
467
|
+
elapsedMs: elapsed(),
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/** The path (with search) a probe should request, defaulting to root. */
|
|
472
|
+
function probePathOf(tunnelUrl) {
|
|
473
|
+
try {
|
|
474
|
+
const url = new URL(tunnelUrl);
|
|
475
|
+
return `${url.pathname}${url.search}` || '/';
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return '/';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
348
481
|
// ─ helpers ───────────────────────────────────────────────────────────────────
|
|
349
482
|
async function readCapped(res, maxBytes) {
|
|
350
483
|
if (!res.body)
|
|
@@ -375,8 +508,32 @@ async function readCapped(res, maxBytes) {
|
|
|
375
508
|
}
|
|
376
509
|
return out;
|
|
377
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* The debugg tunnel server's error markers, spelled out rather than matched by
|
|
513
|
+
* a `DEBUGG_TUNNEL_[A-Z_]+` pattern.
|
|
514
|
+
*
|
|
515
|
+
* A pattern would also match this repo's own `DEBUGG_TUNNEL_FAULT_MODE` env var
|
|
516
|
+
* (services/ngrok/tunnelFaultInjection.ts). This function is used on RUN
|
|
517
|
+
* EVIDENCE as well as on response bodies (testPageChangesHandler's
|
|
518
|
+
* findNgrokErrorMarker), so a log line mentioning that variable would otherwise
|
|
519
|
+
* be read as proof that the browser hit our error page, and a genuine UI
|
|
520
|
+
* failure would be reclassified as an infrastructure fault.
|
|
521
|
+
*/
|
|
522
|
+
const DEBUGG_TUNNEL_MARKERS = Object.freeze([
|
|
523
|
+
'DEBUGG_TUNNEL_OFFLINE',
|
|
524
|
+
'DEBUGG_TUNNEL_UNKNOWN',
|
|
525
|
+
'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
|
|
526
|
+
]);
|
|
527
|
+
const MARKER_PATTERN = new RegExp(`ERR_NGROK_\\d+|${DEBUGG_TUNNEL_MARKERS.join('|')}`);
|
|
528
|
+
/**
|
|
529
|
+
* The stable error marker a tunnel put in front of the user's app, from either
|
|
530
|
+
* transport: ngrok's `ERR_NGROK_*` or the debugg server's `DEBUGG_TUNNEL_*`.
|
|
531
|
+
*
|
|
532
|
+
* Name kept (and with it `TunnelHealthProbeResult.ngrokErrorCode` and
|
|
533
|
+
* `code: 'NGROK_ERROR'`) because handlers echo these into tool output;
|
|
534
|
+
* renaming them is part of deleting ngrok, not of adding a transport.
|
|
535
|
+
*/
|
|
378
536
|
export function extractNgrokErrorCode(body) {
|
|
379
|
-
|
|
380
|
-
const match = body.match(/ERR_NGROK_\d+/);
|
|
537
|
+
const match = body.match(MARKER_PATTERN);
|
|
381
538
|
return match ? match[0] : undefined;
|
|
382
539
|
}
|
|
@@ -61,12 +61,15 @@ export function findExistingTunnel(ctx) {
|
|
|
61
61
|
* @param tunnelId - ID to use as the ngrok subdomain (only takes effect the
|
|
62
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
|
-
* @param revokeKey - Callback that revokes the backend
|
|
64
|
+
* @param revokeKey - Callback that revokes the backend tunnel (called when tunnel stops)
|
|
65
|
+
* @param selection - The provision response's transport fields. Omitted means
|
|
66
|
+
* ngrok, which is what an old backend implies, so every
|
|
67
|
+
* existing caller keeps its meaning.
|
|
65
68
|
*/
|
|
66
|
-
export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey) {
|
|
69
|
+
export async function ensureTunnel(ctx, tunnelKey, tunnelId, keyId, revokeKey, selection) {
|
|
67
70
|
if (!ctx.isLocalhost)
|
|
68
71
|
return ctx;
|
|
69
|
-
const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey);
|
|
72
|
+
const info = await tunnelManager.ensureSessionTunnel(getSessionKey(), tunnelKey, tunnelId, keyId, revokeKey, selection);
|
|
70
73
|
return { ...ctx, tunnelId: info.tunnelId, targetUrl: retargetTunnelUrl(info.tunnelUrl, ctx.originalUrl) };
|
|
71
74
|
}
|
|
72
75
|
/**
|
|
@@ -72,7 +72,22 @@ const logger = new Logger({ module: 'tunnelDisposition' });
|
|
|
72
72
|
* a "frozen" Set still succeeds silently. Freezing the array is a real runtime
|
|
73
73
|
* guarantee; the lookup Set is derived from it and kept private.
|
|
74
74
|
*/
|
|
75
|
-
const ENDPOINT_GONE_CODES = Object.freeze([
|
|
75
|
+
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
|
+
'DEBUGG_TUNNEL_OFFLINE',
|
|
89
|
+
'DEBUGG_TUNNEL_UNKNOWN',
|
|
90
|
+
]);
|
|
76
91
|
const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
|
|
77
92
|
/** The allowlist, as an immutable list. Read-only by construction. */
|
|
78
93
|
export const ENDPOINT_GONE_NGROK_CODES = ENDPOINT_GONE_CODES;
|