@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,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* debuggTransport — the pure-TS tunnel client for the debugg tunnel server.
|
|
3
|
+
*
|
|
4
|
+
* One websocket per tunnel to the relayUrl a provision response supplies,
|
|
5
|
+
* speaking the wire protocol in services/tunnel/protocol/ (spec: bead
|
|
6
|
+
* debugg_ai_mcp-xkoh.1.2). It replaces the `ngrok` package, which downloads and
|
|
7
|
+
* spawns a native agent; `ws` is the only new dependency and is pure JS.
|
|
8
|
+
*
|
|
9
|
+
* This file owns exactly four things — the socket, the reconnect policy, the
|
|
10
|
+
* DIAL policy, and publishing a prober for probeTunnelHealth. Framing, flow
|
|
11
|
+
* control, liveness, GOAWAY drain and PROBE deadlines all belong to
|
|
12
|
+
* TunnelSession, and are deliberately not reimplemented here.
|
|
13
|
+
*
|
|
14
|
+
* THE DIAL POLICY IS THE SECURITY BOUNDARY: a stream is always piped to the
|
|
15
|
+
* address TunnelManager registered at connect() and never to anything the
|
|
16
|
+
* server sends. The protocol's decoder already strips address-shaped fields
|
|
17
|
+
* from OPEN metadata; this is the second half of that guarantee.
|
|
18
|
+
*/
|
|
19
|
+
import * as net from 'node:net';
|
|
20
|
+
import * as tls from 'node:tls';
|
|
21
|
+
import WebSocket from 'ws';
|
|
22
|
+
import { Logger } from '../../utils/logger.js';
|
|
23
|
+
import { buildHandshakeHeaders, interpretHandshakeResponse, webSocketTransport, CloseCode, ErrorCode, MAX_FRAME_SIZE, PROBE_TIMEOUT_MS, TunnelSession, } from './protocol/index.js';
|
|
24
|
+
import { registerControlProbe, unregisterControlProbe } from './probeRegistry.js';
|
|
25
|
+
const logger = new Logger({ module: 'debuggTransport' });
|
|
26
|
+
/** A DebuggSocketLike is always usable as the protocol module's WebSocketLike. */
|
|
27
|
+
const _socketIsWebSocketLike = (s) => s;
|
|
28
|
+
void _socketIsWebSocketLike;
|
|
29
|
+
const DEFAULT_RECONNECT_BACKOFF_MS = [250, 500, 1000, 2000, 5000, 10000];
|
|
30
|
+
const defaultSocketFactory = (url, init) => new WebSocket(url, {
|
|
31
|
+
headers: init.headers,
|
|
32
|
+
// Both ends disable it: dev-server payloads are already compressed and
|
|
33
|
+
// deflate's per-connection buffers break the protocol's bounded-memory
|
|
34
|
+
// property (bead debugg_ai_mcp-xkoh.1.2 §0).
|
|
35
|
+
perMessageDeflate: false,
|
|
36
|
+
maxPayload: MAX_FRAME_SIZE,
|
|
37
|
+
followRedirects: false,
|
|
38
|
+
});
|
|
39
|
+
class DebuggTransport {
|
|
40
|
+
kind = 'debugg';
|
|
41
|
+
tunnels = new Map();
|
|
42
|
+
clientVersion;
|
|
43
|
+
socketFactory;
|
|
44
|
+
backoffMs;
|
|
45
|
+
probeTimeoutMs;
|
|
46
|
+
handshakeTimeoutMs;
|
|
47
|
+
constructor(opts = {}) {
|
|
48
|
+
this.clientVersion = opts.clientVersion;
|
|
49
|
+
this.socketFactory = opts.socketFactory ?? defaultSocketFactory;
|
|
50
|
+
this.backoffMs = opts.reconnectBackoffMs ?? DEFAULT_RECONNECT_BACKOFF_MS;
|
|
51
|
+
this.probeTimeoutMs = opts.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
|
|
52
|
+
this.handshakeTimeoutMs = opts.handshakeTimeoutMs ?? 20_000;
|
|
53
|
+
}
|
|
54
|
+
async connect(localAddr, hostname, token, opts) {
|
|
55
|
+
if (!opts.relayUrl) {
|
|
56
|
+
throw terminal('debugg tunnel provisioning did not supply a relayUrl to connect to');
|
|
57
|
+
}
|
|
58
|
+
const host = hostname.toLowerCase();
|
|
59
|
+
const state = {
|
|
60
|
+
host,
|
|
61
|
+
hostname,
|
|
62
|
+
publicUrl: `https://${hostname}`,
|
|
63
|
+
tunnelId: opts.tunnelId,
|
|
64
|
+
token,
|
|
65
|
+
relayUrl: opts.relayUrl,
|
|
66
|
+
dial: parseLocalAddr(localAddr),
|
|
67
|
+
onDead: opts.onDead,
|
|
68
|
+
stopped: false,
|
|
69
|
+
dead: false,
|
|
70
|
+
reconnectAttempt: 0,
|
|
71
|
+
};
|
|
72
|
+
const { socket, session } = await this.handshake(state);
|
|
73
|
+
this.adopt(state, socket, session);
|
|
74
|
+
this.tunnels.set(host, state);
|
|
75
|
+
// Published so probeTunnelHealth can health-check a hostname that resolves
|
|
76
|
+
// only inside our VPC (services/tunnel/probeRegistry.ts).
|
|
77
|
+
registerControlProbe(host, (path, probeOpts) => this.runProbe(state, path, probeOpts));
|
|
78
|
+
logger.info(`debugg tunnel connected: ${state.publicUrl} -> ${describeDial(state.dial)}`);
|
|
79
|
+
return state.publicUrl;
|
|
80
|
+
}
|
|
81
|
+
async disconnect(publicUrl) {
|
|
82
|
+
const state = this.tunnels.get(hostOf(publicUrl));
|
|
83
|
+
if (!state)
|
|
84
|
+
return;
|
|
85
|
+
state.stopped = true;
|
|
86
|
+
this.teardown(state);
|
|
87
|
+
}
|
|
88
|
+
async probe(publicUrl, path, opts = {}) {
|
|
89
|
+
const state = this.tunnels.get(hostOf(publicUrl));
|
|
90
|
+
if (!state)
|
|
91
|
+
return { error: 'CLOSED', elapsedMs: 0 };
|
|
92
|
+
return this.runProbe(state, path, opts);
|
|
93
|
+
}
|
|
94
|
+
// ── Handshake ──────────────────────────────────────────────────────────────
|
|
95
|
+
handshake(state) {
|
|
96
|
+
const headers = buildHandshakeHeaders({
|
|
97
|
+
tunnelId: state.tunnelId,
|
|
98
|
+
tunnelKey: state.token,
|
|
99
|
+
...(this.clientVersion ? { clientVersion: this.clientVersion } : {}),
|
|
100
|
+
});
|
|
101
|
+
// The token rides in a header and NEVER in the URL: the ALB and nginx log
|
|
102
|
+
// URLs, not headers.
|
|
103
|
+
const socket = this.socketFactory(state.relayUrl, { headers });
|
|
104
|
+
// ATTACH THE SESSION NOW, not on 'open', and not after connect() resolves.
|
|
105
|
+
//
|
|
106
|
+
// The server registers a tunnel the instant the handshake completes and may
|
|
107
|
+
// send OPEN in that same tick, before any post-handshake wiring could run.
|
|
108
|
+
// A session (or a 'stream' listener) attached even one microtask later
|
|
109
|
+
// parses those frames into nothing: the browser request hangs with no error
|
|
110
|
+
// on either side. It is invisible to a unit test that awaits connect()
|
|
111
|
+
// first, and it lands precisely in the reconnect gap — a deploy or a
|
|
112
|
+
// dropped socket is exactly what puts requests there — so in production it
|
|
113
|
+
// reads as a rare, unexplainable hang instead of a clean failure.
|
|
114
|
+
// (Cross-arc regression found by the tunnel server's integration tests.)
|
|
115
|
+
const session = new TunnelSession(webSocketTransport(socket), { role: 'client' });
|
|
116
|
+
session.on('stream', (stream) => this.pipeToLocal(state, stream));
|
|
117
|
+
// Non-fatal diagnostics. The session only emits these when something is
|
|
118
|
+
// listening, and every stream's own 'error' is sunk by the session.
|
|
119
|
+
session.on('error', (err) => {
|
|
120
|
+
logger.debug(`debugg tunnel ${state.hostname} session error: ${err.message}`);
|
|
121
|
+
});
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
let settled = false;
|
|
124
|
+
let versionNegotiated = false;
|
|
125
|
+
let upgraded = false;
|
|
126
|
+
/**
|
|
127
|
+
* Tear down a session that never made it into service, so its timers and
|
|
128
|
+
* socket do not outlive the failed attempt. A socket that reached 101 can
|
|
129
|
+
* be closed politely with a code; one that never upgraded has no
|
|
130
|
+
* websocket to close, so the request is aborted instead (which is also
|
|
131
|
+
* what `ws` needs after 'unexpected-response' — it does not clean up the
|
|
132
|
+
* socket for you once you have registered that listener).
|
|
133
|
+
*/
|
|
134
|
+
const abandon = (closeCode) => {
|
|
135
|
+
try {
|
|
136
|
+
if (upgraded && closeCode !== undefined && !session.closed) {
|
|
137
|
+
session.close(closeCode, 'handshake rejected');
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
socket.terminate();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* already gone */
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
fail(egressError(state, 'the connection timed out', 'ETIMEDOUT'));
|
|
149
|
+
abandon();
|
|
150
|
+
}, this.handshakeTimeoutMs);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
const done = () => { clearTimeout(timer); settled = true; };
|
|
153
|
+
const fail = (err) => {
|
|
154
|
+
if (settled)
|
|
155
|
+
return;
|
|
156
|
+
done();
|
|
157
|
+
reject(err);
|
|
158
|
+
};
|
|
159
|
+
socket.on('upgrade', (res) => {
|
|
160
|
+
if (settled)
|
|
161
|
+
return;
|
|
162
|
+
upgraded = true;
|
|
163
|
+
const result = interpretHandshakeResponse(res.statusCode ?? 101, res.headers ?? {});
|
|
164
|
+
if (result.ok) {
|
|
165
|
+
versionNegotiated = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// A 101 we cannot negotiate: speaking anyway would corrupt the stream.
|
|
169
|
+
abandon(ErrorCode.PROTOCOL_ERROR);
|
|
170
|
+
fail(terminal('the debugg tunnel server accepted the connection but did not echo a protocol version this ' +
|
|
171
|
+
'client offered. Update @debugg-ai/debugg-ai-mcp.'));
|
|
172
|
+
});
|
|
173
|
+
socket.on('open', () => {
|
|
174
|
+
if (settled)
|
|
175
|
+
return;
|
|
176
|
+
upgraded = true;
|
|
177
|
+
if (!versionNegotiated) {
|
|
178
|
+
abandon(ErrorCode.PROTOCOL_ERROR);
|
|
179
|
+
fail(terminal('the debugg tunnel server accepted the connection without negotiating a protocol version. ' +
|
|
180
|
+
'Update @debugg-ai/debugg-ai-mcp.'));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
done();
|
|
184
|
+
resolve({ socket, session });
|
|
185
|
+
});
|
|
186
|
+
socket.on('unexpected-response', (_req, res) => {
|
|
187
|
+
const status = res?.statusCode ?? 0;
|
|
188
|
+
const result = interpretHandshakeResponse(status, res?.headers ?? {});
|
|
189
|
+
abandon();
|
|
190
|
+
fail(handshakeFailureError(state, status, result.ok ? undefined : result.supported, result.ok ? true : result.retryable));
|
|
191
|
+
});
|
|
192
|
+
socket.on('error', (err) => {
|
|
193
|
+
// Before the upgrade completes this is always a connection-level
|
|
194
|
+
// failure: DNS, TLS, refused, or blocked egress.
|
|
195
|
+
if (!settled)
|
|
196
|
+
abandon();
|
|
197
|
+
fail(egressError(state, err.message, err.code));
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// ── Session lifecycle ──────────────────────────────────────────────────────
|
|
202
|
+
adopt(state, socket, session) {
|
|
203
|
+
state.socket = socket;
|
|
204
|
+
state.session = session;
|
|
205
|
+
state.reconnectAttempt = 0;
|
|
206
|
+
// 'stream' and 'error' were attached in handshake(), before the handshake
|
|
207
|
+
// could deliver anything. Only the lifecycle wiring belongs here, because
|
|
208
|
+
// it must NOT fire for a session that never made it into service.
|
|
209
|
+
session.on('goaway', (info) => {
|
|
210
|
+
if (info.code === ErrorCode.REVOKED) {
|
|
211
|
+
this.markDead(state, `the tunnel server reported it revoked (${info.reason || 'no reason given'})`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// A deploy. Dial a replacement immediately (make-before-break) and let
|
|
215
|
+
// this session drain its in-flight streams; its later close is ignored
|
|
216
|
+
// because it is no longer the current one.
|
|
217
|
+
this.scheduleReconnect(state, 0);
|
|
218
|
+
});
|
|
219
|
+
session.on('close', (info) => {
|
|
220
|
+
if (state.session !== session)
|
|
221
|
+
return; // superseded by a newer connection
|
|
222
|
+
state.session = undefined;
|
|
223
|
+
state.socket = undefined;
|
|
224
|
+
if (state.stopped || state.dead)
|
|
225
|
+
return;
|
|
226
|
+
logger.warn(`debugg tunnel ${state.hostname} lost its control channel (${info.closeCode}); reconnecting`);
|
|
227
|
+
this.scheduleReconnect(state);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
scheduleReconnect(state, delayOverrideMs) {
|
|
231
|
+
if (state.stopped || state.dead || state.reconnectTimer)
|
|
232
|
+
return;
|
|
233
|
+
const delay = delayOverrideMs ?? this.backoffMs[Math.min(state.reconnectAttempt, this.backoffMs.length - 1)];
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
state.reconnectTimer = undefined;
|
|
236
|
+
void this.reconnect(state);
|
|
237
|
+
}, delay);
|
|
238
|
+
// Never hold the process open just because a tunnel wants to come back.
|
|
239
|
+
timer.unref?.();
|
|
240
|
+
state.reconnectTimer = timer;
|
|
241
|
+
}
|
|
242
|
+
async reconnect(state) {
|
|
243
|
+
if (state.stopped || state.dead)
|
|
244
|
+
return;
|
|
245
|
+
try {
|
|
246
|
+
const { socket, session } = await this.handshake(state);
|
|
247
|
+
if (state.stopped || state.dead) {
|
|
248
|
+
try {
|
|
249
|
+
socket.close(CloseCode.GOING_AWAY, 'tunnel stopped');
|
|
250
|
+
}
|
|
251
|
+
catch { /* ignore */ }
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const previous = state.session;
|
|
255
|
+
this.adopt(state, socket, session);
|
|
256
|
+
// The old session keeps draining; it is closed by the server, and its
|
|
257
|
+
// close event is ignored now that it is not the current one.
|
|
258
|
+
if (previous && previous !== session) {
|
|
259
|
+
void previous.goAway({ code: ErrorCode.GOING_AWAY, reason: 'replaced' }).catch(() => { });
|
|
260
|
+
}
|
|
261
|
+
logger.info(`debugg tunnel ${state.hostname} reconnected`);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
const error = err;
|
|
265
|
+
if (error?.retryable === false) {
|
|
266
|
+
this.markDead(state, error.message);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
state.reconnectAttempt++;
|
|
270
|
+
this.scheduleReconnect(state);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The tunnel can never come back. TunnelManager evicts it, so the next call
|
|
275
|
+
* re-provisions instead of reusing a corpse.
|
|
276
|
+
*/
|
|
277
|
+
markDead(state, reason) {
|
|
278
|
+
if (state.dead)
|
|
279
|
+
return;
|
|
280
|
+
state.dead = true;
|
|
281
|
+
logger.warn(`debugg tunnel ${state.hostname} is permanently gone: ${reason}`);
|
|
282
|
+
const notify = state.onDead;
|
|
283
|
+
this.teardown(state);
|
|
284
|
+
notify?.(reason);
|
|
285
|
+
}
|
|
286
|
+
teardown(state) {
|
|
287
|
+
if (state.reconnectTimer) {
|
|
288
|
+
clearTimeout(state.reconnectTimer);
|
|
289
|
+
state.reconnectTimer = undefined;
|
|
290
|
+
}
|
|
291
|
+
unregisterControlProbe(state.host);
|
|
292
|
+
this.tunnels.delete(state.host);
|
|
293
|
+
const session = state.session;
|
|
294
|
+
const socket = state.socket;
|
|
295
|
+
state.session = undefined;
|
|
296
|
+
state.socket = undefined;
|
|
297
|
+
try {
|
|
298
|
+
session?.close(ErrorCode.GOING_AWAY, 'client disconnect');
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
/* already closed */
|
|
302
|
+
}
|
|
303
|
+
if (!session && socket) {
|
|
304
|
+
try {
|
|
305
|
+
socket.close(CloseCode.GOING_AWAY, 'client disconnect');
|
|
306
|
+
}
|
|
307
|
+
catch { /* ignore */ }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// ── The dial policy ────────────────────────────────────────────────────────
|
|
311
|
+
pipeToLocal(state, stream) {
|
|
312
|
+
const { host, port, tls: useTls } = state.dial;
|
|
313
|
+
const socket = useTls
|
|
314
|
+
// Local dev certificates are self-signed by definition, and Caddy's own
|
|
315
|
+
// upstream handling makes the same choice (insecure_skip_verify).
|
|
316
|
+
? tls.connect({ host, port, servername: host, rejectUnauthorized: false })
|
|
317
|
+
: net.connect({ host, port });
|
|
318
|
+
let established = false;
|
|
319
|
+
const ready = useTls ? 'secureConnect' : 'connect';
|
|
320
|
+
socket.once(ready, () => {
|
|
321
|
+
established = true;
|
|
322
|
+
stream.pipe(socket);
|
|
323
|
+
socket.pipe(stream);
|
|
324
|
+
});
|
|
325
|
+
socket.on('error', () => {
|
|
326
|
+
if (!established) {
|
|
327
|
+
// The ERR_NGROK_8012 analogue: the tunnel is fine, the app refused.
|
|
328
|
+
// The server renders DEBUGG_TUNNEL_UPSTREAM_REFUSED from this code and
|
|
329
|
+
// tunnelDisposition keeps the tunnel.
|
|
330
|
+
stream.reset(ErrorCode.UPSTREAM_UNREACHABLE);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
stream.reset(ErrorCode.RESET);
|
|
334
|
+
}
|
|
335
|
+
socket.destroy();
|
|
336
|
+
});
|
|
337
|
+
stream.on('close', () => { socket.destroy(); });
|
|
338
|
+
}
|
|
339
|
+
async runProbe(state, path, opts = {}) {
|
|
340
|
+
const session = state.session;
|
|
341
|
+
// Mid-reconnect: report it as a closed channel rather than inventing a
|
|
342
|
+
// verdict. probeTunnelHealth retries that, and a reconnect lands inside
|
|
343
|
+
// its ladder.
|
|
344
|
+
if (!session)
|
|
345
|
+
return { error: 'CLOSED', elapsedMs: 0 };
|
|
346
|
+
return session.probe(path, { timeoutMs: opts.timeoutMs ?? this.probeTimeoutMs });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ── Errors ───────────────────────────────────────────────────────────────────
|
|
350
|
+
function tunnelError(message, retryable) {
|
|
351
|
+
const err = new Error(message);
|
|
352
|
+
err.retryable = retryable;
|
|
353
|
+
return err;
|
|
354
|
+
}
|
|
355
|
+
/** Terminal for this token or this build: retrying sends the same thing again. */
|
|
356
|
+
function terminal(message) {
|
|
357
|
+
return tunnelError(message, false);
|
|
358
|
+
}
|
|
359
|
+
function handshakeFailureError(state, status, supported, retryable) {
|
|
360
|
+
switch (status) {
|
|
361
|
+
case 401:
|
|
362
|
+
// The word "authtoken" is load-bearing: TunnelManager matches it to
|
|
363
|
+
// render its "invalid auth token" message, the same one an ngrok auth
|
|
364
|
+
// failure produces.
|
|
365
|
+
return terminal('the debugg tunnel server rejected the authtoken (HTTP 401) — the tunnel key is invalid, ' +
|
|
366
|
+
'expired or revoked. A new one is provisioned on the next call.');
|
|
367
|
+
case 400:
|
|
368
|
+
return terminal(`the debugg tunnel server rejected the tunnel id "${state.tunnelId}" (HTTP 400)`);
|
|
369
|
+
case 426:
|
|
370
|
+
return terminal('this MCP speaks a tunnel protocol version the server no longer supports (HTTP 426' +
|
|
371
|
+
`${supported ? `, server supports ${supported.join(', ')}` : ''}). ` +
|
|
372
|
+
'Update @debugg-ai/debugg-ai-mcp.');
|
|
373
|
+
case 407:
|
|
374
|
+
return terminal(`an HTTP proxy at your network edge intercepted the tunnel connection to ${hostOf(state.relayUrl)} ` +
|
|
375
|
+
'and demanded authentication (HTTP 407). The tunnel needs a DIRECT outbound HTTPS (443) ' +
|
|
376
|
+
'connection; proxies are not supported.');
|
|
377
|
+
default:
|
|
378
|
+
return tunnelError(`the debugg tunnel server refused the connection (HTTP ${status})`, retryable);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* A connection-level failure reaching the relay. This is the one place where
|
|
383
|
+
* the debugg transport can be WORSE than ngrok for a user — ngrok's agent
|
|
384
|
+
* honours HTTPS_PROXY and this client does not — so the message says plainly
|
|
385
|
+
* what has to be allowed instead of reading as a generic tunnel error.
|
|
386
|
+
*/
|
|
387
|
+
function egressError(state, detail, code) {
|
|
388
|
+
const relayHost = hostOf(state.relayUrl);
|
|
389
|
+
const proxyVar = ['HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy'].find((name) => process.env[name]);
|
|
390
|
+
const proxyNote = proxyVar
|
|
391
|
+
? ` This environment sets ${proxyVar}, which the tunnel connection does NOT use — HTTP(S) proxies are not supported for it.`
|
|
392
|
+
: ' HTTP(S) proxies are not supported for the tunnel connection.';
|
|
393
|
+
return tunnelError(`could not reach the debugg tunnel server at ${relayHost} (${code ?? 'connection failed'}: ${detail}). ` +
|
|
394
|
+
`Testing a local app needs a direct outbound HTTPS connection to ${relayHost} on port 443 — ` +
|
|
395
|
+
`allow it in your firewall, VPN or container network rules.${proxyNote}`, true);
|
|
396
|
+
}
|
|
397
|
+
// ── Address handling ─────────────────────────────────────────────────────────
|
|
398
|
+
/**
|
|
399
|
+
* The five forms TunnelManager registers: Caddy's `http://127.0.0.1:<p>` for a
|
|
400
|
+
* session tunnel (in Docker too — Caddy runs in the same container), and
|
|
401
|
+
* `127.0.0.1:<p>`, `host.docker.internal:<p>`, `https://localhost:<p>` or
|
|
402
|
+
* `https://host.docker.internal:<p>` for the dedicated test_suite path.
|
|
403
|
+
*/
|
|
404
|
+
function parseLocalAddr(localAddr) {
|
|
405
|
+
const schemed = /^(https?):\/\//i.exec(localAddr);
|
|
406
|
+
if (schemed) {
|
|
407
|
+
const url = new URL(localAddr);
|
|
408
|
+
const useTls = schemed[1].toLowerCase() === 'https';
|
|
409
|
+
return {
|
|
410
|
+
host: url.hostname.replace(/^\[|\]$/g, ''),
|
|
411
|
+
port: url.port ? Number(url.port) : (useTls ? 443 : 80),
|
|
412
|
+
tls: useTls,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const lastColon = localAddr.lastIndexOf(':');
|
|
416
|
+
if (lastColon === -1) {
|
|
417
|
+
throw terminal(`debugg tunnel cannot dial "${localAddr}": no port`);
|
|
418
|
+
}
|
|
419
|
+
const host = localAddr.slice(0, lastColon).replace(/^\[|\]$/g, '');
|
|
420
|
+
const port = Number(localAddr.slice(lastColon + 1));
|
|
421
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
422
|
+
throw terminal(`debugg tunnel cannot dial "${localAddr}": no port`);
|
|
423
|
+
}
|
|
424
|
+
return { host, port, tls: false };
|
|
425
|
+
}
|
|
426
|
+
function describeDial(dial) {
|
|
427
|
+
return `${dial.tls ? 'https' : 'http'}://${dial.host}:${dial.port}`;
|
|
428
|
+
}
|
|
429
|
+
function hostOf(url) {
|
|
430
|
+
try {
|
|
431
|
+
return new URL(url).hostname.toLowerCase();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return url;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
export function createDebuggTransport(options = {}) {
|
|
438
|
+
return new DebuggTransport(options);
|
|
439
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ngrokTransport — today's ngrok client, behind the TunnelTransport seam.
|
|
3
|
+
*
|
|
4
|
+
* This is a MOVE, not a rewrite. Every line here came out of
|
|
5
|
+
* services/ngrok/tunnelManager.ts unchanged, because the whole point of the
|
|
6
|
+
* extraction is that the ngrok path keeps behaving exactly as it did while the
|
|
7
|
+
* debugg transport is rolled out (design §5, bead debugg_ai_mcp-xkoh.5.5): the
|
|
8
|
+
* lazy module load, `getApi()` bootstrap, the agent pre-warm, the connect
|
|
9
|
+
* options (including the `https://` addr form), disconnect, and the
|
|
10
|
+
* module-reset-when-no-tunnels-remain behaviour.
|
|
11
|
+
*
|
|
12
|
+
* It exists so that nothing on the debugg path can reach the `ngrok` package.
|
|
13
|
+
* Until this file was split out, `import('ngrok')` ran on every tunnel
|
|
14
|
+
* creation, every teardown and every retry, which would have meant a binary
|
|
15
|
+
* download on machines that never use ngrok again.
|
|
16
|
+
*
|
|
17
|
+
* Deleted wholesale in the rollout arc's last step, along with
|
|
18
|
+
* services/ngrok/ngrokAgentSession.ts and the `ngrok` dependency.
|
|
19
|
+
*/
|
|
20
|
+
import { Logger } from '../../utils/logger.js';
|
|
21
|
+
import { startAgentSession } from '../ngrok/ngrokAgentSession.js';
|
|
22
|
+
const logger = new Logger({ module: 'ngrokTransport' });
|
|
23
|
+
let ngrokModule = null;
|
|
24
|
+
async function getNgrok() {
|
|
25
|
+
if (!ngrokModule) {
|
|
26
|
+
try {
|
|
27
|
+
ngrokModule = await import('ngrok');
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new Error(`Failed to load ngrok module: ${error}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return ngrokModule;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Reset the cached ngrok module so the next connect() bootstraps a fresh agent.
|
|
37
|
+
* Called when the last tunnel is disconnected and the agent process may have died.
|
|
38
|
+
*/
|
|
39
|
+
function resetNgrokModule() {
|
|
40
|
+
ngrokModule = null;
|
|
41
|
+
}
|
|
42
|
+
class NgrokTransport {
|
|
43
|
+
kind = 'ngrok';
|
|
44
|
+
/** Public so TunnelManager can keep exposing its existing test seams. */
|
|
45
|
+
agentSessionStarter;
|
|
46
|
+
agentSessionTimeoutMs;
|
|
47
|
+
/** Whether the ngrok agent's client session is established (bead pqgj). */
|
|
48
|
+
agentSessionReady = false;
|
|
49
|
+
/** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
|
|
50
|
+
agentSessionPromise = null;
|
|
51
|
+
initialized = false;
|
|
52
|
+
constructor(opts = {}) {
|
|
53
|
+
this.agentSessionStarter = opts.agentSessionStarter ?? startAgentSession;
|
|
54
|
+
this.agentSessionTimeoutMs = opts.agentSessionTimeoutMs ?? 5000;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Bead pqgj: make sure the ngrok agent's client session is established before
|
|
58
|
+
* we ask it for a tunnel, so attempt 1 lands on a ready agent instead of the
|
|
59
|
+
* ~293ms not-ready window that made every single run fail its first connect.
|
|
60
|
+
*
|
|
61
|
+
* Never throws: if the agent can't be pre-warmed (ngrok internals moved, slow
|
|
62
|
+
* session, dead token) we fall through and let the retry ladder do what it
|
|
63
|
+
* did before this fix. The ladder stays a genuine safety net.
|
|
64
|
+
*/
|
|
65
|
+
async prepare(authtoken) {
|
|
66
|
+
await this.ensureInitialized();
|
|
67
|
+
if (this.agentSessionReady)
|
|
68
|
+
return;
|
|
69
|
+
if (!this.agentSessionPromise) {
|
|
70
|
+
this.agentSessionPromise = (async () => {
|
|
71
|
+
let markEstablished;
|
|
72
|
+
const established = new Promise((resolve) => { markEstablished = resolve; });
|
|
73
|
+
await this.agentSessionStarter({
|
|
74
|
+
authtoken,
|
|
75
|
+
onStatusChange: (status) => {
|
|
76
|
+
if (status === 'connected') {
|
|
77
|
+
this.agentSessionReady = true;
|
|
78
|
+
markEstablished();
|
|
79
|
+
}
|
|
80
|
+
else if (status === 'closed') {
|
|
81
|
+
this.agentSessionReady = false;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
onTerminated: () => {
|
|
85
|
+
// Agent process died — next tunnel must re-warm.
|
|
86
|
+
this.agentSessionReady = false;
|
|
87
|
+
this.agentSessionPromise = null;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
let capTimer;
|
|
91
|
+
const cap = new Promise((resolve) => {
|
|
92
|
+
capTimer = setTimeout(resolve, this.agentSessionTimeoutMs);
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
await Promise.race([established, cap]);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (capTimer)
|
|
99
|
+
clearTimeout(capTimer);
|
|
100
|
+
}
|
|
101
|
+
})().catch((err) => {
|
|
102
|
+
// Pre-warm unavailable — not fatal, the ladder covers it.
|
|
103
|
+
this.agentSessionPromise = null;
|
|
104
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
+
logger.debug(`ngrok agent pre-warm unavailable, relying on connect retry ladder: ${msg}`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
await this.agentSessionPromise;
|
|
109
|
+
}
|
|
110
|
+
/** Whether the pre-warm believes the agent session is live (trace/diagnostics). */
|
|
111
|
+
get sessionReady() {
|
|
112
|
+
return this.agentSessionReady;
|
|
113
|
+
}
|
|
114
|
+
async connect(localAddr, hostname, token, _opts) {
|
|
115
|
+
const ngrok = await getNgrok();
|
|
116
|
+
const url = await ngrok.connect({
|
|
117
|
+
proto: 'http',
|
|
118
|
+
addr: localAddr,
|
|
119
|
+
hostname,
|
|
120
|
+
authtoken: token,
|
|
121
|
+
});
|
|
122
|
+
if (!url) {
|
|
123
|
+
throw new Error('ngrok.connect() returned empty URL');
|
|
124
|
+
}
|
|
125
|
+
return url;
|
|
126
|
+
}
|
|
127
|
+
async disconnect(publicUrl) {
|
|
128
|
+
const ngrok = await getNgrok();
|
|
129
|
+
await ngrok.disconnect(publicUrl);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The agent-reset the retry ladder does between attempts 1 and 2 — the
|
|
133
|
+
* "agent died" recovery path that used to be the only retried case.
|
|
134
|
+
*/
|
|
135
|
+
async resetAfterFailedAttempt() {
|
|
136
|
+
resetNgrokModule();
|
|
137
|
+
this.initialized = false;
|
|
138
|
+
await this.ensureInitialized();
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* No ngrok tunnels remain, so the agent process may have exited. Reset the
|
|
142
|
+
* module and init state so the next connect() bootstraps a fresh agent.
|
|
143
|
+
*/
|
|
144
|
+
onAllTunnelsStopped() {
|
|
145
|
+
resetNgrokModule();
|
|
146
|
+
this.initialized = false;
|
|
147
|
+
this.agentSessionReady = false;
|
|
148
|
+
this.agentSessionPromise = null;
|
|
149
|
+
}
|
|
150
|
+
async ensureInitialized() {
|
|
151
|
+
if (this.initialized)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
const ngrok = await getNgrok();
|
|
155
|
+
ngrok.getApi();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// ignore — let connect surface real errors
|
|
159
|
+
}
|
|
160
|
+
this.initialized = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function createNgrokTransport(opts = {}) {
|
|
164
|
+
return new NgrokTransport(opts);
|
|
165
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-channel probers, keyed by public tunnel host.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: debugg tunnel URLs resolve only inside our VPC, so
|
|
5
|
+
* utils/localReachability.ts's probeTunnelHealth cannot fetch them from a
|
|
6
|
+
* user's machine. For those hosts the health check goes over the tunnel's own
|
|
7
|
+
* control websocket (PROBE / PROBE_RESULT) instead, and the server answers by
|
|
8
|
+
* making a real request back through the public ingress path.
|
|
9
|
+
*
|
|
10
|
+
* probeTunnelHealth is a free function that takes nothing but a URL, and it is
|
|
11
|
+
* called from four handlers that hold no transport handle. Rather than thread a
|
|
12
|
+
* new argument through five call sites, debuggTransport registers a prober for
|
|
13
|
+
* the host it just connected and removes it when the tunnel stops; everything
|
|
14
|
+
* else keeps taking the HTTP path unchanged.
|
|
15
|
+
*
|
|
16
|
+
* Module-level state, like the template caches in utils/handlerCaches.ts. It is
|
|
17
|
+
* keyed by hostname, which is globally unique (the tunnel id is a uuid), so two
|
|
18
|
+
* sessions in one process cannot collide.
|
|
19
|
+
*/
|
|
20
|
+
const probes = new Map();
|
|
21
|
+
/** Called by a transport once its control channel is up, for `<id>.tunnel.debugg.ai`. */
|
|
22
|
+
export function registerControlProbe(host, probe) {
|
|
23
|
+
probes.set(normalizeHost(host), probe);
|
|
24
|
+
}
|
|
25
|
+
/** Called when the tunnel stops for good. Safe to call for an unknown host. */
|
|
26
|
+
export function unregisterControlProbe(host) {
|
|
27
|
+
probes.delete(normalizeHost(host));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The prober for a URL's host, or undefined when this process holds no live
|
|
31
|
+
* control channel for it. Accepts a full URL or a bare host.
|
|
32
|
+
*/
|
|
33
|
+
export function getControlProbe(url) {
|
|
34
|
+
return probes.get(normalizeHost(url));
|
|
35
|
+
}
|
|
36
|
+
/** Test hook: forget every registered prober. */
|
|
37
|
+
export function _resetControlProbesForTests() {
|
|
38
|
+
probes.clear();
|
|
39
|
+
}
|
|
40
|
+
function normalizeHost(hostOrUrl) {
|
|
41
|
+
if (typeof hostOrUrl !== 'string')
|
|
42
|
+
return '';
|
|
43
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(hostOrUrl)) {
|
|
44
|
+
try {
|
|
45
|
+
return new URL(hostOrUrl).hostname.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return hostOrUrl.toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return hostOrUrl.split('/')[0].split(':')[0].toLowerCase();
|
|
52
|
+
}
|