@debugg-ai/debugg-ai-mcp 3.8.0 → 3.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -51,10 +51,38 @@ Runs an AI browser agent against your app. The agent navigates, interacts, and r
|
|
|
51
51
|
| `credentialRole` | string | Pick a credential by role (e.g. `admin`, `guest`) |
|
|
52
52
|
| `username` | string | Username for login (ephemeral — not persisted) |
|
|
53
53
|
| `password` | string | Password for login (ephemeral — not persisted) |
|
|
54
|
+
| `loginCredentials` | array | Accounts for logins the agent hits **during** the task — `[{username, password, label?}]` |
|
|
55
|
+
| `useEnvironmentCredentials` | boolean | Default `true`. `false` forbids auto-filling the environment's stored credentials |
|
|
56
|
+
| `auth` | object | Auth precondition — `{precondition, entryUrl, deepUrl, environmentId, username, password}` |
|
|
54
57
|
| `repoName` | string | Override auto-detected git repo name (e.g. `my-org/my-repo`) |
|
|
55
58
|
|
|
56
59
|
One focused check per call. The agent has a ~25-step internal budget; split broader suites across multiple calls.
|
|
57
60
|
|
|
61
|
+
##### Credentials: pass them as parameters, not prose
|
|
62
|
+
|
|
63
|
+
Naming an account only in `description` does **not** make the agent use it — it falls back to the environment's stored credential, and the app's rejection of the wrong account comes back looking like an application failure. Anything you pass as a parameter beats the environment default for **every** login in the run, not just the first:
|
|
64
|
+
|
|
65
|
+
- `username` / `password` (or `credentialId` / `credentialRole`) — the run's identity.
|
|
66
|
+
- `auth.username` / `auth.password` — pins the precondition login when you also use `auth.precondition: "login"`.
|
|
67
|
+
- `loginCredentials` — accounts for a login form the agent reaches **part-way through** the task. This is the one for flows like *set a password → get bounced to sign-in → log in as the account you just created*, where splitting into separate calls would lose browser state.
|
|
68
|
+
|
|
69
|
+
Set `useEnvironmentCredentials: false` when a silent fallback to the default test user would invalidate the check. The call is rejected if you opt out without naming an account, since the run would have no way to authenticate.
|
|
70
|
+
|
|
71
|
+
Results report the identity actually used, so a wrong one is visible rather than masquerading as a broken app:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
"logins": [
|
|
75
|
+
{ "username": "qa+invitefix@example.com", "source": "task", "submitted": true, "authenticated": true }
|
|
76
|
+
],
|
|
77
|
+
"credentialWarning": {
|
|
78
|
+
"requested": "qa+invitefix@example.com",
|
|
79
|
+
"used": ["qatest123@example.com"],
|
|
80
|
+
"message": "This run signed in with an environment default credential even though '…' was specified. …"
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`source` is `task` | `explicit` | `credential_id` (an account you named) or `env` | `env_default` (the environment's stored account). `credentialWarning` appears only when you named an account and an environment default was used anyway. `loginError` appears when a named account could not be resolved and the run declined to substitute a different one.
|
|
85
|
+
|
|
58
86
|
Every successful run returns a `browserSession` block alongside the screenshot — presigned S3 URLs for the captured **HAR** (full network trace) and **console log** (every JS console message). Use them to detect refetch loops, hydration errors, and other runtime issues that pass type-checks and unit tests:
|
|
59
87
|
|
|
60
88
|
```json
|
|
@@ -9,7 +9,7 @@ import { handleExternalServiceError } from '../utils/errors.js';
|
|
|
9
9
|
import { fetchImageAsBase64, imageContentBlock, resourceLinkBlock, artifactResourceLinks } from '../utils/imageUtils.js';
|
|
10
10
|
import { DebuggAIServerClient } from '../services/index.js';
|
|
11
11
|
import { getEvalTemplateSlug } from '../services/workflows.js';
|
|
12
|
-
import { adaptVerdict } from '../services/verdictAdapter.js';
|
|
12
|
+
import { adaptVerdict, isEnvironmentDefault } from '../services/verdictAdapter.js';
|
|
13
13
|
import { TunnelProvisionError } from '../services/tunnels.js';
|
|
14
14
|
import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js';
|
|
15
15
|
import { detectRepoName } from '../utils/gitContext.js';
|
|
@@ -55,6 +55,27 @@ function findNgrokErrorMarker(parts) {
|
|
|
55
55
|
}
|
|
56
56
|
return undefined;
|
|
57
57
|
}
|
|
58
|
+
// Credentials ride in `env` and `contextData.auth` on the way out. Log which
|
|
59
|
+
// accounts a run was given — that is the diagnostic that matters when a run
|
|
60
|
+
// signs in as the wrong user — but never their passwords.
|
|
61
|
+
function redactEnv(env) {
|
|
62
|
+
const out = { ...env };
|
|
63
|
+
if (out.password)
|
|
64
|
+
out.password = '[REDACTED]';
|
|
65
|
+
if (Array.isArray(out.taskCredentials)) {
|
|
66
|
+
out.taskCredentials = out.taskCredentials.map((c) => ({
|
|
67
|
+
username: c.username,
|
|
68
|
+
...(c.label ? { label: c.label } : {}),
|
|
69
|
+
password: '[REDACTED]',
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function redactAuth(auth) {
|
|
75
|
+
if (!auth)
|
|
76
|
+
return undefined;
|
|
77
|
+
return auth.password ? { ...auth, password: '[REDACTED]' } : auth;
|
|
78
|
+
}
|
|
58
79
|
// Concurrency control — max 2 simultaneous browser checks.
|
|
59
80
|
// Additional requests queue and run when a slot opens.
|
|
60
81
|
const MAX_CONCURRENT = 2;
|
|
@@ -344,6 +365,13 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
344
365
|
auth.entryUrl = input.auth.entryUrl;
|
|
345
366
|
if (input.auth.deepUrl)
|
|
346
367
|
auth.deepUrl = input.auth.deepUrl;
|
|
368
|
+
// WHICH account the precondition logs in as. Absent → the environment's
|
|
369
|
+
// default credential (the pre-existing behaviour, correct for a caller
|
|
370
|
+
// that named nobody).
|
|
371
|
+
if (input.auth.username)
|
|
372
|
+
auth.username = input.auth.username;
|
|
373
|
+
if (input.auth.password)
|
|
374
|
+
auth.password = input.auth.password;
|
|
347
375
|
if (Object.keys(auth).length > 0)
|
|
348
376
|
contextData.auth = auth;
|
|
349
377
|
}
|
|
@@ -359,8 +387,29 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
359
387
|
env.username = input.username;
|
|
360
388
|
if (input.password)
|
|
361
389
|
env.password = input.password;
|
|
390
|
+
// Accounts for logins the agent hits mid-task. The backend keys these by
|
|
391
|
+
// username so the agent can sign in as the one the task names, instead of
|
|
392
|
+
// its only login affordance filling the environment's stored account.
|
|
393
|
+
if (input.loginCredentials && input.loginCredentials.length > 0) {
|
|
394
|
+
env.taskCredentials = input.loginCredentials.map(c => ({
|
|
395
|
+
username: c.username,
|
|
396
|
+
password: c.password,
|
|
397
|
+
...(c.label ? { label: c.label } : {}),
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
// Only send the opt-out when it's actually an opt-out — omitting it keeps
|
|
401
|
+
// the default (environment credentials remain available as a fallback).
|
|
402
|
+
if (input.useEnvironmentCredentials === false) {
|
|
403
|
+
env.useEnvironmentCredentials = false;
|
|
404
|
+
}
|
|
362
405
|
// --- Execute ---
|
|
363
|
-
|
|
406
|
+
// Log the SHAPE of env, never its secrets. It now carries per-account
|
|
407
|
+
// passwords (taskCredentials), and this log line is not run through the
|
|
408
|
+
// logger's shallow top-level redaction.
|
|
409
|
+
logger.info('Sending contextData', {
|
|
410
|
+
contextData: { ...contextData, auth: redactAuth(contextData.auth) },
|
|
411
|
+
env: Object.keys(env).length > 0 ? redactEnv(env) : undefined,
|
|
412
|
+
});
|
|
364
413
|
if (progressCallback) {
|
|
365
414
|
await progressCallback({ progress: 3, total: TOTAL_STEPS, message: 'Queuing workflow execution...' });
|
|
366
415
|
}
|
|
@@ -542,10 +591,24 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
542
591
|
});
|
|
543
592
|
});
|
|
544
593
|
}
|
|
545
|
-
// Evaluation:
|
|
594
|
+
// Evaluation: RELAY the backend's block; derive one only for older backends.
|
|
595
|
+
//
|
|
596
|
+
// The backend computes `evaluation` from the SAME verdict as the headline
|
|
597
|
+
// outcome (sentinal-sk5sl.1) precisely so one payload cannot say
|
|
598
|
+
// outcome='inconclusive' at the top and evaluation.outcome='unknown'
|
|
599
|
+
// underneath. Rebuilding it here from raw subworkflow node output
|
|
600
|
+
// reintroduced that contradiction client-side, and worse: the node's
|
|
601
|
+
// `success` is a bare boolean, so "could not determine" arrived as
|
|
602
|
+
// passed:false. The backend deliberately sends passed:null for
|
|
603
|
+
// inconclusive — an undetermined run is not a failed one.
|
|
604
|
+
//
|
|
605
|
+
// Deriving from nodes stays ONLY as the pre-contract fallback.
|
|
546
606
|
const evalNode = nodes.find(n => n.nodeType === 'brain.evaluate');
|
|
547
607
|
let evaluation;
|
|
548
|
-
if (
|
|
608
|
+
if (finalExecution.evaluation && typeof finalExecution.evaluation === 'object') {
|
|
609
|
+
evaluation = finalExecution.evaluation;
|
|
610
|
+
}
|
|
611
|
+
else if (evalNode?.outputData) {
|
|
549
612
|
evaluation = {
|
|
550
613
|
passed: evalNode.outputData.passed,
|
|
551
614
|
outcome: evalNode.outputData.outcome,
|
|
@@ -656,6 +719,32 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
656
719
|
responsePayload.failureCategory = verdict.failureCategory;
|
|
657
720
|
if (verdict.reason)
|
|
658
721
|
responsePayload.reason = verdict.reason;
|
|
722
|
+
// --- Which identity did the run actually sign in as? ---
|
|
723
|
+
// A login as the wrong account and an app rejecting the right one produce
|
|
724
|
+
// the SAME symptom ("login failed") and used to be indistinguishable from
|
|
725
|
+
// the result, so an environment-default substitution read as an application
|
|
726
|
+
// bug. Relay the logins verbatim, and when the caller named an account but
|
|
727
|
+
// an environment default was used anyway, say so explicitly rather than
|
|
728
|
+
// leaving the caller to infer it from a failed check.
|
|
729
|
+
if (verdict.logins)
|
|
730
|
+
responsePayload.logins = verdict.logins;
|
|
731
|
+
if (verdict.loginError)
|
|
732
|
+
responsePayload.loginError = verdict.loginError;
|
|
733
|
+
const requestedIdentity = input.username
|
|
734
|
+
?? input.auth?.username
|
|
735
|
+
?? input.loginCredentials?.[0]?.username;
|
|
736
|
+
const substituted = (verdict.logins ?? []).filter(isEnvironmentDefault);
|
|
737
|
+
if (requestedIdentity && substituted.length > 0) {
|
|
738
|
+
responsePayload.credentialWarning = {
|
|
739
|
+
requested: requestedIdentity,
|
|
740
|
+
used: substituted.map(l => l.username).filter(Boolean),
|
|
741
|
+
message: `This run signed in with an environment default credential even though ` +
|
|
742
|
+
`'${requestedIdentity}' was specified. Treat a login failure here as a ` +
|
|
743
|
+
`credential-resolution problem, not an application failure.`,
|
|
744
|
+
};
|
|
745
|
+
logger.warn(`check_app_in_browser: requested identity '${requestedIdentity}' but the run used ` +
|
|
746
|
+
`environment-default credential(s): ${substituted.map(l => l.username).join(', ')}`);
|
|
747
|
+
}
|
|
659
748
|
// Bug z15n: OUR tunnel died mid-run, so this 'fail' describes our error page,
|
|
660
749
|
// not the user's app. Relay it as a distinct, retryable infrastructure class
|
|
661
750
|
// so an automated caller doesn't record a UI regression that never happened.
|
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
/** The verdict enum the backend emits (bead sentinal-k8x1f.2). */
|
|
26
26
|
export const KNOWN_OUTCOMES = ['pass', 'fail', 'inconclusive', 'error', 'timeout'];
|
|
27
27
|
const KNOWN = new Set(KNOWN_OUTCOMES);
|
|
28
|
+
/** Credential sources that mean "the caller named this account for this run". */
|
|
29
|
+
const CALLER_SPECIFIED_SOURCES = new Set(['task', 'explicit', 'credential_id']);
|
|
30
|
+
/** True when this login used an environment default rather than a named account. */
|
|
31
|
+
export function isEnvironmentDefault(login) {
|
|
32
|
+
return !!login.source && !CALLER_SPECIFIED_SOURCES.has(login.source);
|
|
33
|
+
}
|
|
28
34
|
/**
|
|
29
35
|
* Map a workflow execution onto the MCP relay verdict. Never throws.
|
|
30
36
|
*/
|
|
@@ -69,5 +75,10 @@ export function adaptVerdict(execution, opts = {}) {
|
|
|
69
75
|
relay.screenshot = evidence.screenshot;
|
|
70
76
|
if (Array.isArray(evidence?.actionTrace))
|
|
71
77
|
relay.actionTrace = evidence.actionTrace;
|
|
78
|
+
if (Array.isArray(evidence?.logins) && evidence.logins.length > 0)
|
|
79
|
+
relay.logins = evidence.logins;
|
|
80
|
+
if (evidence?.loginError && typeof evidence.loginError === 'object') {
|
|
81
|
+
relay.loginError = evidence.loginError;
|
|
82
|
+
}
|
|
72
83
|
return relay;
|
|
73
84
|
}
|
|
@@ -10,7 +10,9 @@ const BASE_DESCRIPTION = `Give an AI agent eyes on a live website or app. The ag
|
|
|
10
10
|
|
|
11
11
|
LOCALHOST SUPPORT: Pass any localhost URL (e.g. http://localhost:3000) and it Just Works. A secure tunnel is automatically created so the remote browser can reach your local dev server — no manual ngrok setup, no port forwarding, no config.
|
|
12
12
|
|
|
13
|
-
SCOPE PER CALL: Keep each call to ONE focused check — a single page or a short interaction on a single screen (login, submit a form, verify a heading). For anything spanning multiple pages or long multi-step flows, split into SEPARATE calls — the remote browser agent has a ~25-step internal budget per call, and long single calls risk client-side timeouts. Example: instead of "log in, then go to settings, then update profile, then verify," make three calls: (1) log in & verify dashboard, (2) update settings, (3) verify profile change
|
|
13
|
+
SCOPE PER CALL: Keep each call to ONE focused check — a single page or a short interaction on a single screen (login, submit a form, verify a heading). For anything spanning multiple pages or long multi-step flows, split into SEPARATE calls — the remote browser agent has a ~25-step internal budget per call, and long single calls risk client-side timeouts. Example: instead of "log in, then go to settings, then update profile, then verify," make three calls: (1) log in & verify dashboard, (2) update settings, (3) verify profile change.
|
|
14
|
+
|
|
15
|
+
CREDENTIALS: pass them as PARAMETERS, not only in the description. Naming an account in \`description\` alone does not make the agent use it — it falls back to the environment's stored credential. Use \`username\`/\`password\` (or \`credentialId\`) for the run's identity, \`auth.username\`/\`auth.password\` to pin the precondition login, and \`loginCredentials\` for accounts the agent must use at a login form it hits PART-WAY through the task (e.g. set a password → bounced to sign-in → log in as the account you just created). Anything you specify beats the environment's default for every login in the run; the result reports the identity actually used under \`logins\`.`;
|
|
14
16
|
/**
|
|
15
17
|
* Build the dynamic tool description including available environments/credentials.
|
|
16
18
|
*/
|
|
@@ -76,24 +78,50 @@ export function buildTestPageChangesTool(ctx) {
|
|
|
76
78
|
},
|
|
77
79
|
username: {
|
|
78
80
|
type: "string",
|
|
79
|
-
description: "A real, existing account email for the target app. Do NOT invent or guess credentials — use one from the available credentials listed above, or ask the user. The browser agent will type this into the login form."
|
|
81
|
+
description: "A real, existing account email for the target app. Do NOT invent or guess credentials — use one from the available credentials listed above, or ask the user. The browser agent will type this into the login form. Takes precedence over the environment's default credential for EVERY login in the run."
|
|
80
82
|
},
|
|
81
83
|
password: {
|
|
82
84
|
type: "string",
|
|
83
85
|
description: "The real password for the username above. Do NOT guess or use placeholder passwords — use credentials from the list above or ask the user."
|
|
84
86
|
},
|
|
87
|
+
loginCredentials: {
|
|
88
|
+
type: "array",
|
|
89
|
+
description: "Accounts the agent may sign in as when it hits a login form DURING the task — not just the first login. Use this for flows that authenticate part-way through, e.g. set a password, get bounced to sign-in, then log in as the account you just provisioned. Stating credentials only in `description` is not enough: pass them here and the agent uses exactly these values. Overrides the environment's default credential.",
|
|
90
|
+
items: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
username: { type: "string", description: "Account email/username to type into the login form." },
|
|
94
|
+
password: { type: "string", description: "That account's password." },
|
|
95
|
+
label: { type: "string", description: "Optional human label (e.g. 'newly invited user') to disambiguate in the task text." }
|
|
96
|
+
},
|
|
97
|
+
required: ["username", "password"],
|
|
98
|
+
additionalProperties: false
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
useEnvironmentCredentials: {
|
|
102
|
+
type: "boolean",
|
|
103
|
+
description: "Default true. Set false to forbid the agent from ever auto-filling the environment's stored credentials — it signs in only as an account this call named (username/password, credentialId, credentialRole, loginCredentials, or auth.username), or not at all. Use when a run must prove a SPECIFIC account's experience and a silent fallback to the default test user would invalidate it."
|
|
104
|
+
},
|
|
85
105
|
repoName: {
|
|
86
106
|
type: "string",
|
|
87
107
|
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."
|
|
88
108
|
},
|
|
89
109
|
auth: {
|
|
90
110
|
type: "object",
|
|
91
|
-
description: "Optional auth-precondition for a 'log in THEN deep-navigate' check. Set precondition:'login' to authenticate first
|
|
111
|
+
description: "Optional auth-precondition for a 'log in THEN deep-navigate' check. Set precondition:'login' to authenticate first, then land on deepUrl. Use this instead of hoping the agent signs itself in at a login wall. Pass username/password here to pin WHICH account it authenticates as; omit them to use the environment's default credential.",
|
|
92
112
|
properties: {
|
|
93
113
|
environmentId: {
|
|
94
114
|
type: "string",
|
|
95
115
|
description: "UUID of the environment whose credentials to log in with. See available environments in the tool description above."
|
|
96
116
|
},
|
|
117
|
+
username: {
|
|
118
|
+
type: "string",
|
|
119
|
+
description: "Account to authenticate as for the precondition login. Overrides the environment's default credential."
|
|
120
|
+
},
|
|
121
|
+
password: {
|
|
122
|
+
type: "string",
|
|
123
|
+
description: "Password for auth.username."
|
|
124
|
+
},
|
|
97
125
|
precondition: {
|
|
98
126
|
type: "string",
|
|
99
127
|
enum: ["login", "none"],
|
package/dist/types/index.js
CHANGED
|
@@ -18,6 +18,28 @@ export const AuthPreconditionSchema = z.object({
|
|
|
18
18
|
precondition: z.enum(['login', 'none']).optional(),
|
|
19
19
|
entryUrl: z.preprocess(normalizeUrl, z.string().url('entryUrl must be a valid URL (the login page to authenticate on).').optional()),
|
|
20
20
|
deepUrl: z.preprocess(normalizeUrl, z.string().url('deepUrl must be a valid URL (the page to evaluate AFTER login).').optional()),
|
|
21
|
+
// WHICH account to authenticate as. Without these the environment's default
|
|
22
|
+
// credential is used — which is correct for a caller that named nobody, and
|
|
23
|
+
// wrong for one that did. Naming the account here makes it authoritative for
|
|
24
|
+
// the precondition login.
|
|
25
|
+
username: z.string().optional(),
|
|
26
|
+
password: z.string().optional(),
|
|
27
|
+
}).strict();
|
|
28
|
+
/**
|
|
29
|
+
* A single account the browser agent may sign in as DURING the task.
|
|
30
|
+
*
|
|
31
|
+
* The auth precondition covers the FIRST login. This covers every login after
|
|
32
|
+
* it: a flow that sets a password, gets bounced to a sign-in page, and must
|
|
33
|
+
* authenticate as the account it just provisioned. Without an explicit channel
|
|
34
|
+
* for those, the agent's only login affordance filled the environment's stored
|
|
35
|
+
* account, so "then sign in as <the new user>" was unachievable however the
|
|
36
|
+
* task was worded — and the app's correct rejection of the wrong account came
|
|
37
|
+
* back as an application failure.
|
|
38
|
+
*/
|
|
39
|
+
export const LoginCredentialSchema = z.object({
|
|
40
|
+
username: z.string().min(1, 'username is required for a login credential'),
|
|
41
|
+
password: z.string().min(1, 'password is required for a login credential'),
|
|
42
|
+
label: z.string().optional(),
|
|
21
43
|
}).strict();
|
|
22
44
|
/**
|
|
23
45
|
* Tool input validation schemas
|
|
@@ -32,8 +54,20 @@ export const TestPageChangesInputSchema = z.object({
|
|
|
32
54
|
username: z.string().optional(),
|
|
33
55
|
password: z.string().optional(),
|
|
34
56
|
repoName: z.string().optional(),
|
|
57
|
+
// Accounts for logins the agent hits DURING the task, not just the first one.
|
|
58
|
+
loginCredentials: z.array(LoginCredentialSchema).max(10, 'loginCredentials accepts at most 10 accounts per run.').optional(),
|
|
59
|
+
// Opt out of the environment's stored credentials entirely: the agent signs
|
|
60
|
+
// in only as an account this call named, or not at all.
|
|
61
|
+
useEnvironmentCredentials: z.boolean().optional(),
|
|
35
62
|
// Auth-precondition deep-link intent (bead 56kd.6) — "log in THEN go to X".
|
|
36
63
|
auth: AuthPreconditionSchema.optional(),
|
|
64
|
+
}).refine((v) => !(v.useEnvironmentCredentials === false
|
|
65
|
+
&& !v.username && !v.credentialId && !v.credentialRole
|
|
66
|
+
&& !(v.loginCredentials && v.loginCredentials.length > 0)
|
|
67
|
+
&& !v.auth?.username), {
|
|
68
|
+
message: 'useEnvironmentCredentials:false leaves the run with no way to authenticate. '
|
|
69
|
+
+ 'Pass username/password, credentialId, credentialRole, or loginCredentials alongside it.',
|
|
70
|
+
path: ['useEnvironmentCredentials'],
|
|
37
71
|
});
|
|
38
72
|
export const TriggerCrawlInputSchema = z.object({
|
|
39
73
|
url: z.preprocess(normalizeUrl, z.string().url('Invalid URL. Pass a full URL like "http://localhost:3000" or "https://example.com". Localhost URLs are auto-tunneled to the remote browser.')),
|