@debugg-ai/debugg-ai-mcp 4.3.0 → 4.4.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/dist/handlers/probePageHandler.js +14 -7
- package/dist/handlers/runTestSuiteHandler.js +6 -5
- package/dist/handlers/testPageChangesHandler.js +8 -6
- 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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — frame codec.
|
|
3
|
+
*
|
|
4
|
+
* Wire layout, one frame per websocket BINARY message:
|
|
5
|
+
*
|
|
6
|
+
* offset 0, 1 byte : type (u8)
|
|
7
|
+
* offset 1, 4 bytes: streamId (u32 big-endian)
|
|
8
|
+
* offset 5, N bytes: payload
|
|
9
|
+
*
|
|
10
|
+
* The full table of types, payload shapes and rejection rules is on bead
|
|
11
|
+
* debugg_ai_mcp-xkoh.1.2, and pinned byte-for-byte by vectors/frames.json and
|
|
12
|
+
* vectors/invalid-frames.json.
|
|
13
|
+
*
|
|
14
|
+
* Pure and synchronous: no state, no IO, no timers, and no imports beyond this
|
|
15
|
+
* directory — the module is vendored verbatim into the tunnel server's repo.
|
|
16
|
+
*/
|
|
17
|
+
import { ProtocolError } from './errors.js';
|
|
18
|
+
import { FrameType, HEADER_SIZE, MAX_DATA_PAYLOAD, MAX_ERROR_LENGTH, MAX_GOAWAY_REASON_BYTES, MAX_JSON_PAYLOAD, MAX_KIND_LENGTH, MAX_MARKER_LENGTH, MAX_PROBE_PATH_LENGTH, MAX_REQUEST_ID_LENGTH, MAX_STREAM_ID, MAX_WINDOW, } from './constants.js';
|
|
19
|
+
const TYPE_NAME_BY_CODE = new Map(Object.entries(FrameType).map(([name, code]) => [code, name]));
|
|
20
|
+
const MAX_U64 = (1n << 64n) - 1n;
|
|
21
|
+
/** Strict: invalid UTF-8 is a protocol error, never a replacement character. */
|
|
22
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
23
|
+
function fail(detail) {
|
|
24
|
+
throw new ProtocolError(detail);
|
|
25
|
+
}
|
|
26
|
+
function requireStreamId(streamId, type) {
|
|
27
|
+
if (typeof streamId !== 'number' || !Number.isInteger(streamId) || streamId < 1 || streamId > MAX_STREAM_ID) {
|
|
28
|
+
fail(`${type}: stream id must be an integer in 1..${MAX_STREAM_ID}, got ${String(streamId)}`);
|
|
29
|
+
}
|
|
30
|
+
return streamId;
|
|
31
|
+
}
|
|
32
|
+
function requireSessionStreamId(streamId, type) {
|
|
33
|
+
if (streamId !== 0)
|
|
34
|
+
fail(`${type}: session frames use stream id 0, got ${String(streamId)}`);
|
|
35
|
+
}
|
|
36
|
+
function requireU16(value, type, field) {
|
|
37
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffff) {
|
|
38
|
+
fail(`${type}: ${field} must be a u16, got ${String(value)}`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function header(type, streamId, payloadLength) {
|
|
43
|
+
const buf = Buffer.allocUnsafe(HEADER_SIZE + payloadLength);
|
|
44
|
+
buf.writeUInt8(type, 0);
|
|
45
|
+
buf.writeUInt32BE(streamId >>> 0, 1);
|
|
46
|
+
return buf;
|
|
47
|
+
}
|
|
48
|
+
function encodeJsonPayload(obj, type) {
|
|
49
|
+
const payload = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
50
|
+
if (payload.length > MAX_JSON_PAYLOAD) {
|
|
51
|
+
fail(`${type}: JSON payload is ${payload.length} bytes, limit is ${MAX_JSON_PAYLOAD}`);
|
|
52
|
+
}
|
|
53
|
+
return payload;
|
|
54
|
+
}
|
|
55
|
+
// ── JSON payload shapes ──────────────────────────────────────────────────────
|
|
56
|
+
function checkOptionalString(value, maxLength, type, field) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (typeof value !== 'string')
|
|
60
|
+
fail(`${type}: ${field} must be a string`);
|
|
61
|
+
if (value.length > maxLength)
|
|
62
|
+
fail(`${type}: ${field} is longer than ${maxLength} characters`);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Keep only the fields v1 defines, in spec order. Everything else is dropped —
|
|
67
|
+
* including anything address-shaped, which is what stops a server from ever
|
|
68
|
+
* telling a client where to dial.
|
|
69
|
+
*/
|
|
70
|
+
function normalizeMetadata(value) {
|
|
71
|
+
const kind = checkOptionalString(value.kind, MAX_KIND_LENGTH, 'OPEN', 'kind');
|
|
72
|
+
const requestId = checkOptionalString(value.requestId, MAX_REQUEST_ID_LENGTH, 'OPEN', 'requestId');
|
|
73
|
+
const metadata = {};
|
|
74
|
+
if (kind !== undefined)
|
|
75
|
+
metadata.kind = kind;
|
|
76
|
+
if (requestId !== undefined)
|
|
77
|
+
metadata.requestId = requestId;
|
|
78
|
+
return metadata;
|
|
79
|
+
}
|
|
80
|
+
function normalizeProbeRequest(value) {
|
|
81
|
+
const path = value.path;
|
|
82
|
+
if (typeof path !== 'string')
|
|
83
|
+
fail('PROBE: path is required and must be a string');
|
|
84
|
+
if (!path.startsWith('/'))
|
|
85
|
+
fail('PROBE: path must start with "/" — a probe can never be pointed elsewhere');
|
|
86
|
+
if (path.length > MAX_PROBE_PATH_LENGTH)
|
|
87
|
+
fail(`PROBE: path is longer than ${MAX_PROBE_PATH_LENGTH} characters`);
|
|
88
|
+
return { path };
|
|
89
|
+
}
|
|
90
|
+
function normalizeProbeResult(value) {
|
|
91
|
+
const { status, elapsedMs } = value;
|
|
92
|
+
if (status !== undefined) {
|
|
93
|
+
if (typeof status !== 'number' || !Number.isInteger(status) || status < 100 || status > 599) {
|
|
94
|
+
fail('PROBE_RESULT: status must be an integer in 100..599');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const marker = checkOptionalString(value.marker, MAX_MARKER_LENGTH, 'PROBE_RESULT', 'marker');
|
|
98
|
+
const error = checkOptionalString(value.error, MAX_ERROR_LENGTH, 'PROBE_RESULT', 'error');
|
|
99
|
+
if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs) || elapsedMs < 0) {
|
|
100
|
+
fail('PROBE_RESULT: elapsedMs is required and must be a finite number >= 0');
|
|
101
|
+
}
|
|
102
|
+
// Rebuilt in spec order (status, marker, error, elapsedMs) so the encoding is
|
|
103
|
+
// canonical whatever order the caller's object happened to use.
|
|
104
|
+
const out = { elapsedMs };
|
|
105
|
+
const ordered = {};
|
|
106
|
+
if (status !== undefined)
|
|
107
|
+
ordered.status = status;
|
|
108
|
+
if (marker !== undefined)
|
|
109
|
+
ordered.marker = marker;
|
|
110
|
+
if (error !== undefined)
|
|
111
|
+
ordered.error = error;
|
|
112
|
+
ordered.elapsedMs = out.elapsedMs;
|
|
113
|
+
return ordered;
|
|
114
|
+
}
|
|
115
|
+
function parseJsonObject(payload, type) {
|
|
116
|
+
if (payload.length === 0)
|
|
117
|
+
fail(`${type}: payload must be a JSON object`);
|
|
118
|
+
if (payload.length > MAX_JSON_PAYLOAD) {
|
|
119
|
+
fail(`${type}: JSON payload is ${payload.length} bytes, limit is ${MAX_JSON_PAYLOAD}`);
|
|
120
|
+
}
|
|
121
|
+
let text;
|
|
122
|
+
try {
|
|
123
|
+
text = utf8.decode(payload);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return fail(`${type}: payload is not valid UTF-8`);
|
|
127
|
+
}
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(text);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return fail(`${type}: payload is not valid JSON`);
|
|
134
|
+
}
|
|
135
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
136
|
+
return fail(`${type}: payload must be a JSON object`);
|
|
137
|
+
}
|
|
138
|
+
return parsed;
|
|
139
|
+
}
|
|
140
|
+
// ── encode ───────────────────────────────────────────────────────────────────
|
|
141
|
+
/**
|
|
142
|
+
* Encode one frame. Throws ProtocolError rather than emitting anything the peer
|
|
143
|
+
* would have to reject (oversized DATA, a zero WINDOW increment, streamId 0 on a
|
|
144
|
+
* stream frame, ...).
|
|
145
|
+
*
|
|
146
|
+
* JSON payloads are canonical: known fields only, in spec order, no whitespace.
|
|
147
|
+
*/
|
|
148
|
+
export function encodeFrame(frame) {
|
|
149
|
+
switch (frame.type) {
|
|
150
|
+
case 'OPEN': {
|
|
151
|
+
const streamId = requireStreamId(frame.streamId, 'OPEN');
|
|
152
|
+
const metadata = normalizeMetadata((frame.metadata ?? {}));
|
|
153
|
+
const ordered = {};
|
|
154
|
+
if (metadata.kind !== undefined)
|
|
155
|
+
ordered.kind = metadata.kind;
|
|
156
|
+
if (metadata.requestId !== undefined)
|
|
157
|
+
ordered.requestId = metadata.requestId;
|
|
158
|
+
const payload = encodeJsonPayload(ordered, 'OPEN');
|
|
159
|
+
const buf = header(FrameType.OPEN, streamId, payload.length);
|
|
160
|
+
payload.copy(buf, HEADER_SIZE);
|
|
161
|
+
return buf;
|
|
162
|
+
}
|
|
163
|
+
case 'DATA': {
|
|
164
|
+
const streamId = requireStreamId(frame.streamId, 'DATA');
|
|
165
|
+
const data = frame.data;
|
|
166
|
+
if (!data || data.length === 0)
|
|
167
|
+
fail('DATA: payload must be 1..' + MAX_DATA_PAYLOAD + ' bytes');
|
|
168
|
+
if (data.length > MAX_DATA_PAYLOAD) {
|
|
169
|
+
fail(`DATA: payload is ${data.length} bytes, limit is ${MAX_DATA_PAYLOAD}`);
|
|
170
|
+
}
|
|
171
|
+
const buf = header(FrameType.DATA, streamId, data.length);
|
|
172
|
+
Buffer.from(data.buffer, data.byteOffset, data.byteLength).copy(buf, HEADER_SIZE);
|
|
173
|
+
return buf;
|
|
174
|
+
}
|
|
175
|
+
case 'END':
|
|
176
|
+
return header(FrameType.END, requireStreamId(frame.streamId, 'END'), 0);
|
|
177
|
+
case 'RST': {
|
|
178
|
+
const streamId = requireStreamId(frame.streamId, 'RST');
|
|
179
|
+
const code = requireU16(frame.code, 'RST', 'code');
|
|
180
|
+
const buf = header(FrameType.RST, streamId, 2);
|
|
181
|
+
buf.writeUInt16BE(code, HEADER_SIZE);
|
|
182
|
+
return buf;
|
|
183
|
+
}
|
|
184
|
+
case 'WINDOW': {
|
|
185
|
+
const streamId = requireStreamId(frame.streamId, 'WINDOW');
|
|
186
|
+
const increment = frame.increment;
|
|
187
|
+
if (!Number.isInteger(increment) || increment < 1 || increment > MAX_WINDOW) {
|
|
188
|
+
fail(`WINDOW: increment must be an integer in 1..${MAX_WINDOW}, got ${String(increment)}`);
|
|
189
|
+
}
|
|
190
|
+
const buf = header(FrameType.WINDOW, streamId, 4);
|
|
191
|
+
buf.writeUInt32BE(increment, HEADER_SIZE);
|
|
192
|
+
return buf;
|
|
193
|
+
}
|
|
194
|
+
case 'PING':
|
|
195
|
+
case 'PONG': {
|
|
196
|
+
requireSessionStreamId(frame.streamId, frame.type);
|
|
197
|
+
const nonce = frame.nonce;
|
|
198
|
+
if (typeof nonce !== 'bigint' || nonce < 0n || nonce > MAX_U64) {
|
|
199
|
+
fail(`${frame.type}: nonce must be a u64`);
|
|
200
|
+
}
|
|
201
|
+
const buf = header(frame.type === 'PING' ? FrameType.PING : FrameType.PONG, 0, 8);
|
|
202
|
+
buf.writeBigUInt64BE(nonce, HEADER_SIZE);
|
|
203
|
+
return buf;
|
|
204
|
+
}
|
|
205
|
+
case 'GOAWAY': {
|
|
206
|
+
requireSessionStreamId(frame.streamId, 'GOAWAY');
|
|
207
|
+
const code = requireU16(frame.code, 'GOAWAY', 'code');
|
|
208
|
+
const reason = Buffer.from(frame.reason ?? '', 'utf8');
|
|
209
|
+
if (reason.length > MAX_GOAWAY_REASON_BYTES) {
|
|
210
|
+
fail(`GOAWAY: reason is ${reason.length} bytes, limit is ${MAX_GOAWAY_REASON_BYTES}`);
|
|
211
|
+
}
|
|
212
|
+
const buf = header(FrameType.GOAWAY, 0, 2 + reason.length);
|
|
213
|
+
buf.writeUInt16BE(code, HEADER_SIZE);
|
|
214
|
+
reason.copy(buf, HEADER_SIZE + 2);
|
|
215
|
+
return buf;
|
|
216
|
+
}
|
|
217
|
+
case 'PROBE': {
|
|
218
|
+
const probeId = requireStreamId(frame.streamId, 'PROBE');
|
|
219
|
+
const request = normalizeProbeRequest((frame.request ?? {}));
|
|
220
|
+
const payload = encodeJsonPayload({ path: request.path }, 'PROBE');
|
|
221
|
+
const buf = header(FrameType.PROBE, probeId, payload.length);
|
|
222
|
+
payload.copy(buf, HEADER_SIZE);
|
|
223
|
+
return buf;
|
|
224
|
+
}
|
|
225
|
+
case 'PROBE_RESULT': {
|
|
226
|
+
const probeId = requireStreamId(frame.streamId, 'PROBE_RESULT');
|
|
227
|
+
const result = normalizeProbeResult((frame.result ?? {}));
|
|
228
|
+
const payload = encodeJsonPayload(result, 'PROBE_RESULT');
|
|
229
|
+
const buf = header(FrameType.PROBE_RESULT, probeId, payload.length);
|
|
230
|
+
payload.copy(buf, HEADER_SIZE);
|
|
231
|
+
return buf;
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
return fail(`unknown frame type ${String(frame.type)}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ── decode ───────────────────────────────────────────────────────────────────
|
|
238
|
+
/**
|
|
239
|
+
* Decode one websocket binary message into a frame.
|
|
240
|
+
*
|
|
241
|
+
* Throws ProtocolError for anything the spec rejects. Honours the view's
|
|
242
|
+
* byteOffset, because ws hands out Buffers backed by a shared pool.
|
|
243
|
+
*/
|
|
244
|
+
export function decodeFrame(bytes) {
|
|
245
|
+
if (bytes.length < HEADER_SIZE) {
|
|
246
|
+
fail(`frame is ${bytes.length} bytes, shorter than the ${HEADER_SIZE} byte header`);
|
|
247
|
+
}
|
|
248
|
+
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
249
|
+
const type = buf.readUInt8(0);
|
|
250
|
+
const streamId = buf.readUInt32BE(1);
|
|
251
|
+
const payload = buf.subarray(HEADER_SIZE);
|
|
252
|
+
const name = TYPE_NAME_BY_CODE.get(type);
|
|
253
|
+
if (!name)
|
|
254
|
+
fail(`unknown frame type 0x${type.toString(16).padStart(2, '0')}`);
|
|
255
|
+
switch (name) {
|
|
256
|
+
case 'OPEN':
|
|
257
|
+
requireStreamId(streamId, 'OPEN');
|
|
258
|
+
return { type: 'OPEN', streamId, metadata: normalizeMetadata(parseJsonObject(payload, 'OPEN')) };
|
|
259
|
+
case 'DATA':
|
|
260
|
+
requireStreamId(streamId, 'DATA');
|
|
261
|
+
if (payload.length === 0)
|
|
262
|
+
fail('DATA: payload must be at least 1 byte');
|
|
263
|
+
if (payload.length > MAX_DATA_PAYLOAD) {
|
|
264
|
+
fail(`DATA: payload is ${payload.length} bytes, limit is ${MAX_DATA_PAYLOAD}`);
|
|
265
|
+
}
|
|
266
|
+
return { type: 'DATA', streamId, data: payload };
|
|
267
|
+
case 'END':
|
|
268
|
+
requireStreamId(streamId, 'END');
|
|
269
|
+
if (payload.length !== 0)
|
|
270
|
+
fail('END: carries no payload');
|
|
271
|
+
return { type: 'END', streamId };
|
|
272
|
+
case 'RST':
|
|
273
|
+
requireStreamId(streamId, 'RST');
|
|
274
|
+
if (payload.length !== 2)
|
|
275
|
+
fail(`RST: payload must be exactly 2 bytes, got ${payload.length}`);
|
|
276
|
+
return { type: 'RST', streamId, code: payload.readUInt16BE(0) };
|
|
277
|
+
case 'WINDOW': {
|
|
278
|
+
requireStreamId(streamId, 'WINDOW');
|
|
279
|
+
if (payload.length !== 4)
|
|
280
|
+
fail(`WINDOW: payload must be exactly 4 bytes, got ${payload.length}`);
|
|
281
|
+
const increment = payload.readUInt32BE(0);
|
|
282
|
+
if (increment < 1 || increment > MAX_WINDOW) {
|
|
283
|
+
fail(`WINDOW: increment must be in 1..${MAX_WINDOW}, got ${increment}`);
|
|
284
|
+
}
|
|
285
|
+
return { type: 'WINDOW', streamId, increment };
|
|
286
|
+
}
|
|
287
|
+
case 'PING':
|
|
288
|
+
case 'PONG':
|
|
289
|
+
requireSessionStreamId(streamId, name);
|
|
290
|
+
if (payload.length !== 8)
|
|
291
|
+
fail(`${name}: nonce must be exactly 8 bytes, got ${payload.length}`);
|
|
292
|
+
return { type: name, streamId: 0, nonce: payload.readBigUInt64BE(0) };
|
|
293
|
+
case 'GOAWAY': {
|
|
294
|
+
requireSessionStreamId(streamId, 'GOAWAY');
|
|
295
|
+
if (payload.length < 2)
|
|
296
|
+
fail('GOAWAY: payload starts with a u16 code');
|
|
297
|
+
const reasonBytes = payload.subarray(2);
|
|
298
|
+
if (reasonBytes.length > MAX_GOAWAY_REASON_BYTES) {
|
|
299
|
+
fail(`GOAWAY: reason is ${reasonBytes.length} bytes, limit is ${MAX_GOAWAY_REASON_BYTES}`);
|
|
300
|
+
}
|
|
301
|
+
let reason;
|
|
302
|
+
try {
|
|
303
|
+
reason = utf8.decode(reasonBytes);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return fail('GOAWAY: reason is not valid UTF-8');
|
|
307
|
+
}
|
|
308
|
+
return { type: 'GOAWAY', streamId: 0, code: payload.readUInt16BE(0), reason };
|
|
309
|
+
}
|
|
310
|
+
case 'PROBE':
|
|
311
|
+
requireStreamId(streamId, 'PROBE');
|
|
312
|
+
return { type: 'PROBE', streamId, request: normalizeProbeRequest(parseJsonObject(payload, 'PROBE')) };
|
|
313
|
+
case 'PROBE_RESULT':
|
|
314
|
+
requireStreamId(streamId, 'PROBE_RESULT');
|
|
315
|
+
return {
|
|
316
|
+
type: 'PROBE_RESULT',
|
|
317
|
+
streamId,
|
|
318
|
+
result: normalizeProbeResult(parseJsonObject(payload, 'PROBE_RESULT')),
|
|
319
|
+
};
|
|
320
|
+
default:
|
|
321
|
+
return fail(`unknown frame type ${String(name)}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Human-readable frame type name for a wire type byte, or undefined. */
|
|
325
|
+
export function frameTypeName(typeCode) {
|
|
326
|
+
return TYPE_NAME_BY_CODE.get(typeCode);
|
|
327
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — the conformance runner.
|
|
3
|
+
*
|
|
4
|
+
* The vectors in ./vectors are the specification. This runner is what makes them
|
|
5
|
+
* enforceable in a repo that is NOT this one: the tunnel server vendors this
|
|
6
|
+
* whole directory (bead debugg_ai_mcp-xkoh.1.6 §D/§G) and its CI calls
|
|
7
|
+
* runConformanceVectors on the copy. Without a checker inside the vendored unit,
|
|
8
|
+
* the server would have to hand-copy test files from __tests__, which is drift
|
|
9
|
+
* on day one — in the very mechanism meant to prevent drift.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately dependency free and IO free: the CALLER reads the three JSON
|
|
12
|
+
* files and passes them in, so this works under any test runner, bundler or
|
|
13
|
+
* runtime. It returns failures instead of throwing, so each caller can report
|
|
14
|
+
* them its own way.
|
|
15
|
+
*/
|
|
16
|
+
import { decodeFrame, encodeFrame } from './codec.js';
|
|
17
|
+
import { ProtocolError } from './errors.js';
|
|
18
|
+
import { validateHandshakeRequest } from './handshake.js';
|
|
19
|
+
import { ErrorCode } from './constants.js';
|
|
20
|
+
function materialize(vector) {
|
|
21
|
+
if (typeof vector.hex === 'string')
|
|
22
|
+
return Buffer.from(vector.hex, 'hex');
|
|
23
|
+
const parts = [];
|
|
24
|
+
if (vector.hexPrefix)
|
|
25
|
+
parts.push(Buffer.from(vector.hexPrefix, 'hex'));
|
|
26
|
+
if (vector.fill)
|
|
27
|
+
parts.push(Buffer.alloc(vector.fill.count, Buffer.from(vector.fill.byte, 'hex')[0]));
|
|
28
|
+
if (vector.hexSuffix)
|
|
29
|
+
parts.push(Buffer.from(vector.hexSuffix, 'hex'));
|
|
30
|
+
return Buffer.concat(parts);
|
|
31
|
+
}
|
|
32
|
+
/** Vector JSON -> the Frame the codec works with. */
|
|
33
|
+
function frameFromVector(v) {
|
|
34
|
+
switch (v.type) {
|
|
35
|
+
case 'OPEN':
|
|
36
|
+
return { type: 'OPEN', streamId: v.streamId, metadata: v.metadata };
|
|
37
|
+
case 'DATA':
|
|
38
|
+
return {
|
|
39
|
+
type: 'DATA',
|
|
40
|
+
streamId: v.streamId,
|
|
41
|
+
data: v.dataFill
|
|
42
|
+
? Buffer.alloc(v.dataFill.count, Buffer.from(v.dataFill.byte, 'hex')[0])
|
|
43
|
+
: Buffer.from(v.dataHex, 'hex'),
|
|
44
|
+
};
|
|
45
|
+
case 'END':
|
|
46
|
+
return { type: 'END', streamId: v.streamId };
|
|
47
|
+
case 'RST':
|
|
48
|
+
return { type: 'RST', streamId: v.streamId, code: v.code };
|
|
49
|
+
case 'WINDOW':
|
|
50
|
+
return { type: 'WINDOW', streamId: v.streamId, increment: v.increment };
|
|
51
|
+
case 'PING':
|
|
52
|
+
return { type: 'PING', streamId: 0, nonce: BigInt(v.nonce) };
|
|
53
|
+
case 'PONG':
|
|
54
|
+
return { type: 'PONG', streamId: 0, nonce: BigInt(v.nonce) };
|
|
55
|
+
case 'GOAWAY':
|
|
56
|
+
return { type: 'GOAWAY', streamId: 0, code: v.code, reason: v.reason };
|
|
57
|
+
case 'PROBE':
|
|
58
|
+
return { type: 'PROBE', streamId: v.streamId, request: v.request };
|
|
59
|
+
case 'PROBE_RESULT':
|
|
60
|
+
return { type: 'PROBE_RESULT', streamId: v.streamId, result: v.result };
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`unknown vector frame type ${String(v.type)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** A Frame -> plain JSON, so a decoded frame can be compared with a vector. */
|
|
66
|
+
function frameToComparable(frame) {
|
|
67
|
+
if (frame.type === 'DATA') {
|
|
68
|
+
return { type: 'DATA', streamId: frame.streamId, dataHex: Buffer.from(frame.data).toString('hex') };
|
|
69
|
+
}
|
|
70
|
+
if (frame.type === 'PING' || frame.type === 'PONG') {
|
|
71
|
+
return { type: frame.type, streamId: 0, nonce: frame.nonce.toString() };
|
|
72
|
+
}
|
|
73
|
+
return { ...frame };
|
|
74
|
+
}
|
|
75
|
+
function vectorToComparable(v) {
|
|
76
|
+
if (v.type === 'DATA' && v.dataFill) {
|
|
77
|
+
return {
|
|
78
|
+
type: 'DATA',
|
|
79
|
+
streamId: v.streamId,
|
|
80
|
+
dataHex: Buffer.alloc(v.dataFill.count, Buffer.from(v.dataFill.byte, 'hex')[0]).toString('hex'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { ...v };
|
|
84
|
+
}
|
|
85
|
+
/** Structural equality, ignoring key order and undefined-valued keys. */
|
|
86
|
+
function deepEqual(a, b) {
|
|
87
|
+
if (a === b)
|
|
88
|
+
return true;
|
|
89
|
+
if (typeof a !== typeof b)
|
|
90
|
+
return false;
|
|
91
|
+
if (a === null || b === null || typeof a !== 'object')
|
|
92
|
+
return false;
|
|
93
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
94
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
95
|
+
return false;
|
|
96
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
97
|
+
}
|
|
98
|
+
const left = a;
|
|
99
|
+
const right = b;
|
|
100
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
if (left[key] === undefined && right[key] === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
if (!deepEqual(left[key], right[key]))
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
function describe(value) {
|
|
110
|
+
return JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? item.toString() : item));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Run every vector against this build of the codec and the handshake.
|
|
114
|
+
* An empty result means this copy of the module is on-spec.
|
|
115
|
+
*/
|
|
116
|
+
export function runConformanceVectors(vectors) {
|
|
117
|
+
const failures = [];
|
|
118
|
+
const fail = (file, vector, detail) => {
|
|
119
|
+
failures.push({ file, vector, detail });
|
|
120
|
+
};
|
|
121
|
+
for (const vector of (vectors.frames?.vectors ?? [])) {
|
|
122
|
+
const expectedBytes = materialize(vector);
|
|
123
|
+
if (!vector.decodeOnly) {
|
|
124
|
+
try {
|
|
125
|
+
const encoded = Buffer.from(encodeFrame(frameFromVector(vector.frame)));
|
|
126
|
+
if (!encoded.equals(expectedBytes)) {
|
|
127
|
+
fail('frames.json', vector.name, `encoded ${encoded.length} bytes that do not match the vector (got ${encoded
|
|
128
|
+
.subarray(0, 32)
|
|
129
|
+
.toString('hex')}..., want ${expectedBytes.subarray(0, 32).toString('hex')}...)`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
fail('frames.json', vector.name, `encode threw: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const decoded = frameToComparable(decodeFrame(expectedBytes));
|
|
138
|
+
const expected = vectorToComparable(vector.frame);
|
|
139
|
+
if (!deepEqual(decoded, expected)) {
|
|
140
|
+
fail('frames.json', vector.name, `decoded ${describe(decoded)}, want ${describe(expected)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
fail('frames.json', vector.name, `decode threw: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const vector of (vectors.invalidFrames?.vectors ?? [])) {
|
|
148
|
+
try {
|
|
149
|
+
decodeFrame(materialize(vector));
|
|
150
|
+
fail('invalid-frames.json', vector.name, 'decoded successfully; it must raise PROTOCOL_ERROR');
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (!(err instanceof ProtocolError) || err.code !== ErrorCode.PROTOCOL_ERROR) {
|
|
154
|
+
fail('invalid-frames.json', vector.name, `raised ${err.name}, want ProtocolError`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const handshakeDoc = vectors.handshake;
|
|
159
|
+
for (const vector of (handshakeDoc?.vectors ?? [])) {
|
|
160
|
+
try {
|
|
161
|
+
const result = validateHandshakeRequest(vector.requestHeaders, handshakeDoc.supported);
|
|
162
|
+
if (!deepEqual(result, vector.expect)) {
|
|
163
|
+
fail('handshake.json', vector.name, `returned ${describe(result)}, want ${describe(vector.expect)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
fail('handshake.json', vector.name, `threw: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return failures;
|
|
171
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — constants.
|
|
3
|
+
*
|
|
4
|
+
* ONE source of truth for both sides of the wire: the MCP tunnel client in this
|
|
5
|
+
* repo and the debugg tunnel server in the debuggai-api stack. The full
|
|
6
|
+
* specification these values come from is recorded on bead
|
|
7
|
+
* debugg_ai_mcp-xkoh.1.2 (1.2 System Requirements), and the design context is
|
|
8
|
+
* docs/debugg-tunnel-server-design-2026-09-19.md §3.
|
|
9
|
+
*
|
|
10
|
+
* This file — and every other file under services/tunnel/protocol/ — imports
|
|
11
|
+
* nothing but node: built-ins. No logger, no config, no ws. The module has to
|
|
12
|
+
* stay embeddable in a service that is deployed from a different repo, and
|
|
13
|
+
* config/index.ts alone would drag DEBUGGAI_API_KEY validation into it.
|
|
14
|
+
*/
|
|
15
|
+
/** The only protocol version that exists today. */
|
|
16
|
+
export const PROTOCOL_VERSION = 1;
|
|
17
|
+
/** Versions this build can speak, best first. Sent in X-Debugg-Tunnel-Protocol. */
|
|
18
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([1]);
|
|
19
|
+
// ── Frame geometry ───────────────────────────────────────────────────────────
|
|
20
|
+
/** type (u8) + streamId (u32 BE). */
|
|
21
|
+
export const HEADER_SIZE = 5;
|
|
22
|
+
/** Largest DATA payload in one frame. */
|
|
23
|
+
export const MAX_DATA_PAYLOAD = 65536;
|
|
24
|
+
/**
|
|
25
|
+
* Largest legal websocket message. Both ends set ws `maxPayload` to this, so an
|
|
26
|
+
* oversized message is refused by the ws layer (1009) before it is buffered.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_FRAME_SIZE = HEADER_SIZE + MAX_DATA_PAYLOAD;
|
|
29
|
+
/** Largest JSON payload (OPEN / PROBE / PROBE_RESULT). */
|
|
30
|
+
export const MAX_JSON_PAYLOAD = 16384;
|
|
31
|
+
/** Largest GOAWAY reason, in UTF-8 bytes (the u16 code is not counted). */
|
|
32
|
+
export const MAX_GOAWAY_REASON_BYTES = 1024;
|
|
33
|
+
/** Largest PROBE path. */
|
|
34
|
+
export const MAX_PROBE_PATH_LENGTH = 2048;
|
|
35
|
+
/** Largest OPEN `requestId`. */
|
|
36
|
+
export const MAX_REQUEST_ID_LENGTH = 128;
|
|
37
|
+
/** Largest OPEN `kind`. */
|
|
38
|
+
export const MAX_KIND_LENGTH = 32;
|
|
39
|
+
/** Largest PROBE_RESULT `marker`. */
|
|
40
|
+
export const MAX_MARKER_LENGTH = 64;
|
|
41
|
+
/** Largest PROBE_RESULT `error`. */
|
|
42
|
+
export const MAX_ERROR_LENGTH = 256;
|
|
43
|
+
// ── Streams ──────────────────────────────────────────────────────────────────
|
|
44
|
+
/** streamId 0 is reserved for session-level frames (PING/PONG/GOAWAY). */
|
|
45
|
+
export const SESSION_STREAM_ID = 0;
|
|
46
|
+
/** Highest u32 stream id. Reaching it means GOAWAY + reconnect. */
|
|
47
|
+
export const MAX_STREAM_ID = 0xffffffff;
|
|
48
|
+
/**
|
|
49
|
+
* Concurrent streams per tunnel. One Next.js dev page has been measured at 54
|
|
50
|
+
* requests, and nginx deliberately has no upstream keepalive for the tunnel, so
|
|
51
|
+
* one browser request is one stream.
|
|
52
|
+
*/
|
|
53
|
+
export const MAX_CONCURRENT_STREAMS = 256;
|
|
54
|
+
// ── Flow control ─────────────────────────────────────────────────────────────
|
|
55
|
+
/** Credit each stream direction starts with. Fixed in v1, not negotiated. */
|
|
56
|
+
export const INITIAL_WINDOW = 262144;
|
|
57
|
+
/** A window may never exceed this after a WINDOW increment. */
|
|
58
|
+
export const MAX_WINDOW = 2147483647;
|
|
59
|
+
/**
|
|
60
|
+
* Receiver policy (pinned by tests, not by the wire): announce consumed bytes
|
|
61
|
+
* once this many are un-announced, so the sender never stalls while three
|
|
62
|
+
* quarters of the window is still in flight.
|
|
63
|
+
*/
|
|
64
|
+
export const WINDOW_UPDATE_THRESHOLD = 65536;
|
|
65
|
+
/**
|
|
66
|
+
* Send-side bound: the session hands frames to the socket only while fewer than
|
|
67
|
+
* this many bytes are un-acked (ws send callback still pending), so a peer that
|
|
68
|
+
* stops reading cannot inflate the websocket's own send buffer.
|
|
69
|
+
*/
|
|
70
|
+
export const MAX_UNACKED_SEND_BYTES = 262144;
|
|
71
|
+
// ── Timers ───────────────────────────────────────────────────────────────────
|
|
72
|
+
/** PING cadence. Under the public ALB's idle_timeout of 60s. */
|
|
73
|
+
export const PING_INTERVAL_MS = 20000;
|
|
74
|
+
/** No frame of any type for this long means the peer is dead. */
|
|
75
|
+
export const DEAD_PEER_TIMEOUT_MS = 60000;
|
|
76
|
+
/** Client-side PROBE deadline. Matches TunnelHealthProbeOptions.timeoutMs today. */
|
|
77
|
+
export const PROBE_TIMEOUT_MS = 5000;
|
|
78
|
+
/**
|
|
79
|
+
* Normative for the tunnel server: how long a browser request is held while the
|
|
80
|
+
* tunnel's client is reconnecting, before DEBUGG_TUNNEL_OFFLINE is rendered.
|
|
81
|
+
*/
|
|
82
|
+
export const RECONNECT_GRACE_MS = 5000;
|
|
83
|
+
// ── Frame types ──────────────────────────────────────────────────────────────
|
|
84
|
+
export const FrameType = Object.freeze({
|
|
85
|
+
OPEN: 0x01,
|
|
86
|
+
DATA: 0x02,
|
|
87
|
+
END: 0x03,
|
|
88
|
+
RST: 0x04,
|
|
89
|
+
WINDOW: 0x05,
|
|
90
|
+
PING: 0x06,
|
|
91
|
+
PONG: 0x07,
|
|
92
|
+
GOAWAY: 0x08,
|
|
93
|
+
PROBE: 0x09,
|
|
94
|
+
PROBE_RESULT: 0x0a,
|
|
95
|
+
});
|
|
96
|
+
// ── Error codes (RST payload, GOAWAY payload, and websocket close 4000+code) ──
|
|
97
|
+
export const ErrorCode = Object.freeze({
|
|
98
|
+
/** Stream was NOT processed — cap reached, or OPEN after GOAWAY. Retryable. */
|
|
99
|
+
REFUSED: 1,
|
|
100
|
+
/** Stream aborted by an endpoint. */
|
|
101
|
+
RESET: 2,
|
|
102
|
+
/** A deadline expired (dead peer, drain deadline). */
|
|
103
|
+
TIMEOUT: 3,
|
|
104
|
+
/** The peer violated the spec. */
|
|
105
|
+
PROTOCOL_ERROR: 4,
|
|
106
|
+
/** Sender is shutting down; reconnect. */
|
|
107
|
+
GOING_AWAY: 5,
|
|
108
|
+
/** Client could not dial its local target -> DEBUGG_TUNNEL_UPSTREAM_REFUSED. */
|
|
109
|
+
UPSTREAM_UNREACHABLE: 6,
|
|
110
|
+
/** Tunnel revoked or past its max lifetime. Do not reconnect with this token. */
|
|
111
|
+
REVOKED: 7,
|
|
112
|
+
});
|
|
113
|
+
/** Websocket close codes. Anything other than a drained close is 4000 + ErrorCode. */
|
|
114
|
+
export const CloseCode = Object.freeze({
|
|
115
|
+
NORMAL: 1000,
|
|
116
|
+
/** RFC 6455: the connection dropped without a close frame. Never sent, only observed. */
|
|
117
|
+
ABNORMAL: 1006,
|
|
118
|
+
REFUSED: 4001,
|
|
119
|
+
RESET: 4002,
|
|
120
|
+
TIMEOUT: 4003,
|
|
121
|
+
PROTOCOL_ERROR: 4004,
|
|
122
|
+
GOING_AWAY: 4005,
|
|
123
|
+
UPSTREAM_UNREACHABLE: 4006,
|
|
124
|
+
REVOKED: 4007,
|
|
125
|
+
});
|
|
126
|
+
/** Close code carrying a protocol error code. */
|
|
127
|
+
export const CLOSE_CODE_BASE = 4000;
|
|
128
|
+
// ── Handshake ────────────────────────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Header names, lowercase, because that is how node hands them to a server
|
|
131
|
+
* (http.IncomingMessage.headers). The canonical spellings a client sends are in
|
|
132
|
+
* REQUEST_HEADER_NAMES.
|
|
133
|
+
*/
|
|
134
|
+
export const Header = Object.freeze({
|
|
135
|
+
AUTHORIZATION: 'authorization',
|
|
136
|
+
TUNNEL_ID: 'x-debugg-tunnel-id',
|
|
137
|
+
PROTOCOL: 'x-debugg-tunnel-protocol',
|
|
138
|
+
CLIENT_VERSION: 'x-debugg-tunnel-client-version',
|
|
139
|
+
SUPPORTED: 'x-debugg-tunnel-supported',
|
|
140
|
+
/** Response header on the server's error pages, alongside the body marker. */
|
|
141
|
+
TUNNEL_ERROR: 'x-debugg-tunnel-error',
|
|
142
|
+
});
|
|
143
|
+
export const RequestHeader = Object.freeze({
|
|
144
|
+
AUTHORIZATION: 'Authorization',
|
|
145
|
+
TUNNEL_ID: 'X-Debugg-Tunnel-Id',
|
|
146
|
+
PROTOCOL: 'X-Debugg-Tunnel-Protocol',
|
|
147
|
+
CLIENT_VERSION: 'X-Debugg-Tunnel-Client-Version',
|
|
148
|
+
SUPPORTED: 'X-Debugg-Tunnel-Supported',
|
|
149
|
+
});
|
|
150
|
+
/** A tunnel id has to be a DNS label: it becomes `<id>.tunnel.debugg.ai`. */
|
|
151
|
+
export const TUNNEL_ID_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
152
|
+
/** Handshake rejection statuses. */
|
|
153
|
+
export const HandshakeStatus = Object.freeze({
|
|
154
|
+
SWITCHING_PROTOCOLS: 101,
|
|
155
|
+
BAD_REQUEST: 400,
|
|
156
|
+
UNAUTHORIZED: 401,
|
|
157
|
+
UPGRADE_REQUIRED: 426,
|
|
158
|
+
});
|
|
159
|
+
export const HandshakeError = Object.freeze({
|
|
160
|
+
UNSUPPORTED_PROTOCOL_VERSION: 'UNSUPPORTED_PROTOCOL_VERSION',
|
|
161
|
+
BAD_TUNNEL_ID: 'BAD_TUNNEL_ID',
|
|
162
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
163
|
+
});
|
|
164
|
+
// ── Server error markers (server emits, client parses) ───────────────────────
|
|
165
|
+
export const TunnelErrorMarker = Object.freeze({
|
|
166
|
+
/** No client connected — the ERR_NGROK_3200 analogue. */
|
|
167
|
+
OFFLINE: 'DEBUGG_TUNNEL_OFFLINE',
|
|
168
|
+
/** Unknown or revoked tunnel id. */
|
|
169
|
+
UNKNOWN: 'DEBUGG_TUNNEL_UNKNOWN',
|
|
170
|
+
/** The client could not dial the local app — the ERR_NGROK_8012 analogue. */
|
|
171
|
+
UPSTREAM_REFUSED: 'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
|
|
172
|
+
});
|
|
173
|
+
/** HTTP status each marker is served with, chosen to match the ngrok codes they replace. */
|
|
174
|
+
export const MARKER_HTTP_STATUS = Object.freeze({
|
|
175
|
+
[TunnelErrorMarker.OFFLINE]: 404,
|
|
176
|
+
[TunnelErrorMarker.UNKNOWN]: 404,
|
|
177
|
+
[TunnelErrorMarker.UPSTREAM_REFUSED]: 502,
|
|
178
|
+
});
|