@debugg-ai/debugg-ai-mcp 6.1.3 → 6.1.5

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/README.md CHANGED
@@ -23,6 +23,30 @@ clear error on the first localhost-URL call, not a silent hang. Public-URL calls
23
23
  non-browser tool, and `test_suite {action:"run"}` (which uses its own dedicated tunnel and
24
24
  bypasses Caddy entirely) don't need it either way.
25
25
 
26
+ ## Testing a local app (no Tailscale, no port exposure)
27
+
28
+ Start your app on localhost, then pass the `http://localhost:<port>` URL straight to the tool. The MCP
29
+ opens its own DebuggAI tunnel (one per session, dialed through a local Caddy proxy) — do not expose the
30
+ port with Tailscale, ngrok, or anything else, and do not rewrite the URL to a tunnel hostname yourself.
31
+
32
+ ```json
33
+ probe_page { "targets": [{ "url": "http://localhost:4040" }] }
34
+ check_app_in_browser { "url": "http://localhost:4040", "description": "Open the home page and confirm the hero renders" }
35
+ ```
36
+
37
+ Verified 2026-11-10 against a Next.js dev server on `localhost:4040`: `probe_page` returned
38
+ `statusCode 200`, title, 0 console errors, and every asset request resolved via the tunnel.
39
+ Prerequisites: `DEBUGGAI_API_KEY` set for the MCP, the `caddy` binary available (see above), and the
40
+ app actually listening (a dead port fails fast with `LocalServerUnreachable`). Several pages on the
41
+ same port can share one `probe_page` call; mixing ports in one call works but runs serially.
42
+
43
+ `TunnelTrafficBlocked` means the tunnel came up but the health probe through it got no valid response. First
44
+ check you are on the latest MCP (`npx -y @debugg-ai/debugg-ai-mcp@latest`; a stale pinned version is the usual
45
+ cause), then that your dev server answers a plain `curl http://127.0.0.1:<port>/` and does not reject unknown
46
+ `Host` headers (tunnel requests carry the tunnel hostname; a gateway that routes by `Host` needs a catch-all).
47
+ Verified 2026-11-10: servers bound to `127.0.0.1` only (IPv4 Caddy gateways on :4040 and :4043) probe fine via
48
+ `http://localhost:<port>` and `http://127.0.0.1:<port>`.
49
+
26
50
  Get an API key at [debugg.ai](https://debugg.ai), then add to your MCP client config:
27
51
 
28
52
  ```json
@@ -78,6 +102,7 @@ Runs an AI browser agent against your app. The agent navigates, interacts, and r
78
102
  | `loginCredentials` | array | Accounts for logins the agent hits **during** the task — `[{username, password, label?}]` |
79
103
  | `useEnvironmentCredentials` | boolean | Default `true`. `false` forbids auto-filling the environment's stored credentials; with no account named it means **do not log in at all** |
80
104
  | `freshSession` | boolean | Default `false`. `true` forces a real login instead of reusing the warm session held for that account |
105
+ | `extraOrigins` | string[] | Extra local origins the run may be redirected to, e.g. `["http://host.docker.internal:4717", "http://localhost:4304"]` (OAuth vendor + callback on other ports). Redirects to them (and back to `url`) are followed through the tunnel; pages on them are served at `/__o/<port>/`. Only listed origins are exposed. Absolute URLs written inside page HTML (not redirects) are not rewritten |
81
106
  | `auth` | object | Auth precondition — `{precondition, entryUrl, deepUrl, environmentId, username, password}` |
82
107
  | `repoName` | string | Override auto-detected git repo name (e.g. `my-org/my-repo`) |
83
108
 
@@ -10,7 +10,9 @@ import { handleExternalServiceError } from '../utils/errors.js';
10
10
  import { fetchImageAsBase64, imageContentBlock, resourceLinkBlock, artifactResourceLinks } from '../utils/imageUtils.js';
11
11
  import { DebuggAIServerClient } from '../services/index.js';
12
12
  import { getEvalTemplateSlug } from '../services/workflows.js';
13
- import { adaptVerdict, credentialSubstitutions } from '../services/verdictAdapter.js';
13
+ import { authorizeExtraOrigins, ExtraOriginError } from '../utils/extraOrigins.js';
14
+ import { refreshPendingArtifacts } from '../utils/artifactGrace.js';
15
+ import { adaptVerdict, credentialSubstitutions, summarizeLogins } from '../services/verdictAdapter.js';
14
16
  import { TunnelProvisionError } from '../services/tunnels.js';
15
17
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, retargetAuxiliaryUrl, } from '../utils/tunnelContext.js';
16
18
  import { randomUUID } from 'node:crypto';
@@ -318,6 +320,18 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
318
320
  }
319
321
  : undefined,
320
322
  });
323
+ // Bead w1os.2: extra local origins (OAuth vendor, callback) this run may
324
+ // be redirected to. Needs the route held; validated here so a bad entry
325
+ // fails fast with a plain message instead of mid-run.
326
+ try {
327
+ ctx = await authorizeExtraOrigins(ctx, input.extraOrigins);
328
+ }
329
+ catch (err) {
330
+ if (err instanceof ExtraOriginError) {
331
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'InvalidExtraOrigin', message: err.message }, null, 2) }], isError: true };
332
+ }
333
+ throw err;
334
+ }
321
335
  // Bead 1om: verify traffic actually flows through the tunnel. The
322
336
  // tunnel can be established (connect returns OK) yet refuse
323
337
  // to forward traffic — e.g., IPv4/IPv6 bind mismatch, or the dev
@@ -494,6 +508,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
494
508
  let executeResponse;
495
509
  let executionUuid = '';
496
510
  let finalExecution;
511
+ let execStartedAtMs;
497
512
  let attempt = 0;
498
513
  while (true) {
499
514
  attempt++;
@@ -516,6 +531,8 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
516
531
  }
517
532
  await new Promise(r => setTimeout(r, 1000 * (attempt - 1)));
518
533
  }
534
+ const execStartedAt = Date.now();
535
+ execStartedAtMs = execStartedAt;
519
536
  executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData, Object.keys(env).length > 0 ? env : undefined);
520
537
  executionUuid = executeResponse.executionUuid;
521
538
  // Bead 5er7: this is now the execution an abort must cancel (a retry moves
@@ -626,6 +643,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
626
643
  }
627
644
  const duration = Date.now() - startTime;
628
645
  // --- Format result ---
646
+ const runDoneAtMs = Date.now();
629
647
  const nodes = finalExecution.nodeExecutions ?? [];
630
648
  // subworkflow.run carries an inline base64 screenshot on some graph shapes;
631
649
  // only used for the image block below.
@@ -729,13 +747,26 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
729
747
  };
730
748
  if (verdict.budget)
731
749
  responsePayload.budget = verdict.budget;
750
+ // Bead w1os.1: say where the wall-clock went. setupMs is everything this MCP
751
+ // did before queuing the run (tunnel, route lock, health probe); runMs is
752
+ // queue-to-terminal on the backend (agent loop, incl. sign-in). Measured,
753
+ // not estimated.
754
+ if (execStartedAtMs !== undefined) {
755
+ responsePayload.timings = {
756
+ setupMs: execStartedAtMs - startTime,
757
+ runMs: runDoneAtMs - execStartedAtMs,
758
+ totalMs: Date.now() - startTime,
759
+ };
760
+ }
732
761
  // For "navigate and describe what you see", the answer IS the deliverable —
733
762
  // and it reached callers only incidentally, buried in actionTrace[0].intent,
734
763
  // which the verify-gate rewrites when it disagrees with the page. Surface it.
735
764
  if (verdict.report)
736
765
  responsePayload.report = verdict.report;
737
- if (verdict.logins)
766
+ if (verdict.logins) {
738
767
  responsePayload.logins = verdict.logins;
768
+ responsePayload.loginSummary = summarizeLogins(verdict.logins);
769
+ }
739
770
  if (verdict.loginError)
740
771
  responsePayload.loginError = verdict.loginError;
741
772
  // Bead b5x6: the caller named an account and the run SUBMITTED an
@@ -801,7 +832,9 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
801
832
  // browser_session block: presigned S3 URLs for HAR + console log + recording,
802
833
  // passed through verbatim.
803
834
  if (finalExecution.browserSession) {
804
- responsePayload.browserSession = finalExecution.browserSession;
835
+ // Bead w1os.3: artifacts are uploaded after the terminal state; re-read
836
+ // briefly while the backend says they are still queued.
837
+ responsePayload.browserSession = await refreshPendingArtifacts((id) => client.workflows.getExecution(id), executionUuid, finalExecution.browserSession) ?? finalExecution.browserSession;
805
838
  }
806
839
  logger.toolComplete('check_app_in_browser', duration);
807
840
  // NOTE (bead 0bq): the final "Complete:" progress is emitted INSIDE
@@ -222,6 +222,84 @@ export const CADDY_HANDLER_ID = 'dbg-handler';
222
222
  /** Placeholder upstream: dials a closed port so a stray request 502s cleanly
223
223
  * from the first millisecond instead of exposing a raw connection-refused. */
224
224
  const PLACEHOLDER_DIAL = '127.0.0.1:1';
225
+ /** Path prefix under which a secondary local origin is exposed on the tunnel. */
226
+ export const AUX_PATH_PREFIX = '/__o';
227
+ /** @id of the subroute holding the per-origin routes. */
228
+ export const CADDY_AUX_ID = 'dbg-aux';
229
+ const LOCAL_HOST_ALIASES = '(?:localhost|127\\.0\\.0\\.1|host\\.docker\\.internal|\\[::1\\])';
230
+ /**
231
+ * Location-header rewrite rules: a redirect to any local alias of a known port
232
+ * becomes a path on the tunnel origin, so the REMOTE browser (which cannot
233
+ * resolve `host.docker.internal` / dial its own `localhost:PORT`) follows it
234
+ * through the tunnel. The primary port maps to `/`, an aux port to `/__o/<port>/`.
235
+ */
236
+ export function buildLocationRewrites(primaryPort, aux) {
237
+ const rules = [];
238
+ for (const o of aux) {
239
+ rules.push({
240
+ search_regexp: `^https?://${LOCAL_HOST_ALIASES}:${o.port}(?:/(.*))?$`,
241
+ replace: AUX_PATH_PREFIX + '/' + o.port + '/${1}',
242
+ });
243
+ }
244
+ if (primaryPort !== undefined) {
245
+ rules.push({
246
+ search_regexp: `^https?://${LOCAL_HOST_ALIASES}:${primaryPort}(?:/(.*))?$`,
247
+ // Once any aux origin exists the primary is addressed by prefix too, so a
248
+ // redirect BACK to it does not inherit an aux page's Referer-scoped routing.
249
+ replace: AUX_PATH_PREFIX + '/' + primaryPort + '/${1}',
250
+ });
251
+ }
252
+ return rules;
253
+ }
254
+ function withLocationRewrites(handler, rules) {
255
+ if (rules.length === 0)
256
+ return handler;
257
+ return { ...handler, headers: { response: { replace: { Location: rules } } } };
258
+ }
259
+ /**
260
+ * The subroute that serves every authorised origin (aux origins plus the
261
+ * current primary), each addressable two ways:
262
+ * - `/__o/<port>/*` -> prefix stripped, proxied to that port;
263
+ * - any other path whose Referer is a `/__o/<port>/` page -> proxied to that
264
+ * port UNstripped, so root-relative links, form actions and assets on a
265
+ * secondary origin's page stay on that origin. The per-origin routes are
266
+ * fixed at config time, so a forged Referer can only select an origin the
267
+ * caller already authorised.
268
+ * A request matching neither falls through to the primary upstream.
269
+ */
270
+ export function buildAuxSubroute(aux, primary, inDocker) {
271
+ const rules = buildLocationRewrites(primary?.port, aux);
272
+ const all = [...aux];
273
+ if (primary && aux.length > 0 && !all.some((o) => o.port === primary.port)) {
274
+ all.push({ port: primary.port, isHttpsLocal: primary.isHttpsLocal });
275
+ }
276
+ const proxyFor = (o) => {
277
+ const proxy = {
278
+ handler: 'reverse_proxy',
279
+ upstreams: [{ dial: resolveDialAddress(o.port, !!o.isHttpsLocal, inDocker) }],
280
+ };
281
+ if (o.isHttpsLocal)
282
+ proxy.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
283
+ return withLocationRewrites(proxy, rules);
284
+ };
285
+ const routes = [];
286
+ for (const o of all) {
287
+ routes.push({
288
+ match: [{ path: [`${AUX_PATH_PREFIX}/${o.port}/*`] }],
289
+ handle: [{ handler: 'rewrite', strip_path_prefix: `${AUX_PATH_PREFIX}/${o.port}` }, proxyFor(o)],
290
+ });
291
+ }
292
+ for (const o of all) {
293
+ routes.push({
294
+ match: [{
295
+ not: [{ path: [`${AUX_PATH_PREFIX}/*`] }],
296
+ header_regexp: { Referer: { pattern: `^https?://[^/]+${AUX_PATH_PREFIX}/${o.port}/` } },
297
+ }],
298
+ handle: [proxyFor(o)],
299
+ });
300
+ }
301
+ return { '@id': CADDY_AUX_ID, handler: 'subroute', routes };
302
+ }
225
303
  /**
226
304
  * Builds the eager-at-spawn Caddy JSON config. One code path for every call
227
305
  * including the first (architecture doc §2.2): PATCH-only against a known
@@ -229,7 +307,7 @@ const PLACEHOLDER_DIAL = '127.0.0.1:1';
229
307
  * building new structure. Both listeners bind 127.0.0.1 only — the admin
230
308
  * API has no built-in auth, so this is a hard security requirement.
231
309
  */
232
- export function buildCaddyConfig(proxyPort, adminPort) {
310
+ export function buildCaddyConfig(proxyPort, adminPort, aux = []) {
233
311
  return {
234
312
  admin: { listen: `127.0.0.1:${adminPort}` },
235
313
  apps: {
@@ -238,6 +316,10 @@ export function buildCaddyConfig(proxyPort, adminPort) {
238
316
  srv0: {
239
317
  listen: [`127.0.0.1:${proxyPort}`],
240
318
  routes: [
319
+ // Aux origins first so `/__o/<port>/*` never falls through to the primary upstream.
320
+ {
321
+ handle: [buildAuxSubroute(aux, undefined, isDockerEnv())],
322
+ },
241
323
  {
242
324
  handle: [
243
325
  {
@@ -265,7 +347,7 @@ export function buildCaddyConfig(proxyPort, adminPort) {
265
347
  * address, so an omitted `@id` deletes the tag and the NEXT PATCH 404s
266
348
  * (only manifests on the second call — see LIFE-4 in the test suite).
267
349
  */
268
- export function buildPatchBody(target, inDocker) {
350
+ export function buildPatchBody(target, inDocker, aux = []) {
269
351
  const isHttpsLocal = !!target.isHttpsLocal;
270
352
  const dial = resolveDialAddress(target.port, isHttpsLocal, inDocker);
271
353
  const body = {
@@ -277,7 +359,8 @@ export function buildPatchBody(target, inDocker) {
277
359
  // Local self-signed certs — doesn't touch the public leg's real TLS.
278
360
  body.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
279
361
  }
280
- return body;
362
+ // Redirects to the primary origin or an authorised aux origin stay on the tunnel.
363
+ return aux.length > 0 ? withLocationRewrites(body, buildLocationRewrites(target.port, aux)) : body;
281
364
  }
282
365
  function adminHttpRequest(adminPort, method, reqPath, body, timeoutMs = 2000) {
283
366
  return new Promise((resolve, reject) => {
@@ -325,6 +408,8 @@ export class CaddyProxyManager {
325
408
  configPath = null;
326
409
  lastProxyPort = null;
327
410
  lastAppliedTarget = null;
411
+ /** Authorised secondary origins (bead w1os.2). Grow-only; survives a respawn. */
412
+ auxOrigins = [];
328
413
  portChangedListeners = [];
329
414
  constructor(opts = {}) {
330
415
  // Short random hex suffix — PID alone collides once one process hosts
@@ -359,7 +444,7 @@ export class CaddyProxyManager {
359
444
  this.lastAppliedTarget.isHttpsLocal === isHttpsLocal) {
360
445
  return; // idempotent no-op — this is what makes same-port calls free
361
446
  }
362
- const body = buildPatchBody(target, isDockerEnv());
447
+ const body = buildPatchBody(target, isDockerEnv(), this.auxOrigins);
363
448
  try {
364
449
  await this.patchHandler(adminPort, body);
365
450
  }
@@ -379,6 +464,33 @@ export class CaddyProxyManager {
379
464
  await this.patchHandler(freshAdminPort, body);
380
465
  }
381
466
  this.lastAppliedTarget = { port: target.port, isHttpsLocal };
467
+ if (this.auxOrigins.length > 0)
468
+ await this.patchAux(this.lastAppliedTarget);
469
+ }
470
+ /** Re-issue the aux subroute so it knows the current primary (bead w1os.2). */
471
+ async patchAux(primary, origins = this.auxOrigins) {
472
+ const adminPort = this.adminPort;
473
+ if (adminPort === null)
474
+ return;
475
+ const res = await adminHttpRequest(adminPort, 'PATCH', `/id/${CADDY_AUX_ID}`, JSON.stringify(buildAuxSubroute(origins, primary, isDockerEnv())));
476
+ if (res.status < 200 || res.status >= 300) {
477
+ throw new CaddyAdminApiError(`PATCH /id/${CADDY_AUX_ID} failed: ${res.status} ${res.body}`);
478
+ }
479
+ }
480
+ async addAuxOrigins(origins) {
481
+ const fresh = origins.filter((o) => !this.auxOrigins.some((a) => a.port === o.port && !!a.isHttpsLocal === !!o.isHttpsLocal));
482
+ if (fresh.length === 0)
483
+ return;
484
+ const { adminPort } = await this.ensureStarted();
485
+ const next = [...this.auxOrigins, ...fresh];
486
+ const primary = this.lastAppliedTarget;
487
+ // Replace the aux subroute, then re-issue the primary handler so its
488
+ // Location rewrites know the new origins. Both are idempotent PATCHes.
489
+ await this.patchAux(primary ?? undefined, next);
490
+ this.auxOrigins = next;
491
+ if (primary) {
492
+ await this.patchHandler(adminPort, buildPatchBody(primary, isDockerEnv(), next));
493
+ }
382
494
  }
383
495
  async isHealthy() {
384
496
  if (!this.started || !this.adminPort || this.isChildDead)
@@ -445,7 +557,7 @@ export class CaddyProxyManager {
445
557
  writeConfig(proxyPort, adminPort) {
446
558
  fs.mkdirSync(this.configDir, { recursive: true });
447
559
  const configPath = path.join(this.configDir, configFileName(process.pid, this.instanceId));
448
- const config = buildCaddyConfig(proxyPort, adminPort);
560
+ const config = buildCaddyConfig(proxyPort, adminPort, this.auxOrigins);
449
561
  fs.writeFileSync(configPath, JSON.stringify(config));
450
562
  return configPath;
451
563
  }
@@ -120,6 +120,23 @@ export function pollTimeoutReason(facts) {
120
120
  const head = parts.join(' ');
121
121
  return facts.lastStatus ? `${head}; last status: ${facts.lastStatus}.` : `${head}.`;
122
122
  }
123
+ /**
124
+ * Counts over the login records (bead w1os.4) — numbers only, so the MCP
125
+ * authors no text (epic 56kd). The backend appends a `no_login_form_detected`
126
+ * record (submitted:false, authenticated:false) after a successful sign-in
127
+ * whenever a later page had no form; read alone it looks like a failed login.
128
+ * The raw records stay verbatim (bead ymq2); this just states the tally:
129
+ * authenticated — records with authenticated:true
130
+ * failed — typed credentials (submitted:true) and did not authenticate
131
+ * notAttempted — nothing was typed (skipped or refused records)
132
+ */
133
+ export function summarizeLogins(logins) {
134
+ if (!logins)
135
+ return undefined;
136
+ const authenticated = logins.filter((l) => l.authenticated === true).length;
137
+ const failed = logins.filter((l) => l.authenticated !== true && l.submitted === true).length;
138
+ return { total: logins.length, authenticated, failed, notAttempted: logins.length - authenticated - failed };
139
+ }
123
140
  /** Credential sources that mean "the caller named this account for this run". */
124
141
  const CALLER_SPECIFIED_SOURCES = new Set(['task', 'explicit', 'credential_id']);
125
142
  /**
@@ -106,6 +106,12 @@ export function buildTestPageChangesTool(ctx) {
106
106
  type: "boolean",
107
107
  description: "Default false. Set true to force a REAL login instead of reusing the warm session the backend keeps per account. Use when the login flow itself is what you're checking, when you suspect the stored session is stale, or when the app's only route between personas is a logout. Costs one login; the run re-captures afterwards, so later runs stay fast."
108
108
  },
109
+ extraOrigins: {
110
+ type: "array",
111
+ items: { type: "string" },
112
+ description: "Extra LOCAL origins this run may be redirected to, e.g. [\"http://host.docker.internal:4717\", \"http://localhost:4304\"] (a bare port like \"4304\" also works). Use for flows that hop between local servers — an OAuth consent page on another port, a callback on a third — which the remote browser otherwise cannot reach (DNS_PROBE_FINISHED_NXDOMAIN / connection refused). Redirects to these origins, and to the main url's origin, are followed through the tunnel; only origins listed here are exposed. Hosts allowed: localhost, 127.0.0.1, [::1], host.docker.internal. Limitation: absolute URLs written inside page HTML (not redirects) are not rewritten.",
113
+ maxItems: 8
114
+ },
109
115
  repoName: {
110
116
  type: "string",
111
117
  description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this if you want to run against a different project than the one you're in."
@@ -64,6 +64,11 @@ export const TestPageChangesInputSchema = z.object({
64
64
  // skip login; that is right for speed and wrong when the login itself is what
65
65
  // you are checking, or when the app's only route between personas is a logout.
66
66
  freshSession: z.boolean().optional(),
67
+ // Extra LOCAL origins the run may be redirected to (bead w1os.2) — e.g. an OAuth
68
+ // vendor on :4717 (also host.docker.internal:4717) or a callback on :4304. Each is
69
+ // tunnelled and redirects to it are followed through the tunnel. Only origins named
70
+ // here are exposed.
71
+ extraOrigins: z.array(z.string().min(1)).max(8, 'extraOrigins accepts at most 8 origins.').optional(),
67
72
  // Auth-precondition deep-link intent (bead 56kd.6) — "log in THEN go to X".
68
73
  auth: AuthPreconditionSchema.optional(),
69
74
  });
@@ -0,0 +1,29 @@
1
+ const PENDING = /queue|pending|processing|uploading|downloading/i;
2
+ export function artifactsPending(bs) {
3
+ if (!bs)
4
+ return false;
5
+ const statuses = [bs.recordingStatus, bs.harStatus, bs.consoleLogStatus];
6
+ return statuses.some((s) => typeof s === 'string' && PENDING.test(s));
7
+ }
8
+ function artifactsReady(bs) {
9
+ return !artifactsPending(bs) || (!!bs.harUrl && !!bs.consoleLogUrl);
10
+ }
11
+ export async function refreshPendingArtifacts(getExecution, executionUuid, current, opts = {}) {
12
+ const attempts = opts.attempts ?? 3;
13
+ const intervalMs = opts.intervalMs ?? 2000;
14
+ let latest = current;
15
+ for (let i = 0; i < attempts && artifactsPending(latest) && !(latest && artifactsReady(latest)); i++) {
16
+ if (opts.signal?.aborted)
17
+ break;
18
+ await new Promise((r) => setTimeout(r, intervalMs));
19
+ try {
20
+ const exec = await getExecution(executionUuid);
21
+ if (exec.browserSession)
22
+ latest = exec.browserSession;
23
+ }
24
+ catch {
25
+ break; // artifact refresh is best-effort; never fail the run for it
26
+ }
27
+ }
28
+ return latest;
29
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Extra local origins a run may be redirected to (bead w1os.2). Kept out of
3
+ * tunnelContext.ts so handler tests that mock that module are unaffected.
4
+ */
5
+ import { tunnelManager } from '../services/tunnel/tunnelManager.js';
6
+ import { extractLocalhostPort } from './urlParser.js';
7
+ const EXTRA_ORIGIN_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1', 'host.docker.internal']);
8
+ export class ExtraOriginError extends Error {
9
+ origin;
10
+ constructor(origin, reason) {
11
+ super(`extraOrigins entry "${origin}" ${reason}`);
12
+ this.origin = origin;
13
+ this.name = 'ExtraOriginError';
14
+ }
15
+ }
16
+ /** Parse `4304`, `localhost:4304`, or `http(s)://host.docker.internal:4717[/…]`. */
17
+ export function parseExtraOrigin(raw) {
18
+ const t = raw.trim();
19
+ if (/^\d+$/.test(t)) {
20
+ const port = Number(t);
21
+ if (port < 1 || port > 65535)
22
+ throw new ExtraOriginError(raw, 'is not a valid port');
23
+ return { port };
24
+ }
25
+ let u;
26
+ try {
27
+ u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(t) ? t : `http://${t}`);
28
+ }
29
+ catch {
30
+ throw new ExtraOriginError(raw, 'is not a URL, host:port, or port');
31
+ }
32
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
33
+ throw new ExtraOriginError(raw, 'must be http or https');
34
+ if (!EXTRA_ORIGIN_HOSTS.has(u.hostname.toLowerCase())) {
35
+ throw new ExtraOriginError(raw, 'must be localhost, 127.0.0.1, [::1] or host.docker.internal');
36
+ }
37
+ const isHttpsLocal = u.protocol === 'https:';
38
+ const port = u.port ? Number(u.port) : isHttpsLocal ? 443 : 80;
39
+ return { port, ...(isHttpsLocal ? { isHttpsLocal: true } : {}) };
40
+ }
41
+ /**
42
+ * Authorise the caller's extra origins on this session's Caddy and record them
43
+ * on the context. Call after `acquirePortRoute`. No-op without a tunnel.
44
+ */
45
+ export async function authorizeExtraOrigins(ctx, raw) {
46
+ if (!raw || raw.length === 0 || !ctx.isLocalhost || !ctx.tunnelId)
47
+ return ctx;
48
+ const primaryPort = extractLocalhostPort(ctx.originalUrl);
49
+ const origins = raw.map(parseExtraOrigin).filter((o) => o.port !== primaryPort);
50
+ if (origins.length === 0)
51
+ return ctx;
52
+ const info = tunnelManager.getTunnelInfo(ctx.tunnelId);
53
+ if (!info)
54
+ throw new Error(`authorizeExtraOrigins: no TunnelInfo for tunnel ${ctx.tunnelId}`);
55
+ await info.caddy.addAuxOrigins(origins);
56
+ return { ...ctx, extraOrigins: origins };
57
+ }
@@ -101,10 +101,13 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
101
101
  const timeoutMs = opts.timeoutMs ?? 5000;
102
102
  const started = Date.now();
103
103
  let last;
104
+ let allClosed = true;
104
105
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
105
106
  const { result, retryable } = await probeOverControlChannel(tunnelUrl, timeoutMs, started);
106
107
  if (!retryable)
107
108
  return result;
109
+ if (!result.detail?.endsWith(' CLOSED'))
110
+ allClosed = false;
108
111
  last = result;
109
112
  if (attempt < maxAttempts) {
110
113
  await sleep(backoff[attempt - 1] ?? backoff[backoff.length - 1] ?? 150);
@@ -112,6 +115,14 @@ export async function probeTunnelHealth(tunnelUrl, opts = {}) {
112
115
  }
113
116
  // Ladder exhausted on a transient error — the tunnel never came up. Report
114
117
  // it: "not ready" that never becomes ready IS unhealthy.
118
+ //
119
+ // A control channel CLOSED on every attempt means the tunnel has no live
120
+ // session at all (a reconnect that never lands, or the transport forgot the
121
+ // tunnel while the manager still holds it). It can never serve a request, so
122
+ // flag it for eviction — otherwise every later call reuses the corpse and
123
+ // fails identically until the MCP process restarts.
124
+ if (allClosed && last)
125
+ last.tunnelErrorCode = 'CONTROL_CHANNEL_CLOSED';
115
126
  return last;
116
127
  }
117
128
  // ─ Control-channel probe ─────────────────────────────────────────────────────
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { tunnelManager, getSessionKey } from '../services/tunnel/tunnelManager.js';
11
11
  import { isLocalhostUrl, replaceTunnelUrls, retargetTunnelUrl, extractLocalhostPort } from './urlParser.js';
12
+ import { AUX_PATH_PREFIX } from '../services/caddy/caddyProxy.js';
12
13
  // ─── URL resolution ──────────────────────────────────────────────────────────
13
14
  /**
14
15
  * Resolve tool input to a concrete URL string.
@@ -110,6 +111,12 @@ export function retargetAuxiliaryUrl(ctx, auxUrl) {
110
111
  const port = extractLocalhostPort(auxUrl);
111
112
  const primaryPort = extractLocalhostPort(ctx.originalUrl);
112
113
  if (port !== undefined && primaryPort !== undefined && port !== primaryPort) {
114
+ // Bead w1os.2: a port the caller authorised via extraOrigins is reachable at /__o/<port>/.
115
+ if (ctx.extraOrigins?.some((o) => o.port === port)) {
116
+ const t = new URL(auxUrl);
117
+ const base = new URL(ctx.targetUrl);
118
+ return { ok: true, url: `${base.origin}${AUX_PATH_PREFIX}/${port}${t.pathname}${t.search}${t.hash}`, rewritten: true };
119
+ }
113
120
  return { ok: false, reason: 'port_mismatch', port, primaryPort };
114
121
  }
115
122
  return { ok: true, url: retargetTunnelUrl(ctx.targetUrl, auxUrl), rewritten: true };
@@ -69,6 +69,11 @@ const logger = new Logger({ module: 'tunnelDisposition' });
69
69
  const ENDPOINT_GONE_CODES = Object.freeze([
70
70
  'DEBUGG_TUNNEL_OFFLINE',
71
71
  'DEBUGG_TUNNEL_UNKNOWN',
72
+ // Client-side verdict, not a server marker: probeTunnelHealth saw the tunnel's
73
+ // own control channel CLOSED on every attempt of its ladder, i.e. there is no
74
+ // session left to carry traffic. Keeping it makes every later call fail the
75
+ // same way (the crochets build lead's "stuck at CLOSED" report).
76
+ 'CONTROL_CHANNEL_CLOSED',
72
77
  ]);
73
78
  const ENDPOINT_GONE_LOOKUP = new Set(ENDPOINT_GONE_CODES);
74
79
  /** The allowlist, as an immutable list. Read-only by construction. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "6.1.3",
3
+ "version": "6.1.5",
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",