@debugg-ai/debugg-ai-mcp 4.4.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +32 -14
- package/dist/handlers/createEnvironmentHandler.js +4 -4
- package/dist/handlers/createProjectHandler.js +1 -1
- package/dist/handlers/environmentSessionsHandler.js +2 -18
- package/dist/handlers/probePageHandler.js +10 -13
- package/dist/handlers/runTestSuiteHandler.js +8 -9
- package/dist/handlers/searchEnvironmentsHandler.js +3 -6
- package/dist/handlers/searchExecutionsHandler.js +3 -11
- package/dist/handlers/searchProjectsHandler.js +1 -6
- package/dist/handlers/testPageChangesHandler.js +88 -183
- package/dist/handlers/triggerCrawlHandler.js +52 -44
- package/dist/handlers/updateEnvironmentHandler.js +2 -2
- package/dist/index.js +1 -1
- package/dist/services/caddy/caddyProxy.js +7 -6
- package/dist/services/index.js +18 -21
- package/dist/services/tunnel/debuggTransport.js +9 -9
- package/dist/services/tunnel/protocol/constants.js +3 -3
- package/dist/services/tunnel/transport.js +28 -9
- package/dist/services/{ngrok → tunnel}/tunnelFaultInjection.js +9 -15
- package/dist/services/{ngrok → tunnel}/tunnelManager.js +65 -131
- package/dist/services/{ngrok → tunnel}/tunnelRegistry.js +2 -2
- package/dist/services/tunnels.js +64 -46
- package/dist/services/verdictAdapter.js +167 -41
- package/dist/services/workflows.js +10 -1
- package/dist/tools/testPageChanges.js +1 -1
- package/dist/utils/authorizedCredentialHosts.js +8 -20
- package/dist/utils/confirmDestructive.js +1 -2
- package/dist/utils/errors.js +4 -5
- package/dist/utils/imageUtils.js +2 -2
- package/dist/utils/localReachability.js +62 -341
- package/dist/utils/resolveProject.js +1 -1
- package/dist/utils/telemetry.js +4 -4
- package/dist/utils/tunnelContext.js +11 -9
- package/dist/utils/tunnelDisposition.js +51 -69
- package/dist/utils/tunnelDomains.js +30 -21
- package/dist/utils/urlParser.js +38 -6
- package/package.json +2 -3
- package/dist/services/ngrok/error.js +0 -3
- package/dist/services/ngrok/ngrokAgentInspector.js +0 -210
- package/dist/services/ngrok/ngrokAgentSession.js +0 -49
- package/dist/services/ngrok/types.js +0 -1
- package/dist/services/tunnel/ngrokTransport.js +0 -165
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* and reports back what happened.
|
|
12
12
|
*/
|
|
13
13
|
import { config } from '../config/index.js';
|
|
14
|
+
import { toUserFacingOutcome, noVerdictReason } from '../services/verdictAdapter.js';
|
|
14
15
|
import { Logger } from '../utils/logger.js';
|
|
15
16
|
import { handleExternalServiceError } from '../utils/errors.js';
|
|
16
17
|
import { DebuggAIServerClient } from '../services/index.js';
|
|
@@ -85,14 +86,14 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
85
86
|
try {
|
|
86
87
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
87
88
|
if (ctx.isLocalhost) {
|
|
88
|
-
// Bead 1om: pre-flight local port probe BEFORE provision/
|
|
89
|
+
// Bead 1om: pre-flight local port probe BEFORE provision/tunnel/backend.
|
|
89
90
|
const localPort = extractLocalhostPort(ctx.originalUrl);
|
|
90
91
|
if (typeof localPort === 'number') {
|
|
91
92
|
const probe = await probeLocalPort(localPort);
|
|
92
93
|
if (!probe.reachable) {
|
|
93
94
|
const payload = {
|
|
94
95
|
error: 'LocalServerUnreachable',
|
|
95
|
-
message: `No server listening on 127.0.0.1:${localPort}
|
|
96
|
+
message: `No server listening on 127.0.0.1:${localPort}.`,
|
|
96
97
|
detail: { port: localPort, probeCode: probe.code, probeDetail: probe.detail, elapsedMs: probe.elapsedMs },
|
|
97
98
|
};
|
|
98
99
|
logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`);
|
|
@@ -119,9 +120,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
119
120
|
catch (provisionError) {
|
|
120
121
|
const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
|
|
121
122
|
const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
|
|
122
|
-
throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}
|
|
123
|
-
`The remote browser needs a secure tunnel to reach your local dev server. ` +
|
|
124
|
-
`(Detail: ${msg})${diag}`);
|
|
123
|
+
throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}: ${msg}${diag}`);
|
|
125
124
|
}
|
|
126
125
|
provision = tunnel;
|
|
127
126
|
ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel), tunnel);
|
|
@@ -151,15 +150,15 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
151
150
|
if (!health.healthy) {
|
|
152
151
|
const payload = {
|
|
153
152
|
error: 'TunnelTrafficBlocked',
|
|
154
|
-
message: `Tunnel
|
|
153
|
+
message: `Tunnel established; a request through it to 127.0.0.1:${extractLocalhostPort(ctx.originalUrl)} failed: ${health.detail ?? health.code}.`,
|
|
155
154
|
detail: {
|
|
156
155
|
code: health.code,
|
|
157
156
|
status: health.status,
|
|
158
|
-
|
|
157
|
+
tunnelErrorCode: health.tunnelErrorCode,
|
|
159
158
|
elapsedMs: health.elapsedMs,
|
|
160
159
|
},
|
|
161
160
|
};
|
|
162
|
-
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.
|
|
161
|
+
logger.warn(`Tunnel health probe failed for ${ctx.targetUrl}: ${health.code} ${health.tunnelErrorCode ?? ''} in ${health.elapsedMs}ms`);
|
|
163
162
|
// Evict ONLY on a code proving the endpoint is gone; every other failure
|
|
164
163
|
// keeps the tunnel we are already paying for, because a teardown+
|
|
165
164
|
// re-provision costs two billed hours and this probe cannot tell a dead
|
|
@@ -186,9 +185,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
186
185
|
return client.workflows.findTemplateBySlug(templateSlug);
|
|
187
186
|
});
|
|
188
187
|
if (!templateUuid) {
|
|
189
|
-
throw new Error(`Crawl
|
|
190
|
-
`Ensure the backend has that template seeded and accessible ` +
|
|
191
|
-
`(GET /api/v1/workflows/?slug=${templateSlug}).`);
|
|
188
|
+
throw new Error(`Crawl workflow template not found (slug "${templateSlug}").`);
|
|
192
189
|
}
|
|
193
190
|
// --- Build contextData + env ---
|
|
194
191
|
const contextData = {
|
|
@@ -293,13 +290,29 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
293
290
|
targetUrl: ctx.originalUrl,
|
|
294
291
|
durationMs: finalExecution.durationMs ?? duration,
|
|
295
292
|
};
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
293
|
+
// Map onto the user-facing enum instead of relaying raw. This path never
|
|
294
|
+
// went through adaptVerdict, so it would otherwise emit whatever the
|
|
295
|
+
// backend sent — including retired non-answers like 'inconclusive'.
|
|
296
|
+
const rawOutcome = finalExecution.state?.outcome;
|
|
297
|
+
if (finalExecution.verdict?.skipped === true && !finalExecution.verdict.outcome) {
|
|
298
|
+
// platform-98fv.16: the run never attempted a test — no verdict, the
|
|
299
|
+
// backend's recorded reason verbatim. Never the raw run outcome.
|
|
300
|
+
responsePayload.outcome = null;
|
|
301
|
+
responsePayload.skipped = true;
|
|
302
|
+
const skipReason = finalExecution.verdict.reason;
|
|
303
|
+
if (typeof skipReason === 'string' && skipReason.trim() !== '')
|
|
304
|
+
responsePayload.skipReason = skipReason;
|
|
305
|
+
}
|
|
306
|
+
else if (rawOutcome !== undefined && rawOutcome !== null) {
|
|
307
|
+
responsePayload.outcome = toUserFacingOutcome(rawOutcome);
|
|
308
|
+
if (responsePayload.outcome === 'error' && !finalExecution.errorMessage) {
|
|
309
|
+
responsePayload.reason = noVerdictReason(rawOutcome);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
299
312
|
if (finalExecution.errorMessage)
|
|
300
313
|
responsePayload.errorMessage = finalExecution.errorMessage;
|
|
301
|
-
if (finalExecution.errorInfo
|
|
302
|
-
responsePayload.
|
|
314
|
+
if (finalExecution.errorInfo)
|
|
315
|
+
responsePayload.errorInfo = finalExecution.errorInfo;
|
|
303
316
|
if (executeResponse.resolvedEnvironmentId)
|
|
304
317
|
responsePayload.resolvedEnvironmentId = executeResponse.resolvedEnvironmentId;
|
|
305
318
|
if (executeResponse.resolvedCredentialId)
|
|
@@ -310,34 +323,29 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
310
323
|
if (finalExecution.browserSession) {
|
|
311
324
|
responsePayload.browserSession = finalExecution.browserSession;
|
|
312
325
|
}
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
statesImported: d.statesImported ?? 0,
|
|
337
|
-
knowledgeGraphId: d.knowledgeGraphId ?? '',
|
|
338
|
-
...(Array.isArray(d.importErrors) && d.importErrors.length > 0 ? { importErrors: d.importErrors } : {}),
|
|
339
|
-
};
|
|
340
|
-
}
|
|
326
|
+
// Node output, relayed under the backend's own keys. Only the listed keys,
|
|
327
|
+
// and only when the node reported them: a missing count is absent, never a
|
|
328
|
+
// fabricated 0 / '' that reads as "the backend said zero".
|
|
329
|
+
const pick = (src, keys) => {
|
|
330
|
+
if (!src)
|
|
331
|
+
return undefined;
|
|
332
|
+
const out = {};
|
|
333
|
+
for (const k of keys)
|
|
334
|
+
if (src[k] !== undefined && src[k] !== null)
|
|
335
|
+
out[k] = src[k];
|
|
336
|
+
return out;
|
|
337
|
+
};
|
|
338
|
+
const crawlSummary = pick(nodes.find(n => n.nodeType === 'surfer.crawl')?.outputData, [
|
|
339
|
+
'pagesDiscovered', 'actionsExecuted', 'stepsTaken', 'transitionsRecorded',
|
|
340
|
+
'knowledgeGraphStates', 'success', 'error',
|
|
341
|
+
]);
|
|
342
|
+
if (crawlSummary)
|
|
343
|
+
responsePayload.crawlSummary = crawlSummary;
|
|
344
|
+
const knowledgeGraph = pick(nodes.find(n => n.nodeType === 'knowledge_graph.import')?.outputData, [
|
|
345
|
+
'skipped', 'reason', 'edgesImported', 'statesImported', 'knowledgeGraphId', 'importErrors',
|
|
346
|
+
]);
|
|
347
|
+
if (knowledgeGraph)
|
|
348
|
+
responsePayload.knowledgeGraph = knowledgeGraph;
|
|
341
349
|
logger.toolComplete('trigger_crawl', duration);
|
|
342
350
|
// Bead 0bq: final progress is emitted INSIDE pollExecution's onUpdate when
|
|
343
351
|
// terminal status is detected. Emitting it here would race the response
|
|
@@ -134,10 +134,10 @@ export async function updateEnvironmentHandler(input, _context) {
|
|
|
134
134
|
if (warnings.length > 0)
|
|
135
135
|
payload.credentialWarnings = warnings;
|
|
136
136
|
if (input.authorizedCredentialHosts !== undefined) {
|
|
137
|
-
const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts
|
|
137
|
+
const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts);
|
|
138
138
|
if (hostsWarning) {
|
|
139
139
|
payload.authorizedCredentialHostsWarning = hostsWarning;
|
|
140
|
-
logger.warn(`update_environment:
|
|
140
|
+
logger.warn(`update_environment: authorizedCredentialHosts not persisted`, hostsWarning);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
logger.toolComplete('update_environment', Date.now() - start);
|
package/dist/index.js
CHANGED
|
@@ -271,7 +271,7 @@ function safeLog(level, message, meta) {
|
|
|
271
271
|
*/
|
|
272
272
|
async function gracefulShutdown(signal) {
|
|
273
273
|
safeLog('info', `Received ${signal}, shutting down gracefully`);
|
|
274
|
-
const { tunnelManager } = await import('./services/
|
|
274
|
+
const { tunnelManager } = await import('./services/tunnel/tunnelManager.js');
|
|
275
275
|
await tunnelManager.stopAllTunnels().catch((err) => safeLog('warn', 'stopAllTunnels failed during shutdown', { error: String(err) }));
|
|
276
276
|
await Telemetry.shutdown();
|
|
277
277
|
process.exit(0);
|
|
@@ -83,8 +83,9 @@ export function isDockerEnv() {
|
|
|
83
83
|
// @radically-straightforward/caddy is a dependency (package.json's "caddy"
|
|
84
84
|
// field pins the exact version — see its postinstall) that downloads Caddy
|
|
85
85
|
// from the project's own GitHub releases and drops it at
|
|
86
|
-
// node_modules/.bin/caddy(.exe).
|
|
87
|
-
//
|
|
86
|
+
// node_modules/.bin/caddy(.exe). It is now the ONLY binary this package pulls
|
|
87
|
+
// down at install time — the ngrok package, whose postinstall fetched the ngrok
|
|
88
|
+
// agent the same way, is gone. Pinned rather
|
|
88
89
|
// than "latest" deliberately: a real config incompatibility with a Caddy
|
|
89
90
|
// version (--adapter json rejected by 2.11.3) was found and fixed during
|
|
90
91
|
// development of this file — "latest" silently shipping a breaking change
|
|
@@ -273,7 +274,7 @@ export function buildPatchBody(target, inDocker) {
|
|
|
273
274
|
upstreams: [{ dial }],
|
|
274
275
|
};
|
|
275
276
|
if (isHttpsLocal) {
|
|
276
|
-
// Local self-signed certs — doesn't touch the public leg's real
|
|
277
|
+
// Local self-signed certs — doesn't touch the public leg's real TLS.
|
|
277
278
|
body.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
|
|
278
279
|
}
|
|
279
280
|
return body;
|
|
@@ -370,7 +371,7 @@ export class CaddyProxyManager {
|
|
|
370
371
|
}
|
|
371
372
|
// Network-level failure against a previously-healthy admin port —
|
|
372
373
|
// the process is presumed dead. Exactly one respawn-and-retry, then
|
|
373
|
-
// propagate uncaught — no loop (matches
|
|
374
|
+
// propagate uncaught — no loop (matches the transport's
|
|
374
375
|
// onTerminated philosophy: lazy, bounded, next-call-triggered).
|
|
375
376
|
logger.warn(`Caddy admin API unreachable on port ${adminPort} — assuming process died, respawning once: ${err}`);
|
|
376
377
|
this.markDead();
|
|
@@ -520,7 +521,7 @@ export class CaddyProxyManager {
|
|
|
520
521
|
* successfully-bound proxy port (if any); attempt 2 falls back to a fresh
|
|
521
522
|
* one. `CaddyPortReclaimError` means BOTH attempts failed — Caddy is fully
|
|
522
523
|
* down. `onPortChanged` fires on the "succeeded but moved" case, which is
|
|
523
|
-
* the signal TunnelManager needs to evict the now-orphaned
|
|
524
|
+
* the signal TunnelManager needs to evict the now-orphaned tunnel.
|
|
524
525
|
*/
|
|
525
526
|
async doStart() {
|
|
526
527
|
sweepOrphanedConfigs(this.configDir);
|
|
@@ -593,7 +594,7 @@ export class CaddyProxyManager {
|
|
|
593
594
|
}
|
|
594
595
|
}
|
|
595
596
|
/** Restart policy: lazy, bounded, next-call-triggered — no background
|
|
596
|
-
* watchdog (matches
|
|
597
|
+
* watchdog (matches the transport's onDead philosophy). This
|
|
597
598
|
* handler only marks state as dead; it never itself triggers a respawn. */
|
|
598
599
|
installExitHandler(child) {
|
|
599
600
|
child.once('exit', (code, signal) => {
|
package/dist/services/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toUserFacingOutcome } from './verdictAdapter.js';
|
|
1
2
|
import { createWorkflowsService } from "./workflows.js";
|
|
2
3
|
import { createTunnelsService } from "./tunnels.js";
|
|
3
4
|
import { AxiosTransport } from "../utils/axiosTransport.js";
|
|
@@ -298,7 +299,8 @@ export class DebuggAIServerClient {
|
|
|
298
299
|
if (!this.tx)
|
|
299
300
|
throw new Error('Client not initialized — call init() first');
|
|
300
301
|
const response = await this.tx.delete(`api/v1/environments/${envUuid}/sessions/`, { params: filters });
|
|
301
|
-
|
|
302
|
+
// absent → null: a count the backend did not report is not a reported 0
|
|
303
|
+
return { invalidated: response?.invalidated ?? null };
|
|
302
304
|
}
|
|
303
305
|
/**
|
|
304
306
|
* Fetch a single environment by UUID. Throws AxiosError with status 404 if not found.
|
|
@@ -473,15 +475,6 @@ export class DebuggAIServerClient {
|
|
|
473
475
|
isActive: c.isActive,
|
|
474
476
|
};
|
|
475
477
|
}
|
|
476
|
-
/**
|
|
477
|
-
* Revoke an ngrok API key by its key ID.
|
|
478
|
-
* Call this after workflow execution completes to clean up the short-lived key.
|
|
479
|
-
*/
|
|
480
|
-
async revokeNgrokKey(ngrokKeyId) {
|
|
481
|
-
if (!this.tx)
|
|
482
|
-
throw new Error('Client not initialized — call init() first');
|
|
483
|
-
await this.tx.post('api/v1/ngrok/revoke/', { ngrokKeyId });
|
|
484
|
-
}
|
|
485
478
|
// ── E2E Suite Management ──────────────────────────────────────────────────
|
|
486
479
|
async createTestSuite(input) {
|
|
487
480
|
if (!this.tx)
|
|
@@ -509,8 +502,8 @@ export class DebuggAIServerClient {
|
|
|
509
502
|
uuid: s.uuid,
|
|
510
503
|
name: s.name,
|
|
511
504
|
description: s.description ?? null,
|
|
512
|
-
runStatus: s.runStatus ?? s.run_status ??
|
|
513
|
-
testsCount: s.testsCount ?? s.tests_count ??
|
|
505
|
+
runStatus: s.runStatus ?? s.run_status ?? null,
|
|
506
|
+
testsCount: s.testsCount ?? s.tests_count ?? null,
|
|
514
507
|
passRate: s.passRate ?? s.pass_rate ?? null,
|
|
515
508
|
lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
|
|
516
509
|
})),
|
|
@@ -545,7 +538,7 @@ export class DebuggAIServerClient {
|
|
|
545
538
|
agentTaskDescription: t.agentTaskDescription ?? t.agent_task_description ?? '',
|
|
546
539
|
suite: t.suite ?? input.suiteUuid,
|
|
547
540
|
project: t.project ?? input.projectUuid,
|
|
548
|
-
runCount: t.runCount ?? t.run_count ??
|
|
541
|
+
runCount: t.runCount ?? t.run_count ?? null,
|
|
549
542
|
};
|
|
550
543
|
}
|
|
551
544
|
async updateTestCase(testUuid, patch) {
|
|
@@ -581,7 +574,7 @@ export class DebuggAIServerClient {
|
|
|
581
574
|
const s = await this.tx.post(`api/v1/test-suites/${suiteUuid}/run/`, body);
|
|
582
575
|
return {
|
|
583
576
|
suiteUuid,
|
|
584
|
-
runStatus: s?.runStatus ?? s?.run_status ??
|
|
577
|
+
runStatus: s?.runStatus ?? s?.run_status ?? null,
|
|
585
578
|
testsTriggered: (s?.tests ?? []).length,
|
|
586
579
|
};
|
|
587
580
|
}
|
|
@@ -593,7 +586,7 @@ export class DebuggAIServerClient {
|
|
|
593
586
|
return {
|
|
594
587
|
uuid: s.uuid,
|
|
595
588
|
name: s.name,
|
|
596
|
-
runStatus: s.runStatus ?? s.run_status ??
|
|
589
|
+
runStatus: s.runStatus ?? s.run_status ?? null,
|
|
597
590
|
testsCount: tests.length,
|
|
598
591
|
passRate: s.passRate ?? s.pass_rate ?? null,
|
|
599
592
|
lastRunAt: s.lastRunAt ?? s.last_run_at ?? null,
|
|
@@ -603,14 +596,18 @@ export class DebuggAIServerClient {
|
|
|
603
596
|
return {
|
|
604
597
|
uuid: t.uuid,
|
|
605
598
|
name: t.name,
|
|
606
|
-
runCount: t.runCount ?? t.run_count ??
|
|
607
|
-
passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ??
|
|
608
|
-
failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ??
|
|
599
|
+
runCount: t.runCount ?? t.run_count ?? null,
|
|
600
|
+
passedRunsCount: t.passedRunsCount ?? t.passed_runs_count ?? null,
|
|
601
|
+
failedRunsCount: t.failedRunsCount ?? t.failed_runs_count ?? null,
|
|
609
602
|
passRate: t.passRate ?? t.pass_rate ?? null,
|
|
610
603
|
lastRun: lastRun ? {
|
|
611
604
|
uuid: lastRun.uuid,
|
|
612
605
|
status: lastRun.status,
|
|
613
|
-
|
|
606
|
+
// the user-facing verdict allowlist, same as every other surface;
|
|
607
|
+
// a run with no outcome yet keeps its null
|
|
608
|
+
outcome: typeof lastRun.outcome === 'string' && lastRun.outcome.trim() !== ''
|
|
609
|
+
? toUserFacingOutcome(lastRun.outcome)
|
|
610
|
+
: lastRun.outcome ?? null,
|
|
614
611
|
executionTime: lastRun.executionTime ?? lastRun.execution_time ?? null,
|
|
615
612
|
timestamp: lastRun.timestamp,
|
|
616
613
|
} : null,
|
|
@@ -623,8 +620,8 @@ export class DebuggAIServerClient {
|
|
|
623
620
|
uuid: s.uuid,
|
|
624
621
|
name: s.name,
|
|
625
622
|
description: s.description ?? null,
|
|
626
|
-
runStatus: s.runStatus ?? s.run_status ??
|
|
627
|
-
testsCount: s.testsCount ?? s.tests_count ??
|
|
623
|
+
runStatus: s.runStatus ?? s.run_status ?? null,
|
|
624
|
+
testsCount: s.testsCount ?? s.tests_count ?? null,
|
|
628
625
|
};
|
|
629
626
|
}
|
|
630
627
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One websocket per tunnel to the relayUrl a provision response supplies,
|
|
5
5
|
* speaking the wire protocol in services/tunnel/protocol/ (spec: bead
|
|
6
|
-
* debugg_ai_mcp-xkoh.1.2). It
|
|
6
|
+
* debugg_ai_mcp-xkoh.1.2). It replaced the `ngrok` package, which downloaded and
|
|
7
7
|
* spawns a native agent; `ws` is the only new dependency and is pure JS.
|
|
8
8
|
*
|
|
9
9
|
* This file owns exactly four things — the socket, the reconnect policy, the
|
|
@@ -324,7 +324,7 @@ class DebuggTransport {
|
|
|
324
324
|
});
|
|
325
325
|
socket.on('error', () => {
|
|
326
326
|
if (!established) {
|
|
327
|
-
// The
|
|
327
|
+
// The tunnel is fine; the app refused the connection.
|
|
328
328
|
// The server renders DEBUGG_TUNNEL_UPSTREAM_REFUSED from this code and
|
|
329
329
|
// tunnelDisposition keeps the tunnel.
|
|
330
330
|
stream.reset(ErrorCode.UPSTREAM_UNREACHABLE);
|
|
@@ -359,9 +359,9 @@ function terminal(message) {
|
|
|
359
359
|
function handshakeFailureError(state, status, supported, retryable) {
|
|
360
360
|
switch (status) {
|
|
361
361
|
case 401:
|
|
362
|
-
// The word "authtoken" is load-bearing: TunnelManager
|
|
363
|
-
// render its "invalid auth token"
|
|
364
|
-
//
|
|
362
|
+
// The word "authtoken" is load-bearing: TunnelManager's connect ladder
|
|
363
|
+
// matches it to stop retrying and render its "invalid auth token"
|
|
364
|
+
// message.
|
|
365
365
|
return terminal('the debugg tunnel server rejected the authtoken (HTTP 401) — the tunnel key is invalid, ' +
|
|
366
366
|
'expired or revoked. A new one is provisioned on the next call.');
|
|
367
367
|
case 400:
|
|
@@ -379,10 +379,10 @@ function handshakeFailureError(state, status, supported, retryable) {
|
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
381
|
/**
|
|
382
|
-
* A connection-level failure reaching the relay. This
|
|
383
|
-
* the
|
|
384
|
-
*
|
|
385
|
-
*
|
|
382
|
+
* A connection-level failure reaching the relay. This client does NOT honour
|
|
383
|
+
* HTTPS_PROXY (the retired ngrok agent did, which is the one capability lost
|
|
384
|
+
* in the cutover — design §5 "open gap"), so the message says plainly what has
|
|
385
|
+
* to be allowed instead of reading as a generic tunnel error.
|
|
386
386
|
*/
|
|
387
387
|
function egressError(state, detail, code) {
|
|
388
388
|
const relayHost = hostOf(state.relayUrl);
|
|
@@ -163,14 +163,14 @@ export const HandshakeError = Object.freeze({
|
|
|
163
163
|
});
|
|
164
164
|
// ── Server error markers (server emits, client parses) ───────────────────────
|
|
165
165
|
export const TunnelErrorMarker = Object.freeze({
|
|
166
|
-
/** No client connected — the
|
|
166
|
+
/** No client connected — 404, matching what the retired ngrok edge served. */
|
|
167
167
|
OFFLINE: 'DEBUGG_TUNNEL_OFFLINE',
|
|
168
168
|
/** Unknown or revoked tunnel id. */
|
|
169
169
|
UNKNOWN: 'DEBUGG_TUNNEL_UNKNOWN',
|
|
170
|
-
/** The client could not dial the local app —
|
|
170
|
+
/** The client could not dial the local app — 502. */
|
|
171
171
|
UPSTREAM_REFUSED: 'DEBUGG_TUNNEL_UPSTREAM_REFUSED',
|
|
172
172
|
});
|
|
173
|
-
/** HTTP status each marker is served with, chosen to match the
|
|
173
|
+
/** HTTP status each marker is served with, chosen to match the codes they replaced. */
|
|
174
174
|
export const MARKER_HTTP_STATUS = Object.freeze({
|
|
175
175
|
[TunnelErrorMarker.OFFLINE]: 404,
|
|
176
176
|
[TunnelErrorMarker.UNKNOWN]: 404,
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TunnelTransport — the
|
|
3
|
-
*
|
|
2
|
+
* TunnelTransport — the boundary between tunnel LIFECYCLE (tunnelManager.ts:
|
|
3
|
+
* who owns a tunnel, when it is reused, when it is torn down) and tunnel
|
|
4
|
+
* TRANSPORT (debuggTransport.ts: the websocket mux that actually carries
|
|
5
|
+
* bytes).
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
+
* This started life as a two-implementation seam so the ngrok client and the
|
|
8
|
+
* debugg client could run side by side through the rollout. ngrok is gone and
|
|
9
|
+
* there is exactly one implementation now, but the interface is KEPT and the
|
|
10
|
+
* two-implementation machinery around it is not — see the note below.
|
|
11
|
+
*
|
|
12
|
+
* WHY KEEP AN INTERFACE WITH ONE IMPLEMENTATION: it is the injection point
|
|
13
|
+
* (`tunnelManager.transport`) every tunnel-lifecycle test drives, in the same
|
|
14
|
+
* spirit as `caddyFactory`. Without it, testing eviction, the retry ladder or
|
|
15
|
+
* the idle timer means standing up a real websocket against a real relay.
|
|
16
|
+
* It also keeps debuggTransport's ~600 lines of framing, flow control and
|
|
17
|
+
* reconnect out of TunnelManager, which is the reason the file boundary is
|
|
18
|
+
* worth having regardless of how many implementations exist.
|
|
19
|
+
*
|
|
20
|
+
* WHAT WAS REMOVED WITH ngrok, because it was ceremony the moment the second
|
|
21
|
+
* implementation went away:
|
|
22
|
+
* - `TunnelTransportKind` and the `Record<kind, TunnelTransport>` registry —
|
|
23
|
+
* a map with one key, keyed by a union with one member.
|
|
24
|
+
* - `prepare()`, `resetAfterFailedAttempt()` and `onAllTunnelsStopped()` —
|
|
25
|
+
* three optional hooks that existed ONLY for the ngrok agent process
|
|
26
|
+
* (pre-warm, agent reset between retries, module reset when the agent may
|
|
27
|
+
* have exited). debugg has no out-of-process agent, so every one of them
|
|
28
|
+
* was a no-op on the only surviving implementation.
|
|
7
29
|
*
|
|
8
30
|
* 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
|
|
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.
|
|
31
|
+
* the per-session Caddy instance, PortLock, URL rewriting, the retry ladder and
|
|
32
|
+
* the idle timer (docs/debugg-tunnel-server-design-2026-09-19.md §5).
|
|
14
33
|
*/
|
|
15
34
|
export {};
|
|
@@ -6,27 +6,27 @@
|
|
|
6
6
|
* - DEBUGG_TUNNEL_FAULT_MODE env var explicitly set
|
|
7
7
|
*
|
|
8
8
|
* Modes (comma-separated, parseable by parseFaultMode):
|
|
9
|
-
* fail-connect-N:<count> — fail the first <count>
|
|
9
|
+
* fail-connect-N:<count> — fail the first <count> transport connect() attempts
|
|
10
10
|
* empty-url-N:<count> — return empty URL from first <count> connect() attempts
|
|
11
11
|
* delay-connect:<ms> — sleep <ms> before each connect() call
|
|
12
|
-
* inspector-unreachable:1 — the local ngrok agent inspector observes nothing
|
|
13
|
-
* (bead lc62: proves reconciliation is add-only, i.e.
|
|
14
|
-
* a blind inspector cannot cost a re-provision)
|
|
15
|
-
* inspector-adopt:<port> — the inspector reports one synthetic live tunnel on
|
|
16
|
-
* <port>, exercising re-adoption end to end without
|
|
17
|
-
* provisioning a real, billable tunnel
|
|
18
12
|
*
|
|
19
13
|
* Examples:
|
|
20
14
|
* DEBUGG_TUNNEL_FAULT_MODE=fail-connect-N:2
|
|
21
15
|
* DEBUGG_TUNNEL_FAULT_MODE=delay-connect:2000,fail-connect-N:1
|
|
22
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* Two further modes — `inspector-unreachable` and `inspector-adopt` — went
|
|
18
|
+
* away with the ngrok agent inspector they drove. Both existed to exercise
|
|
19
|
+
* re-adoption of a tunnel already registered with the local ngrok agent, a
|
|
20
|
+
* mechanism that the per-session-tunnel cutover retired ("no sharing, ever")
|
|
21
|
+
* and that the debugg transport has no equivalent of: it owns its own socket
|
|
22
|
+
* and there is no out-of-process agent holding tunnels to discover.
|
|
23
23
|
*/
|
|
24
24
|
export function parseFaultMode(raw) {
|
|
25
25
|
if (!raw)
|
|
26
26
|
return null;
|
|
27
27
|
const mode = {};
|
|
28
28
|
for (const token of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
29
|
-
const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect
|
|
29
|
+
const m = token.match(/^(fail-connect-N|empty-url-N|delay-connect):(\d+)$/);
|
|
30
30
|
if (!m)
|
|
31
31
|
continue;
|
|
32
32
|
const [, name, valStr] = m;
|
|
@@ -37,12 +37,6 @@ export function parseFaultMode(raw) {
|
|
|
37
37
|
mode.emptyUrlN = val;
|
|
38
38
|
else if (name === 'delay-connect')
|
|
39
39
|
mode.delayConnectMs = val;
|
|
40
|
-
// Value-less switch, but kept in the `name:<digits>` grammar so one regex
|
|
41
|
-
// covers every mode. Any non-zero value turns it on.
|
|
42
|
-
else if (name === 'inspector-unreachable')
|
|
43
|
-
mode.inspectorUnreachable = val !== 0;
|
|
44
|
-
else if (name === 'inspector-adopt')
|
|
45
|
-
mode.inspectorAdoptPort = val;
|
|
46
40
|
}
|
|
47
41
|
return Object.keys(mode).length > 0 ? mode : null;
|
|
48
42
|
}
|