@debugg-ai/debugg-ai-mcp 6.0.3 → 6.1.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 +38 -0
- package/dist/handlers/probePageHandler.js +54 -11
- package/dist/handlers/testPageChangesHandler.js +45 -38
- package/dist/handlers/triggerCrawlHandler.js +40 -4
- package/dist/services/tunnel/debuggTransport.js +26 -19
- package/dist/services/tunnel/proxy.js +65 -0
- package/dist/services/tunnels.js +11 -6
- package/dist/utils/axiosTransport.js +18 -1
- package/dist/utils/projectRequired.js +31 -0
- package/dist/utils/tunnelContext.js +10 -1
- package/dist/utils/urlParser.js +19 -5
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ All notable changes to the DebuggAI MCP project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Fixed — REST client now CONNECT-tunnels through HTTPS_PROXY / honors NO_PROXY (platform-y2ob.3)
|
|
11
|
+
- The API client (axios) sent an absolute-URI request to the proxy, which CONNECT-only proxies reject with HTTP 405, so a proxied user failed before any tunnel was attempted. It now opens a CONNECT tunnel via the same proxy selection and NO_PROXY matching as the tunnel websocket. A 407 names the proxy host (never its credentials). No proxy, or a NO_PROXY match: unchanged.
|
|
12
|
+
|
|
13
|
+
### Fixed — a failed project lookup no longer reads as "no project linked" (platform-y2ob.4)
|
|
14
|
+
- check_app_in_browser and trigger_crawl swallowed any error from the repo-to-project lookup and then said no project is linked to the repo. A lookup error now returns `ProjectLookupFailed` with the HTTP status and the backend's message; only a successful lookup that finds nothing says `ProjectRequired`.
|
|
15
|
+
|
|
16
|
+
### Fixed — probe_page no longer hides a failed run; check_app_in_browser ProjectRequired says what to pass (platform-y2ob.1, platform-y2ob.2)
|
|
17
|
+
|
|
18
|
+
- `probe_page`: when the backend records the execution as failed/error, the response is `isError` and carries the recorded `status`, `errorMessage`, `agentError` and `errorInfo` verbatim, and each result's `error` is that message. A target that returns no page data now reports the execution status and that no page data came back instead of null fields with no reason.
|
|
19
|
+
- `check_app_in_browser`: `ProjectRequired` now uses the same text as `trigger_crawl` (shared in `utils/projectRequired.ts`), naming `projectUuid` and `repoName`.
|
|
20
|
+
|
|
21
|
+
### Fixed — tunnel provision reports its client version; no downgrade advice (platform-65o4)
|
|
22
|
+
|
|
23
|
+
- `POST api/v1/tunnels/` now sends `client_version`, so the backend's `Tunnel.client_version` is no longer empty and failing client versions can be told apart.
|
|
24
|
+
- The two error branches that said "Pin @debugg-ai/debugg-ai-mcp@4.4.1" now say to run `npx @debugg-ai/debugg-ai-mcp@latest` and restart the MCP client; a backend that still answers ngrok is reported as out of date.
|
|
25
|
+
- The live tunnel test accepts a loopback `ws://` relay, so it runs against a local stack.
|
|
26
|
+
|
|
27
|
+
### Fixed — foreign tunnel addresses no longer rewritten to localhost (platform-o7wv)
|
|
28
|
+
- `replaceTunnelUrls` / `sanitizeResponseUrls` rewrote EVERY `*.tunnel.debugg.ai` host in a response to the caller's localhost origin, hiding real fixed addresses (e.g. a `pub-rt-...` tunnel). Now only the hostname(s) of the tunnel this session opened (`ctx.tunnelId` / session tunnel) are mapped back; all other tunnel-domain addresses are left as is. The session's own tunnel behaves as before.
|
|
29
|
+
|
|
30
|
+
### Fixed — trigger_crawl fails before the tunnel when no project can be determined (platform-xmkv)
|
|
31
|
+
|
|
32
|
+
- `trigger_crawl` never resolved a project (the `projectUuid` doc said "auto-detected" but only an explicit value was sent), so from a repo with no matching project the backend answered `Missing required field(s) in context_data: project_id`, which names no tool parameter and reads as a tunnel failure. It now resolves `projectUuid` > a named `environmentId` > `repoName` > the git checkout, sends the result as `projectId`, and returns `ProjectRequired` (input-validation text, tells you to pass `projectUuid` or `repoName`) BEFORE the port probe or any tunnel.
|
|
33
|
+
- `check_app_in_browser` runs the same `ProjectRequired` check before provisioning a tunnel instead of after.
|
|
34
|
+
- `probe_page` revokes a tunnel key whose `ensureTunnel` failed (bead xkoh.9). The old cleanup indexed a list that is empty exactly on that path, and was misaligned when a public target preceded a localhost one.
|
|
35
|
+
|
|
36
|
+
### Added — the debugg tunnel honours HTTPS_PROXY (platform-neog)
|
|
37
|
+
|
|
38
|
+
The tunnel's control websocket now goes through an HTTP CONNECT proxy when
|
|
39
|
+
`HTTPS_PROXY` / `https_proxy` / `ALL_PROXY` / `all_proxy` is set, unless the relay
|
|
40
|
+
host matches `NO_PROXY` / `no_proxy` (host, `.suffix`, `*.suffix`, `host:port`, `*`).
|
|
41
|
+
`http://user:pass@host:port` proxy auth works. A proxy 407 or an unreachable proxy
|
|
42
|
+
now says so, naming only the proxy host:port (never credentials). With no proxy
|
|
43
|
+
variable set, behaviour is unchanged. Replaces the old "proxies are not supported"
|
|
44
|
+
message. New dependency: `https-proxy-agent`.
|
|
45
|
+
|
|
8
46
|
## [6.0.0] — 2026-10-04 — BREAKING
|
|
9
47
|
|
|
10
48
|
### Changed — responses are the backend's data, not MCP-authored text
|
|
@@ -108,6 +108,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
108
108
|
// bare key ids because revoking follows the transport the backend picked
|
|
109
109
|
// (it routes to the tunnel's own revoke endpoint).
|
|
110
110
|
const acquiredProvisions = [];
|
|
111
|
+
// Provisions whose ensureTunnel completed with a live tunnelId. Anything in
|
|
112
|
+
// acquiredProvisions but not here is an orphaned key (platform-xmkv).
|
|
113
|
+
const connectedProvisions = new Set();
|
|
111
114
|
// Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
|
|
112
115
|
const TOTAL_STEPS = 3 + input.targets.length + 1;
|
|
113
116
|
let progressStep = 0;
|
|
@@ -168,6 +171,8 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
168
171
|
const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
|
|
169
172
|
throw new Error(`Tunnel creation failed for ${ctx.originalUrl}: ${msg}`);
|
|
170
173
|
}
|
|
174
|
+
if (tunneled.tunnelId)
|
|
175
|
+
connectedProvisions.add(tunnel);
|
|
171
176
|
// NOTE: the tunnel health probe used to run right here, immediately
|
|
172
177
|
// after ensureTunnel. Under the session-tunnel + Caddy model that is
|
|
173
178
|
// WRONG — ensureTunnel only confirms the tunnel reaches Caddy, not
|
|
@@ -217,6 +222,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
217
222
|
const browserSessions = [];
|
|
218
223
|
let lastExecutionDurationMs;
|
|
219
224
|
let completedOffset = 0;
|
|
225
|
+
// Executions the backend recorded as failed/error (platform-y2ob.1) —
|
|
226
|
+
// surfaced verbatim at top level, never swallowed into empty results.
|
|
227
|
+
const failedExecutions = [];
|
|
220
228
|
for (const group of groups) {
|
|
221
229
|
// §2.4: acquire this session's shared Caddy route for the group's port
|
|
222
230
|
// BEFORE dispatching — held for the group's whole execute+poll cycle,
|
|
@@ -327,6 +335,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
327
335
|
}
|
|
328
336
|
}, abortController.signal);
|
|
329
337
|
lastExecutionDurationMs = finalExecution.durationMs ?? undefined;
|
|
338
|
+
const executionFailed = isFailedStatus(finalExecution.status);
|
|
339
|
+
if (executionFailed)
|
|
340
|
+
failedExecutions.push(finalExecution);
|
|
330
341
|
if (finalExecution.browserSession)
|
|
331
342
|
browserSessions.push(finalExecution.browserSession);
|
|
332
343
|
// ── Format this group's results into the shared, ORIGINAL-order arrays ──
|
|
@@ -371,6 +382,13 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
371
382
|
if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) {
|
|
372
383
|
result.surferPageUuid = data.surferPageUuid;
|
|
373
384
|
}
|
|
385
|
+
// platform-y2ob.1: a target with no page data must never read as a
|
|
386
|
+
// clean success. Relay the backend's recorded failure verbatim; when
|
|
387
|
+
// it recorded none, state what was observed.
|
|
388
|
+
if (!result.error && !hasPageData(data)) {
|
|
389
|
+
result.error = executionFailureText(finalExecution)
|
|
390
|
+
?? `Execution status: ${finalExecution.status ?? 'unknown'}; no page data was returned for this target.`;
|
|
391
|
+
}
|
|
374
392
|
// Bead debugg_ai_mcp-6cfv.6 fix: sanitize each result against ITS
|
|
375
393
|
// OWN target's tunnel context, never the whole accumulated payload.
|
|
376
394
|
// Under the old per-port-tunnel model every target had a distinct
|
|
@@ -411,6 +429,18 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
411
429
|
// (single-execution) response shape any existing caller already parses.
|
|
412
430
|
if (executionIds.length > 1)
|
|
413
431
|
responsePayload.executionIds = executionIds;
|
|
432
|
+
if (failedExecutions.length > 0) {
|
|
433
|
+
// Backend's recorded status + message, verbatim (same fields
|
|
434
|
+
// check_app_in_browser relays: errorMessage, agentError, errorInfo).
|
|
435
|
+
const f = failedExecutions[0];
|
|
436
|
+
responsePayload.status = f.status;
|
|
437
|
+
if (f.errorMessage)
|
|
438
|
+
responsePayload.errorMessage = f.errorMessage;
|
|
439
|
+
if (f.state?.error)
|
|
440
|
+
responsePayload.agentError = f.state.error;
|
|
441
|
+
if (f.errorInfo)
|
|
442
|
+
responsePayload.errorInfo = f.errorInfo;
|
|
443
|
+
}
|
|
414
444
|
if (browserSessions.length === 1) {
|
|
415
445
|
responsePayload.browserSession = browserSessions[0];
|
|
416
446
|
}
|
|
@@ -447,7 +477,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
447
477
|
}
|
|
448
478
|
}
|
|
449
479
|
}
|
|
450
|
-
return
|
|
480
|
+
return failedExecutions.length > 0
|
|
481
|
+
? { content: responseContent, isError: true }
|
|
482
|
+
: { content: responseContent };
|
|
451
483
|
}
|
|
452
484
|
catch (error) {
|
|
453
485
|
const duration = Date.now() - startTime;
|
|
@@ -463,16 +495,27 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
463
495
|
// Tunnels intentionally NOT torn down — reuse pattern (bead vwd) +
|
|
464
496
|
// 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the
|
|
465
497
|
// key but tunnel creation failed before ensureTunnel completed).
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
for (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
client.tunnels.revoke(provision).catch(err => logger.warn(`Failed to revoke unused tunnel ${provision.tunnelId}: ${err}`));
|
|
475
|
-
}
|
|
498
|
+
// Revoke only orphaned keys: provisioned, but ensureTunnel threw or
|
|
499
|
+
// returned no tunnelId. (The old check indexed targetContexts by provision
|
|
500
|
+
// position, which is undefined exactly when ensureTunnel threw, and
|
|
501
|
+
// misaligned whenever a public target preceded a localhost one.)
|
|
502
|
+
for (const provision of acquiredProvisions) {
|
|
503
|
+
if (connectedProvisions.has(provision))
|
|
504
|
+
continue;
|
|
505
|
+
client.tunnels.revoke(provision).catch(err => logger.warn(`Failed to revoke unused tunnel ${provision.tunnelId}: ${err}`));
|
|
476
506
|
}
|
|
477
507
|
}
|
|
478
508
|
}
|
|
509
|
+
function isFailedStatus(status) {
|
|
510
|
+
return typeof status === 'string' && ['failed', 'error'].includes(status.toLowerCase());
|
|
511
|
+
}
|
|
512
|
+
function hasPageData(data) {
|
|
513
|
+
return ['capturedUrl', 'finalUrl', 'url', 'title'].some(k => typeof data?.[k] === 'string' && data[k])
|
|
514
|
+
|| typeof data?.statusCode === 'number';
|
|
515
|
+
}
|
|
516
|
+
/** The backend's own recorded failure text for an execution, verbatim. */
|
|
517
|
+
function executionFailureText(exec) {
|
|
518
|
+
if (!isFailedStatus(exec?.status))
|
|
519
|
+
return null;
|
|
520
|
+
return exec.errorMessage || exec.state?.error || `Execution status: ${exec.status}`;
|
|
521
|
+
}
|
|
@@ -21,6 +21,7 @@ import { extractLocalhostPort, rewriteLocalhostInText } from '../utils/urlParser
|
|
|
21
21
|
import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
|
|
22
22
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
23
23
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
24
|
+
import { projectRequiredMessage, projectLookupFailedPayload } from '../utils/projectRequired.js';
|
|
24
25
|
const logger = new Logger({ module: 'testPageChangesHandler' });
|
|
25
26
|
// Bead kbxy: bounded retry on known transient backend signatures (Pydantic
|
|
26
27
|
// JSON parse errors, 502s, ECONNRESETs). Default 1 retry; env-overridable
|
|
@@ -199,6 +200,43 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
199
200
|
const MAX_EXEC_STEPS = 25;
|
|
200
201
|
const TOTAL_STEPS = SETUP_STEPS + MAX_EXEC_STEPS;
|
|
201
202
|
try {
|
|
203
|
+
// --- Project: resolve + validate BEFORE any tunnel ---
|
|
204
|
+
const repoName = input.repoName || detectRepoName();
|
|
205
|
+
// An environment belongs to one project, so naming one names the project:
|
|
206
|
+
// the backend files the run under it (platform-98fv.21). The working folder's
|
|
207
|
+
// repo name is only a guess, used when no environment is named.
|
|
208
|
+
const namedEnvironmentId = input.environmentId || input.auth?.environmentId;
|
|
209
|
+
let lookupError;
|
|
210
|
+
const projectUuid = !namedEnvironmentId && repoName
|
|
211
|
+
? await getCachedProjectUuid(repoName, async (repo) => {
|
|
212
|
+
try {
|
|
213
|
+
return await client.findProjectByRepoName(repo);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
logger.warn(`Failed to look up project for repo "${repo}": ${err}`);
|
|
217
|
+
lookupError = err;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
: undefined;
|
|
222
|
+
// Fail fast + actionable when project_id can't be resolved (pinned backend
|
|
223
|
+
// semantics: project_id is required). Checked BEFORE any tunnel is
|
|
224
|
+
// provisioned (platform-xmkv): a tunnel bought for a call that cannot run
|
|
225
|
+
// is a billed leak, and beats a backend node failing mid-run.
|
|
226
|
+
if (!projectUuid && !namedEnvironmentId && lookupError !== undefined && repoName) {
|
|
227
|
+
// The lookup FAILED — not "no project linked" (platform-y2ob.4).
|
|
228
|
+
const payload = projectLookupFailedPayload(repoName, lookupError);
|
|
229
|
+
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
230
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
231
|
+
}
|
|
232
|
+
if (!projectUuid && !namedEnvironmentId) {
|
|
233
|
+
const payload = {
|
|
234
|
+
error: 'ProjectRequired',
|
|
235
|
+
message: projectRequiredMessage(repoName),
|
|
236
|
+
};
|
|
237
|
+
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
238
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
239
|
+
}
|
|
202
240
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
203
241
|
if (ctx.isLocalhost) {
|
|
204
242
|
// Bead 1om: pre-flight local port probe BEFORE committing to backend
|
|
@@ -319,47 +357,16 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
319
357
|
if (progressCallback) {
|
|
320
358
|
await progressCallback({ progress: 2, total: TOTAL_STEPS, message: 'Locating evaluation workflow template...' });
|
|
321
359
|
}
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// drift apart (bug clka: the key used to be a decoupled 'app evaluation'
|
|
330
|
-
// literal while the lookup searched a different string).
|
|
331
|
-
getCachedTemplateUuid(getEvalTemplateSlug(), async () => {
|
|
332
|
-
return client.workflows.findEvaluationTemplate();
|
|
333
|
-
}),
|
|
334
|
-
!namedEnvironmentId && repoName
|
|
335
|
-
? getCachedProjectUuid(repoName, async (repo) => {
|
|
336
|
-
try {
|
|
337
|
-
return await client.findProjectByRepoName(repo);
|
|
338
|
-
}
|
|
339
|
-
catch (err) {
|
|
340
|
-
logger.warn(`Failed to look up project for repo "${repo}": ${err}`);
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
})
|
|
344
|
-
: Promise.resolve(undefined),
|
|
345
|
-
]);
|
|
360
|
+
// --- Resolve template (post-tunnel) ---
|
|
361
|
+
// Cache key = the dispatch slug so the cache key and the lookup can never
|
|
362
|
+
// drift apart (bug clka: the key used to be a decoupled 'app evaluation'
|
|
363
|
+
// literal while the lookup searched a different string).
|
|
364
|
+
const templateUuid = await getCachedTemplateUuid(getEvalTemplateSlug(), async () => {
|
|
365
|
+
return client.workflows.findEvaluationTemplate();
|
|
366
|
+
});
|
|
346
367
|
if (!templateUuid) {
|
|
347
368
|
throw new Error(`App Evaluation workflow template not found (slug "${getEvalTemplateSlug()}").`);
|
|
348
369
|
}
|
|
349
|
-
// Fail fast + actionable when project_id can't be resolved (pinned backend
|
|
350
|
-
// semantics: project_id is required). Surfacing "link this repo to a
|
|
351
|
-
// project" now — before executeWorkflow — beats letting a backend workflow
|
|
352
|
-
// node fail mid-run several minutes into the evaluation.
|
|
353
|
-
if (!projectUuid && !namedEnvironmentId) {
|
|
354
|
-
const payload = {
|
|
355
|
-
error: 'ProjectRequired',
|
|
356
|
-
message: repoName
|
|
357
|
-
? `No DebuggAI project found for repo "${repoName}".`
|
|
358
|
-
: 'No git repository detected and no repoName passed.',
|
|
359
|
-
};
|
|
360
|
-
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
361
|
-
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
362
|
-
}
|
|
363
370
|
// --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
|
|
364
371
|
const contextData = {
|
|
365
372
|
targetUrl: ctx.targetUrl ?? originalUrl,
|
|
@@ -21,11 +21,12 @@ import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js
|
|
|
21
21
|
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
22
22
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
|
-
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
25
|
-
import { detectLocalGitRef } from '../utils/gitContext.js';
|
|
24
|
+
import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
25
|
+
import { detectLocalGitRef, detectRepoName } from '../utils/gitContext.js';
|
|
26
26
|
import { getCrawlTemplateSlug } from '../services/workflows.js';
|
|
27
27
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
28
28
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
29
|
+
import { projectRequiredMessage, projectLookupFailedPayload } from '../utils/projectRequired.js';
|
|
29
30
|
const logger = new Logger({ module: 'triggerCrawlHandler' });
|
|
30
31
|
// Bead kbo9: same env-driven retry budget as testPageChangesHandler (kbxy).
|
|
31
32
|
function getMaxTransientRetries() {
|
|
@@ -84,6 +85,41 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
84
85
|
requestSignal.addEventListener('abort', onAbort, { once: true });
|
|
85
86
|
}
|
|
86
87
|
try {
|
|
88
|
+
// --- Project: resolve BEFORE any tunnel (platform-xmkv) ---
|
|
89
|
+
// The backend requires a project; failing here costs nothing, failing after
|
|
90
|
+
// provisioning leaves a billed tunnel behind and surfaces as a raw backend
|
|
91
|
+
// "Missing required field(s) in context_data: project_id" that names no
|
|
92
|
+
// tool parameter. Precedence: projectUuid > a named environment (it belongs
|
|
93
|
+
// to one project, the backend resolves it) > repoName > the git checkout.
|
|
94
|
+
let projectUuid = input.projectUuid;
|
|
95
|
+
let lookupError;
|
|
96
|
+
const repoName = input.repoName || detectRepoName();
|
|
97
|
+
if (!projectUuid && !input.environmentId && repoName) {
|
|
98
|
+
projectUuid = await getCachedProjectUuid(repoName, async (repo) => {
|
|
99
|
+
try {
|
|
100
|
+
return await client.findProjectByRepoName(repo);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
logger.warn(`Failed to look up project for repo "${repo}": ${err}`);
|
|
104
|
+
lookupError = err;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (!projectUuid && !input.environmentId && lookupError !== undefined && repoName) {
|
|
110
|
+
// The lookup FAILED — not "no project linked" (platform-y2ob.4).
|
|
111
|
+
const payload = projectLookupFailedPayload(repoName, lookupError);
|
|
112
|
+
logger.warn(`trigger_crawl: ${payload.message}`);
|
|
113
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
114
|
+
}
|
|
115
|
+
if (!projectUuid && !input.environmentId) {
|
|
116
|
+
const payload = {
|
|
117
|
+
error: 'ProjectRequired',
|
|
118
|
+
message: projectRequiredMessage(repoName),
|
|
119
|
+
};
|
|
120
|
+
logger.warn(`trigger_crawl: ${payload.message}`);
|
|
121
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
122
|
+
}
|
|
87
123
|
// --- Tunnel: reuse existing or provision a fresh one ---
|
|
88
124
|
if (ctx.isLocalhost) {
|
|
89
125
|
// Bead 1om: pre-flight local port probe BEFORE provision/tunnel/backend.
|
|
@@ -191,8 +227,8 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
|
|
|
191
227
|
const contextData = {
|
|
192
228
|
targetUrl: ctx.targetUrl ?? ctx.originalUrl,
|
|
193
229
|
};
|
|
194
|
-
if (
|
|
195
|
-
contextData.projectId =
|
|
230
|
+
if (projectUuid)
|
|
231
|
+
contextData.projectId = projectUuid;
|
|
196
232
|
contextData.headless = true; // D7: the MCP always runs headless — no opt-out.
|
|
197
233
|
if (typeof input.timeoutSeconds === 'number')
|
|
198
234
|
contextData.timeoutSeconds = input.timeoutSeconds;
|
|
@@ -21,6 +21,7 @@ import * as tls from 'node:tls';
|
|
|
21
21
|
import WebSocket from 'ws';
|
|
22
22
|
import { Logger } from '../../utils/logger.js';
|
|
23
23
|
import { buildHandshakeHeaders, interpretHandshakeResponse, webSocketTransport, CloseCode, ErrorCode, MAX_FRAME_SIZE, PROBE_TIMEOUT_MS, TunnelSession, } from './protocol/index.js';
|
|
24
|
+
import { resolveTunnelProxy } from './proxy.js';
|
|
24
25
|
import { registerControlProbe, unregisterControlProbe } from './probeRegistry.js';
|
|
25
26
|
const logger = new Logger({ module: 'debuggTransport' });
|
|
26
27
|
/** A DebuggSocketLike is always usable as the protocol module's WebSocketLike. */
|
|
@@ -29,6 +30,7 @@ void _socketIsWebSocketLike;
|
|
|
29
30
|
const DEFAULT_RECONNECT_BACKOFF_MS = [250, 500, 1000, 2000, 5000, 10000];
|
|
30
31
|
const defaultSocketFactory = (url, init) => new WebSocket(url, {
|
|
31
32
|
headers: init.headers,
|
|
33
|
+
...(init.agent ? { agent: init.agent } : {}),
|
|
32
34
|
// Both ends disable it: dev-server payloads are already compressed and
|
|
33
35
|
// deflate's per-connection buffers break the protocol's bounded-memory
|
|
34
36
|
// property (bead debugg_ai_mcp-xkoh.1.2 §0).
|
|
@@ -100,7 +102,8 @@ class DebuggTransport {
|
|
|
100
102
|
});
|
|
101
103
|
// The token rides in a header and NEVER in the URL: the ALB and nginx log
|
|
102
104
|
// URLs, not headers.
|
|
103
|
-
const
|
|
105
|
+
const proxy = resolveTunnelProxy(state.relayUrl);
|
|
106
|
+
const socket = this.socketFactory(state.relayUrl, proxy ? { headers, agent: proxy.agent } : { headers });
|
|
104
107
|
// ATTACH THE SESSION NOW, not on 'open', and not after connect() resolves.
|
|
105
108
|
//
|
|
106
109
|
// The server registers a tunnel the instant the handshake completes and may
|
|
@@ -145,7 +148,7 @@ class DebuggTransport {
|
|
|
145
148
|
}
|
|
146
149
|
};
|
|
147
150
|
const timer = setTimeout(() => {
|
|
148
|
-
fail(egressError(state, 'the connection timed out', 'ETIMEDOUT'));
|
|
151
|
+
fail(egressError(state, 'the connection timed out', 'ETIMEDOUT', proxy));
|
|
149
152
|
abandon();
|
|
150
153
|
}, this.handshakeTimeoutMs);
|
|
151
154
|
timer.unref?.();
|
|
@@ -187,14 +190,14 @@ class DebuggTransport {
|
|
|
187
190
|
const status = res?.statusCode ?? 0;
|
|
188
191
|
const result = interpretHandshakeResponse(status, res?.headers ?? {});
|
|
189
192
|
abandon();
|
|
190
|
-
fail(handshakeFailureError(state, status, result.ok ? undefined : result.supported, result.ok ? true : result.retryable));
|
|
193
|
+
fail(handshakeFailureError(state, status, result.ok ? undefined : result.supported, result.ok ? true : result.retryable, proxy));
|
|
191
194
|
});
|
|
192
195
|
socket.on('error', (err) => {
|
|
193
196
|
// Before the upgrade completes this is always a connection-level
|
|
194
197
|
// failure: DNS, TLS, refused, or blocked egress.
|
|
195
198
|
if (!settled)
|
|
196
199
|
abandon();
|
|
197
|
-
fail(egressError(state, err.message, err.code));
|
|
200
|
+
fail(egressError(state, err.message, err.code, proxy));
|
|
198
201
|
});
|
|
199
202
|
});
|
|
200
203
|
}
|
|
@@ -356,7 +359,7 @@ function tunnelError(message, retryable) {
|
|
|
356
359
|
function terminal(message) {
|
|
357
360
|
return tunnelError(message, false);
|
|
358
361
|
}
|
|
359
|
-
function handshakeFailureError(state, status, supported, retryable) {
|
|
362
|
+
function handshakeFailureError(state, status, supported, retryable, proxy) {
|
|
360
363
|
switch (status) {
|
|
361
364
|
case 401:
|
|
362
365
|
// The word "authtoken" is load-bearing: TunnelManager's connect ladder
|
|
@@ -371,28 +374,32 @@ function handshakeFailureError(state, status, supported, retryable) {
|
|
|
371
374
|
`${supported ? `, server supports ${supported.join(', ')}` : ''}). ` +
|
|
372
375
|
'Update @debugg-ai/debugg-ai-mcp.');
|
|
373
376
|
case 407:
|
|
374
|
-
return terminal(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
+
return terminal(proxy
|
|
378
|
+
? `the HTTP proxy ${proxy.display} (from ${proxy.varName}) demanded authentication for the tunnel ` +
|
|
379
|
+
`connection to ${hostOf(state.relayUrl)} (HTTP 407). Put valid credentials in the proxy URL ` +
|
|
380
|
+
`(http://user:password@${proxy.display}) or ask your network admin to allow it.`
|
|
381
|
+
: `an HTTP proxy at your network edge intercepted the tunnel connection to ${hostOf(state.relayUrl)} ` +
|
|
382
|
+
'and demanded authentication (HTTP 407). Set HTTPS_PROXY=http://user:password@host:port so the ' +
|
|
383
|
+
'tunnel connects through it, or allow a direct outbound HTTPS (443) connection.');
|
|
377
384
|
default:
|
|
378
385
|
return tunnelError(`the debugg tunnel server refused the connection (HTTP ${status})`, retryable);
|
|
379
386
|
}
|
|
380
387
|
}
|
|
381
388
|
/**
|
|
382
|
-
* A connection-level failure reaching the relay
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* to be allowed instead of reading as a generic tunnel error.
|
|
389
|
+
* A connection-level failure reaching the relay (or, when HTTPS_PROXY is in
|
|
390
|
+
* use, the proxy in front of it). Says plainly what has to be allowed instead
|
|
391
|
+
* of reading as a generic tunnel error. Never includes proxy credentials.
|
|
386
392
|
*/
|
|
387
|
-
function egressError(state, detail, code) {
|
|
393
|
+
function egressError(state, detail, code, proxy) {
|
|
388
394
|
const relayHost = hostOf(state.relayUrl);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
395
|
+
if (proxy) {
|
|
396
|
+
return tunnelError(`could not connect to the debugg tunnel server at ${relayHost} through the HTTP proxy ${proxy.display} ` +
|
|
397
|
+
`(from ${proxy.varName}) (${code ?? 'connection failed'}: ${detail}). Check that the proxy is reachable ` +
|
|
398
|
+
`and allows CONNECT to ${relayHost}, or set NO_PROXY=${relayHost} to connect directly.`, true);
|
|
399
|
+
}
|
|
393
400
|
return tunnelError(`could not reach the debugg tunnel server at ${relayHost} (${code ?? 'connection failed'}: ${detail}). ` +
|
|
394
|
-
`Testing a local app needs
|
|
395
|
-
|
|
401
|
+
`Testing a local app needs outbound HTTPS to ${relayHost} on port 443 — allow it in your firewall, ` +
|
|
402
|
+
'VPN or container network rules, or set HTTPS_PROXY=http://host:port if you reach the internet through an HTTP proxy.', true);
|
|
396
403
|
}
|
|
397
404
|
// ── Address handling ─────────────────────────────────────────────────────────
|
|
398
405
|
/**
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
2
|
+
const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
3
|
+
function defaultPort(url) {
|
|
4
|
+
if (url.port)
|
|
5
|
+
return Number(url.port);
|
|
6
|
+
return url.protocol === 'wss:' || url.protocol === 'https:' ? 443 : 80;
|
|
7
|
+
}
|
|
8
|
+
/** curl-style NO_PROXY: comma/space list, `*`, `.suffix`, `*.suffix`, `host`, `host:port`. */
|
|
9
|
+
export function noProxyMatches(target, noProxy) {
|
|
10
|
+
const host = target.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
11
|
+
const port = defaultPort(target);
|
|
12
|
+
for (const raw of noProxy.split(/[,\s]+/)) {
|
|
13
|
+
let entry = raw.trim().toLowerCase();
|
|
14
|
+
if (!entry)
|
|
15
|
+
continue;
|
|
16
|
+
if (entry === '*')
|
|
17
|
+
return true;
|
|
18
|
+
let entryPort;
|
|
19
|
+
const m = /^(.*):(\d+)$/.exec(entry);
|
|
20
|
+
if (m && !m[1].includes(':')) {
|
|
21
|
+
entry = m[1];
|
|
22
|
+
entryPort = Number(m[2]);
|
|
23
|
+
}
|
|
24
|
+
if (entryPort !== undefined && entryPort !== port)
|
|
25
|
+
continue;
|
|
26
|
+
entry = entry.replace(/^\*?\./, '');
|
|
27
|
+
if (!entry)
|
|
28
|
+
continue;
|
|
29
|
+
if (host === entry || host.endsWith(`.${entry}`))
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
export function resolveTunnelProxy(relayUrl, env = process.env) {
|
|
35
|
+
const varName = PROXY_VARS.find((name) => env[name]?.trim());
|
|
36
|
+
if (!varName)
|
|
37
|
+
return undefined;
|
|
38
|
+
let target;
|
|
39
|
+
try {
|
|
40
|
+
target = new URL(relayUrl);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const noProxy = env.NO_PROXY ?? env.no_proxy;
|
|
46
|
+
if (noProxy && noProxyMatches(target, noProxy))
|
|
47
|
+
return undefined;
|
|
48
|
+
const value = env[varName].trim();
|
|
49
|
+
let proxyUrl;
|
|
50
|
+
try {
|
|
51
|
+
proxyUrl = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:')
|
|
57
|
+
return undefined;
|
|
58
|
+
if (!proxyUrl.hostname)
|
|
59
|
+
return undefined;
|
|
60
|
+
return {
|
|
61
|
+
varName,
|
|
62
|
+
display: `${proxyUrl.hostname}${proxyUrl.port ? `:${proxyUrl.port}` : ''}`,
|
|
63
|
+
agent: new HttpsProxyAgent(proxyUrl.toString()),
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/services/tunnels.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Provisions short-lived debugg tunnel tokens for MCP-managed tunnel setup.
|
|
4
4
|
* Called before executeWorkflow so the tunnel URL is known before execution starts.
|
|
5
5
|
*/
|
|
6
|
+
import { config } from '../config/index.js';
|
|
6
7
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
7
8
|
import { isValidTunnelDomain, registerTunnelDomain, } from '../utils/tunnelDomains.js';
|
|
8
9
|
/**
|
|
@@ -111,7 +112,11 @@ export const createTunnelsService = (tx) => {
|
|
|
111
112
|
async function provision(purpose = 'workflow') {
|
|
112
113
|
let response;
|
|
113
114
|
try {
|
|
114
|
-
response = await tx.post('api/v1/tunnels/', {
|
|
115
|
+
response = await tx.post('api/v1/tunnels/', {
|
|
116
|
+
purpose,
|
|
117
|
+
transports: [...SUPPORTED_TRANSPORTS],
|
|
118
|
+
client_version: config.server.version,
|
|
119
|
+
});
|
|
115
120
|
}
|
|
116
121
|
catch (err) {
|
|
117
122
|
throw classifyProvisionError(err);
|
|
@@ -135,9 +140,9 @@ export const createTunnelsService = (tx) => {
|
|
|
135
140
|
throw new TunnelProvisionError({
|
|
136
141
|
message: 'Tunnel provisioning selected the ngrok transport, which this version of ' +
|
|
137
142
|
'@debugg-ai/debugg-ai-mcp no longer implements (it offered: ' +
|
|
138
|
-
`${SUPPORTED_TRANSPORTS.join(', ')}).
|
|
139
|
-
'
|
|
140
|
-
'backend
|
|
143
|
+
`${SUPPORTED_TRANSPORTS.join(', ')}). Run ` +
|
|
144
|
+
'`npx @debugg-ai/debugg-ai-mcp@latest` and restart your MCP client. If the backend still ' +
|
|
145
|
+
'answers ngrok, the backend is out of date; please report it.',
|
|
141
146
|
retryable: false,
|
|
142
147
|
});
|
|
143
148
|
}
|
|
@@ -154,8 +159,8 @@ export const createTunnelsService = (tx) => {
|
|
|
154
159
|
if (!response.relayUrl || !response.tunnelDomain) {
|
|
155
160
|
throw new TunnelProvisionError({
|
|
156
161
|
message: 'Tunnel provisioning returned no relayUrl/tunnelDomain, so there is no debugg tunnel to ' +
|
|
157
|
-
'connect to.
|
|
158
|
-
'
|
|
162
|
+
'connect to. Run `npx @debugg-ai/debugg-ai-mcp@latest` and restart your MCP client. If the ' +
|
|
163
|
+
'backend still answers without a relayUrl, the backend is out of date; please report it.',
|
|
159
164
|
retryable: false,
|
|
160
165
|
});
|
|
161
166
|
}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// utils/axiosTransport.ts
|
|
2
2
|
import axios from "axios";
|
|
3
3
|
import { objToCamelCase, objToSnakeCase, } from "./objectNaming.js";
|
|
4
|
+
import { resolveTunnelProxy } from "../services/tunnel/proxy.js";
|
|
4
5
|
/**
|
|
5
6
|
* A tiny wrapper around axios that keeps all your interceptors
|
|
6
7
|
* but gives service factories a clean, typed surface.
|
|
7
8
|
*/
|
|
8
9
|
export class AxiosTransport {
|
|
9
10
|
axios;
|
|
10
|
-
constructor({ baseUrl, apiKey, tokenType = 'token', instance }) {
|
|
11
|
+
constructor({ baseUrl, apiKey, tokenType = 'token', instance, env = process.env }) {
|
|
12
|
+
// HTTPS_PROXY / NO_PROXY via the same selection the tunnel websocket uses
|
|
13
|
+
// (platform-y2ob.3). axios's built-in env proxy sends an absolute-URI
|
|
14
|
+
// request, which CONNECT-only proxies answer 405; use a CONNECT tunnel.
|
|
15
|
+
// No proxy applies => the config below is exactly what it always was.
|
|
16
|
+
const proxy = instance ? undefined : resolveTunnelProxy(baseUrl, env);
|
|
17
|
+
const proxyConfig = proxy
|
|
18
|
+
? { proxy: false, httpAgent: proxy.agent, httpsAgent: proxy.agent }
|
|
19
|
+
: {};
|
|
11
20
|
// Use an injected instance or create one that mimics `axiosServices`
|
|
12
21
|
// Use provided apiKey as the Token. Must be requested on the app.
|
|
13
22
|
this.axios =
|
|
@@ -15,6 +24,7 @@ export class AxiosTransport {
|
|
|
15
24
|
axios.create({
|
|
16
25
|
baseURL: baseUrl.replace(/\/+$/, "/"),
|
|
17
26
|
timeout: 30_000,
|
|
27
|
+
...proxyConfig,
|
|
18
28
|
headers: {
|
|
19
29
|
Accept: "application/json",
|
|
20
30
|
"Content-Type": "application/json",
|
|
@@ -27,6 +37,13 @@ export class AxiosTransport {
|
|
|
27
37
|
res.data = objToCamelCase(res.data);
|
|
28
38
|
return res;
|
|
29
39
|
}, (err) => {
|
|
40
|
+
if (proxy && err.response?.status === 407) {
|
|
41
|
+
const e407 = new Error(`the HTTP proxy ${proxy.display} (from ${proxy.varName}) demanded authentication for the connection to ` +
|
|
42
|
+
`${new URL(baseUrl).host} (HTTP 407). Put valid credentials in the proxy URL ` +
|
|
43
|
+
`(http://user:password@${proxy.display}) or ask your network admin to allow it.`);
|
|
44
|
+
e407.statusCode = 407;
|
|
45
|
+
return Promise.reject(e407);
|
|
46
|
+
}
|
|
30
47
|
const data = err.response?.data;
|
|
31
48
|
if (data) {
|
|
32
49
|
const strField = (v) => typeof v === 'string' && v ? v : null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ProjectRequired text (platform-y2ob.2): says what was observed and
|
|
3
|
+
* what to pass. Used by check_app_in_browser and trigger_crawl.
|
|
4
|
+
*/
|
|
5
|
+
export function projectRequiredMessage(repoName) {
|
|
6
|
+
const observed = repoName
|
|
7
|
+
? `Could not determine the DebuggAI project: no project is linked to git repo "${repoName}".`
|
|
8
|
+
: 'Could not determine the DebuggAI project: this is not a git repo and no repoName was passed.';
|
|
9
|
+
return observed
|
|
10
|
+
+ ' Pass `projectUuid` (list yours with the `project` tool, action "list") or `repoName` (the "org/repo" of a linked project).';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A repo->project lookup that FAILED (platform-y2ob.4), as opposed to one that
|
|
14
|
+
* succeeded and found nothing. Only the latter may say "not linked": a 405 or a
|
|
15
|
+
* network error says nothing about whether a project exists. Reports the HTTP
|
|
16
|
+
* status and the backend's message verbatim.
|
|
17
|
+
*/
|
|
18
|
+
export function projectLookupFailedPayload(repoName, err) {
|
|
19
|
+
const e = err;
|
|
20
|
+
const statusCode = typeof e?.statusCode === 'number' ? e.statusCode : undefined;
|
|
21
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
22
|
+
const how = statusCode !== undefined
|
|
23
|
+
? `HTTP ${statusCode}: ${reason}`
|
|
24
|
+
: `${e?.networkCode ? `${e.networkCode}: ` : ''}${reason}`;
|
|
25
|
+
return {
|
|
26
|
+
error: 'ProjectLookupFailed',
|
|
27
|
+
message: `Could not look up the DebuggAI project for git repo "${repoName}" (${how}). `
|
|
28
|
+
+ 'This is a lookup failure, not a missing link: pass `projectUuid` to skip the lookup, or fix the connection and retry.',
|
|
29
|
+
detail: { repoName, ...(statusCode !== undefined ? { statusCode } : {}), ...(e?.networkCode ? { networkCode: e.networkCode } : {}) },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -179,5 +179,14 @@ export function sanitizeResponseUrls(value, ctx) {
|
|
|
179
179
|
if (!ctx.isLocalhost)
|
|
180
180
|
return value;
|
|
181
181
|
const origin = new URL(ctx.originalUrl).origin;
|
|
182
|
-
|
|
182
|
+
const ids = [];
|
|
183
|
+
if (ctx.tunnelId)
|
|
184
|
+
ids.push(ctx.tunnelId);
|
|
185
|
+
try {
|
|
186
|
+
const sessionTunnel = tunnelManager.getSessionTunnelInfo(getSessionKey());
|
|
187
|
+
if (sessionTunnel?.tunnelId)
|
|
188
|
+
ids.push(sessionTunnel.tunnelId);
|
|
189
|
+
}
|
|
190
|
+
catch { /* no session tunnel: ctx.tunnelId alone decides */ }
|
|
191
|
+
return replaceTunnelUrls(value, origin, ids);
|
|
183
192
|
}
|
package/dist/utils/urlParser.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* URL Parser Utilities
|
|
3
3
|
* Helper functions for parsing and validating URLs, specifically for detecting localhost URLs
|
|
4
4
|
*/
|
|
5
|
-
import { tunnelUrlPattern } from './tunnelDomains.js';
|
|
5
|
+
import { tunnelUrlPattern, extractTunnelIdFromHost } from './tunnelDomains.js';
|
|
6
6
|
/**
|
|
7
7
|
* Normalize a user-supplied URL string before validation.
|
|
8
8
|
* Handles bare hostnames without a scheme (e.g. "localhost:3000" → "http://localhost:3000").
|
|
@@ -95,18 +95,32 @@ export function isLocalhostUrl(urlString) {
|
|
|
95
95
|
* Covers EVERY known tunnel domain (utils/tunnelDomains.ts), including the
|
|
96
96
|
* retired `ngrok.debugg.ai` one a historical run's stored URLs still carry: a
|
|
97
97
|
* domain this does not know is a hostname that leaks to the caller.
|
|
98
|
+
*
|
|
99
|
+
* When `ownTunnelIds` is given, ONLY hosts whose tunnel id (leftmost label) is
|
|
100
|
+
* listed are rewritten; foreign tunnel-domain addresses are left untouched.
|
|
101
|
+
* Omitted = legacy rewrite-everything behavior.
|
|
98
102
|
*/
|
|
99
|
-
export function replaceTunnelUrls(value, localhostOrigin) {
|
|
103
|
+
export function replaceTunnelUrls(value, localhostOrigin, ownTunnelIds) {
|
|
100
104
|
if (typeof value === 'string') {
|
|
101
|
-
|
|
105
|
+
const target = localhostOrigin.replace(/\/$/, '');
|
|
106
|
+
if (ownTunnelIds === undefined)
|
|
107
|
+
return value.replace(tunnelUrlPattern(), target);
|
|
108
|
+
const own = new Set(ownTunnelIds.filter((id) => !!id).map(id => id.toLowerCase()));
|
|
109
|
+
// Only hosts of tunnels THIS session opened are mapped back; any other
|
|
110
|
+
// tunnel-domain address (a fixed runner/pub- tunnel, another user's) is
|
|
111
|
+
// a real public address and stays exactly as is.
|
|
112
|
+
return value.replace(tunnelUrlPattern(), (match) => {
|
|
113
|
+
const id = extractTunnelIdFromHost(match);
|
|
114
|
+
return id !== null && own.has(id.toLowerCase()) ? target : match;
|
|
115
|
+
});
|
|
102
116
|
}
|
|
103
117
|
if (Array.isArray(value)) {
|
|
104
|
-
return value.map(item => replaceTunnelUrls(item, localhostOrigin));
|
|
118
|
+
return value.map(item => replaceTunnelUrls(item, localhostOrigin, ownTunnelIds));
|
|
105
119
|
}
|
|
106
120
|
if (value !== null && typeof value === 'object') {
|
|
107
121
|
const result = {};
|
|
108
122
|
for (const [k, v] of Object.entries(value)) {
|
|
109
|
-
result[k] = replaceTunnelUrls(v, localhostOrigin);
|
|
123
|
+
result[k] = replaceTunnelUrls(v, localhostOrigin, ownTunnelIds);
|
|
110
124
|
}
|
|
111
125
|
return result;
|
|
112
126
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@debugg-ai/debugg-ai-mcp",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"caddy": "2.11.3",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"@modelcontextprotocol/sdk": "^1.27.0",
|
|
61
61
|
"@radically-straightforward/caddy": "^2.0.12",
|
|
62
62
|
"axios": "^1.9.0",
|
|
63
|
+
"https-proxy-agent": "^7.0.6",
|
|
63
64
|
"mkdirp": "^3.0.1",
|
|
64
65
|
"posthog-node": "^5.26.0",
|
|
65
66
|
"uuid": "^11.1.0",
|