@debugg-ai/debugg-ai-mcp 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +23 -7
- package/dist/handlers/createEnvironmentHandler.js +4 -4
- package/dist/handlers/createProjectHandler.js +1 -1
- package/dist/handlers/environmentSessionsHandler.js +2 -18
- package/dist/handlers/probePageHandler.js +8 -11
- package/dist/handlers/runTestSuiteHandler.js +4 -5
- package/dist/handlers/searchEnvironmentsHandler.js +3 -6
- package/dist/handlers/searchExecutionsHandler.js +3 -11
- package/dist/handlers/searchProjectsHandler.js +1 -6
- package/dist/handlers/testPageChangesHandler.js +67 -162
- package/dist/handlers/triggerCrawlHandler.js +49 -41
- package/dist/handlers/updateEnvironmentHandler.js +2 -2
- package/dist/services/index.js +18 -12
- package/dist/services/verdictAdapter.js +167 -41
- package/dist/services/workflows.js +10 -1
- package/dist/utils/authorizedCredentialHosts.js +8 -20
- package/dist/utils/confirmDestructive.js +1 -2
- package/dist/utils/errors.js +4 -5
- package/dist/utils/imageUtils.js +0 -1
- package/dist/utils/resolveProject.js +1 -1
- package/dist/utils/urlParser.js +33 -0
- package/package.json +1 -1
|
@@ -7,24 +7,119 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Backend contract (camelCase after axiosTransport conversion). The containers
|
|
9
9
|
* are TOP-LEVEL siblings of `state` on the execution-detail response:
|
|
10
|
-
* execution.verdict: { outcome: pass|fail|
|
|
10
|
+
* execution.verdict: { outcome: pass|fail|error|null, reason, skipped? }
|
|
11
|
+
* (outcome null + skipped: true = never attempted a test)
|
|
11
12
|
* execution.budget: { maxSteps, usedSteps }
|
|
12
13
|
* execution.evidence: { screenshot, actionTrace }
|
|
13
14
|
* `verdict` is SINGULAR — distinct from the pre-existing plural `verdicts`
|
|
14
15
|
* (RunVerdict array) and the raw `outcome` string, neither of which we read.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
17
|
+
* EVERY verdict a user sees is pass | fail | error (platform-98fv.16,
|
|
18
|
+
* 2026-10-04). `inconclusive`, `unverified`, `unknown` and `abandoned` are gone
|
|
19
|
+
* as user-facing values: they told a user nothing they could act on. They now
|
|
20
|
+
* become `error` carrying the reason "No verdict recorded", which at least says
|
|
21
|
+
* what happened.
|
|
22
|
+
*
|
|
23
|
+
* Principle: relay, never invent — but never relay a non-answer either.
|
|
24
|
+
* - pass/fail are relayed verbatim; everything else collapses to `error`.
|
|
25
|
+
* - nothing derived from the outcome is added beside it (no `success`, no
|
|
26
|
+
* `failureCategory`): a second copy of the verdict is a second thing to
|
|
27
|
+
* disagree with it.
|
|
28
|
+
* - the backend's own `reason` is shown whenever it sends one. We only
|
|
29
|
+
* supply a reason when it did not.
|
|
30
|
+
* - a missing / null / unrecognized verdict on a FINISHED run is `error`,
|
|
31
|
+
* never `fail`, never the raw execution status and never the raw run
|
|
32
|
+
* outcome (`state.outcome` is internal: it can be cancelled / timeout /
|
|
33
|
+
* skipped).
|
|
34
|
+
* - a run that NEVER ATTEMPTED A TEST has no verdict (platform-98fv.16): the
|
|
35
|
+
* backend sends `verdict: { outcome: null, reason, skipped: true }` and the
|
|
36
|
+
* relay is `outcome: null, skipped: true, skipReason: <reason verbatim>`.
|
|
37
|
+
* - a run still going has no verdict yet: `outcome: null`.
|
|
38
|
+
* - `budget` is the backend's container, relayed verbatim or not at all.
|
|
39
|
+
*
|
|
40
|
+
* `timeout` deserves its own note because it is OURS, not the backend's. The
|
|
41
|
+
* MCP synthesises it when its own 10-minute poll deadline expires
|
|
42
|
+
* (services/workflows.ts) — which is a different event from the backend saying
|
|
43
|
+
* a run timed out. Both are `error`, because neither is a verdict about the
|
|
44
|
+
* user's app, but the REASON keeps them apart: ours says we stopped waiting and
|
|
45
|
+
* the run may still be going, theirs says whatever the backend said. Merging
|
|
46
|
+
* them would erase the answer to the first question anyone asks.
|
|
47
|
+
*/
|
|
48
|
+
/** The ONLY outcomes a user ever sees. */
|
|
49
|
+
export const USER_FACING_OUTCOMES = ['pass', 'fail', 'error'];
|
|
50
|
+
/**
|
|
51
|
+
* A reason is a FACTUAL RECORD: what we did, what we saw, what happened. No
|
|
52
|
+
* hedges ("may", "likely"), no blame, no advice, and no fixed sentence per
|
|
53
|
+
* category — a canned string reads as an explanation while carrying no
|
|
54
|
+
* information, and the reader cannot tell one occurrence from another.
|
|
55
|
+
*
|
|
56
|
+
* So these are built from the actual values observed, and every number in them
|
|
57
|
+
* is real.
|
|
24
58
|
*/
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
59
|
+
/**
|
|
60
|
+
* THE mapping, exported because more than one field carries an outcome and they
|
|
61
|
+
* must never disagree: the headline `outcome` and the nested
|
|
62
|
+
* `evaluation.outcome` are read by the same person in the same payload. The
|
|
63
|
+
* backend keeps them consistent by deriving both from one verdict
|
|
64
|
+
* (sentinal-sk5sl.1); once the MCP re-maps one of them, it has to re-map the
|
|
65
|
+
* other through the same function or it reintroduces exactly the contradiction
|
|
66
|
+
* that design removed.
|
|
67
|
+
*/
|
|
68
|
+
export function toUserFacingOutcome(raw) {
|
|
69
|
+
// Deliberately an allowlist, not a denylist of known non-answers: a value a
|
|
70
|
+
// future backend invents must land on `error` too, rather than leaking
|
|
71
|
+
// through because nobody added it to a list.
|
|
72
|
+
const v = typeof raw === 'string' ? raw.trim() : '';
|
|
73
|
+
return v === 'pass' || v === 'fail' ? v : 'error';
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* An execution record as the executions tool relays it: the backend object
|
|
77
|
+
* verbatim, except the three fields a user reads as the run's verdict —
|
|
78
|
+
* `outcome`, `verdict.outcome`, `evaluation.outcome` — which go through the
|
|
79
|
+
* same allowlist as check_app_in_browser. A null/absent outcome (a run still
|
|
80
|
+
* going) stays null: "no verdict yet" is not an error. Internal run state
|
|
81
|
+
* (`state.*`, node output) keeps its raw values.
|
|
82
|
+
*/
|
|
83
|
+
export function withUserFacingOutcomes(execution) {
|
|
84
|
+
if (!execution || typeof execution !== 'object')
|
|
85
|
+
return execution;
|
|
86
|
+
const mapIfSet = (v) => (typeof v === 'string' && v.trim() !== '' ? toUserFacingOutcome(v) : v);
|
|
87
|
+
const e = { ...execution };
|
|
88
|
+
if ('outcome' in e)
|
|
89
|
+
e.outcome = mapIfSet(e.outcome);
|
|
90
|
+
for (const key of ['verdict', 'evaluation']) {
|
|
91
|
+
if (e[key] && typeof e[key] === 'object' && 'outcome' in e[key]) {
|
|
92
|
+
e[key] = { ...e[key], outcome: mapIfSet(e[key].outcome) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return e;
|
|
96
|
+
}
|
|
97
|
+
export function noVerdictReason(rawOutcome) {
|
|
98
|
+
const seen = typeof rawOutcome === 'string' && rawOutcome.trim() !== '' ? rawOutcome.trim() : null;
|
|
99
|
+
return seen
|
|
100
|
+
? `Backend returned outcome '${seen}' with no reason.`
|
|
101
|
+
: 'Backend returned no outcome and no reason.';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* OUR deadline expiring is not the backend reporting a timeout, and the two
|
|
105
|
+
* must stay distinguishable: one means we stopped listening, the other means
|
|
106
|
+
* the run stopped. This states only what we observed — no claim about whether
|
|
107
|
+
* the run is still going, because we do not know.
|
|
108
|
+
*/
|
|
109
|
+
export function pollTimeoutReason(facts) {
|
|
110
|
+
const parts = [];
|
|
111
|
+
parts.push(facts.executionUuid
|
|
112
|
+
? `No result from execution ${facts.executionUuid}`
|
|
113
|
+
: 'No result from the execution');
|
|
114
|
+
if (typeof facts.elapsedMs === 'number') {
|
|
115
|
+
parts.push(`after ${Math.round(facts.elapsedMs / 1000)}s of polling`);
|
|
116
|
+
}
|
|
117
|
+
if (typeof facts.pollCount === 'number') {
|
|
118
|
+
parts.push(`(${facts.pollCount} polls)`);
|
|
119
|
+
}
|
|
120
|
+
const head = parts.join(' ');
|
|
121
|
+
return facts.lastStatus ? `${head}; last status: ${facts.lastStatus}.` : `${head}.`;
|
|
122
|
+
}
|
|
28
123
|
/** Credential sources that mean "the caller named this account for this run". */
|
|
29
124
|
const CALLER_SPECIFIED_SOURCES = new Set(['task', 'explicit', 'credential_id']);
|
|
30
125
|
/**
|
|
@@ -59,42 +154,73 @@ export function credentialSubstitutions(logins, namedIdentities) {
|
|
|
59
154
|
* Map a workflow execution onto the MCP relay verdict. Never throws.
|
|
60
155
|
*/
|
|
61
156
|
export function adaptVerdict(execution, opts = {}) {
|
|
62
|
-
const state = execution?.state ?? null;
|
|
63
157
|
// Contract containers live at the TOP LEVEL of the execution response
|
|
64
158
|
// (siblings of `state`), NOT nested under state.
|
|
65
159
|
const verdict = execution?.verdict ?? null;
|
|
66
160
|
const budget = execution?.budget ?? null;
|
|
67
161
|
const evidence = execution?.evidence ?? null;
|
|
68
|
-
// --- Outcome
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
162
|
+
// --- Outcome: pass | fail | error, or null for no verdict ---
|
|
163
|
+
// Only the explicit verdict. NEVER the raw run outcome (state.outcome is the
|
|
164
|
+
// run's internal record — cancelled / timeout / skipped) and never
|
|
165
|
+
// execution.status.
|
|
166
|
+
const rawOutcome = opts.outcomeOverride ?? verdict?.outcome;
|
|
167
|
+
const raw = typeof rawOutcome === 'string' ? rawOutcome.trim() : '';
|
|
168
|
+
const isOurPollTimeout = opts.outcomeOverride === 'timeout';
|
|
169
|
+
const backendReason = typeof verdict?.reason === 'string' && verdict.reason.trim() !== '' ? verdict.reason : null;
|
|
170
|
+
if (!isOurPollTimeout && !raw) {
|
|
171
|
+
// A run that never attempted a test (platform-98fv.16): no verdict, and the
|
|
172
|
+
// backend's recorded reason, verbatim.
|
|
173
|
+
if (verdict?.skipped === true) {
|
|
174
|
+
const skipped = { outcome: null, skipped: true };
|
|
175
|
+
if (backendReason)
|
|
176
|
+
skipped.skipReason = backendReason;
|
|
177
|
+
if (budget && typeof budget === 'object')
|
|
178
|
+
skipped.budget = budget;
|
|
179
|
+
return attachEvidence(skipped, evidence);
|
|
180
|
+
}
|
|
181
|
+
// Still going: no verdict YET is not an error.
|
|
182
|
+
if (!TERMINAL_STATUSES.has(String(execution?.status ?? ''))) {
|
|
183
|
+
const pending = { outcome: null };
|
|
184
|
+
if (budget && typeof budget === 'object')
|
|
185
|
+
pending.budget = budget;
|
|
186
|
+
return attachEvidence(pending, evidence);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// error covers: the backend's own 'error'/'timeout', every retired non-answer
|
|
190
|
+
// (inconclusive/unverified/unknown/abandoned), anything a newer backend
|
|
191
|
+
// invents, and a verdict that never arrived.
|
|
192
|
+
const outcome = toUserFacingOutcome(raw);
|
|
193
|
+
const relay = { outcome };
|
|
194
|
+
if (budget && typeof budget === 'object')
|
|
195
|
+
relay.budget = budget;
|
|
196
|
+
// Reason precedence, and the order is the whole point:
|
|
197
|
+
// 1. the backend's own words, relayed VERBATIM and never rephrased;
|
|
198
|
+
// 2. our poll deadline, stated as what we observed — only when WE forced
|
|
199
|
+
// the timeout, so a backend-reported timeout keeps its own reason;
|
|
200
|
+
// 3. a factual record of what arrived, when nothing explained itself.
|
|
201
|
+
// OUR deadline comes first, deliberately. If we never saw a terminal verdict,
|
|
202
|
+
// any reason we are holding came from a mid-flight poll — it describes a run
|
|
203
|
+
// still in progress, not its result. Relaying it next to outcome='error'
|
|
204
|
+
// would pair someone else's "looks good" with our failure.
|
|
205
|
+
if (isOurPollTimeout) {
|
|
206
|
+
relay.reason = pollTimeoutReason({
|
|
207
|
+
executionUuid: execution?.uuid,
|
|
208
|
+
lastStatus: execution?.status,
|
|
209
|
+
...(opts.pollTimeout ?? {}),
|
|
210
|
+
});
|
|
72
211
|
}
|
|
73
|
-
else {
|
|
74
|
-
|
|
75
|
-
// field; NEVER fall back to execution.status. Unknown values → inconclusive.
|
|
76
|
-
const raw = verdict?.outcome ?? state?.outcome;
|
|
77
|
-
outcome = typeof raw === 'string' && KNOWN.has(raw) ? raw : 'inconclusive';
|
|
212
|
+
else if (backendReason) {
|
|
213
|
+
relay.reason = backendReason;
|
|
78
214
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
outcome,
|
|
89
|
-
success,
|
|
90
|
-
stepsBudget,
|
|
91
|
-
stepsTaken,
|
|
92
|
-
stepsRemaining,
|
|
93
|
-
};
|
|
94
|
-
if (!success)
|
|
95
|
-
relay.failureCategory = outcome;
|
|
96
|
-
if (typeof verdict?.reason === 'string' && verdict.reason)
|
|
97
|
-
relay.reason = verdict.reason;
|
|
215
|
+
else if (outcome === 'error') {
|
|
216
|
+
relay.reason = noVerdictReason(raw);
|
|
217
|
+
}
|
|
218
|
+
return attachEvidence(relay, evidence);
|
|
219
|
+
}
|
|
220
|
+
/** The run lifecycle states after which no verdict will arrive. */
|
|
221
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
222
|
+
/** The backend's evidence container onto the relay, verbatim. */
|
|
223
|
+
function attachEvidence(relay, evidence) {
|
|
98
224
|
if (typeof evidence?.screenshot === 'string' && evidence.screenshot)
|
|
99
225
|
relay.screenshot = evidence.screenshot;
|
|
100
226
|
if (Array.isArray(evidence?.actionTrace))
|
|
@@ -93,7 +93,10 @@ export const createWorkflowsService = (tx) => {
|
|
|
93
93
|
status: e.status,
|
|
94
94
|
mode: e.mode,
|
|
95
95
|
source: e.source,
|
|
96
|
+
// The run verdict (pass|fail|error); null in flight AND for a run that
|
|
97
|
+
// never attempted a test — then skipReason (backend `skip_reason`) is set.
|
|
96
98
|
outcome: e.outcome ?? null,
|
|
99
|
+
...(typeof e.skipReason === 'string' && e.skipReason ? { skipReason: e.skipReason } : {}),
|
|
97
100
|
startedAt: e.startedAt,
|
|
98
101
|
completedAt: e.completedAt,
|
|
99
102
|
durationMs: e.durationMs,
|
|
@@ -171,7 +174,13 @@ export const createWorkflowsService = (tx) => {
|
|
|
171
174
|
finalIntervalMs: intervalMs,
|
|
172
175
|
timedOut: true,
|
|
173
176
|
});
|
|
174
|
-
|
|
177
|
+
// Carry the facts the deadline path observed, so the reason the user
|
|
178
|
+
// sees contains real numbers rather than a canned sentence.
|
|
179
|
+
return {
|
|
180
|
+
...lastExecution,
|
|
181
|
+
timedOut: true,
|
|
182
|
+
pollTimeout: { elapsedMs: Date.now() - pollStart, pollCount },
|
|
183
|
+
};
|
|
175
184
|
}
|
|
176
185
|
throw new Error(`Execution ${executionUuid} timed out after ${EXECUTION_TIMEOUT_MS / 1000}s`);
|
|
177
186
|
}
|
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
* The MCP ships this field before every backend accepts it (sentinal-oj7dp.23).
|
|
5
5
|
* A backend that does not know the field ignores it and still answers 200, so a
|
|
6
6
|
* create/update "succeeds" whether or not the hosts were saved. The only proof
|
|
7
|
-
* is the response echoing them back — so when it doesn't,
|
|
8
|
-
*
|
|
7
|
+
* is the response echoing them back — so when it doesn't, the response carries
|
|
8
|
+
* the two facts side by side: what was sent, what came back. No prose: the
|
|
9
|
+
* comparison is the whole message.
|
|
9
10
|
*/
|
|
10
11
|
const norm = (hosts) => new Set(hosts.map((h) => h.trim().toLowerCase()));
|
|
11
12
|
/**
|
|
12
|
-
* Compare the requested hosts with the backend's echo. Returns
|
|
13
|
-
* they differ (as sets, case-insensitively — the backend normalizes), or
|
|
14
|
-
* undefined when the echo confirms them.
|
|
15
|
-
* already created the environment, so a retry of create would duplicate it.
|
|
13
|
+
* Compare the requested hosts with the backend's echo. Returns the mismatch
|
|
14
|
+
* when they differ (as sets, case-insensitively — the backend normalizes), or
|
|
15
|
+
* undefined when the echo confirms them.
|
|
16
16
|
*/
|
|
17
|
-
export function checkAuthorizedCredentialHostsEcho(requested, returned
|
|
17
|
+
export function checkAuthorizedCredentialHostsEcho(requested, returned) {
|
|
18
18
|
const echoed = Array.isArray(returned) ? returned : null;
|
|
19
19
|
if (echoed) {
|
|
20
20
|
const want = norm(requested);
|
|
@@ -22,17 +22,5 @@ export function checkAuthorizedCredentialHostsEcho(requested, returned, op) {
|
|
|
22
22
|
if (want.size === got.size && [...want].every((h) => got.has(h)))
|
|
23
23
|
return undefined;
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
const why = echoed
|
|
27
|
-
? `The backend did not persist authorizedCredentialHosts as requested: it holds [${echoed.join(', ')}], not [${requested.join(', ')}].`
|
|
28
|
-
: 'The backend did not persist authorizedCredentialHosts: it is not yet supported on this DebuggAI server ' +
|
|
29
|
-
'(the response did not echo the hosts back).';
|
|
30
|
-
const applied = op === 'create'
|
|
31
|
-
? 'The environment itself WAS created, with any credentials listed here — do not re-run create; ' +
|
|
32
|
-
'set the hosts later with environment {action:"update"}.'
|
|
33
|
-
: 'Everything else in this update was applied.';
|
|
34
|
-
const consequence = requested.length > 0
|
|
35
|
-
? ` Until the hosts are saved, check_app_in_browser will still refuse to enter credentials on ${hosts} (offscope_host).`
|
|
36
|
-
: '';
|
|
37
|
-
return { requested, returned: echoed, message: `${why} ${applied}${consequence}` };
|
|
25
|
+
return { requested, returned: echoed };
|
|
38
26
|
}
|
|
@@ -69,6 +69,5 @@ export async function ensureConfirmed(action, label, input, ctx) {
|
|
|
69
69
|
}
|
|
70
70
|
if (input.confirm === true)
|
|
71
71
|
return null;
|
|
72
|
-
return refusal('confirmation_required', `Refusing to ${verb.toLowerCase()} ${label} without
|
|
73
|
-
+ 'Pass confirm:true, or use an elicitation-capable client.');
|
|
72
|
+
return refusal('confirmation_required', `Refusing to ${verb.toLowerCase()} ${label} without confirm:true.`);
|
|
74
73
|
}
|
package/dist/utils/errors.js
CHANGED
|
@@ -18,11 +18,10 @@ export function toMCPError(error, context) {
|
|
|
18
18
|
stack: error.stack,
|
|
19
19
|
context
|
|
20
20
|
});
|
|
21
|
-
return new MCPError(MCPErrorCode.INTERNAL_ERROR, error.message,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
21
|
+
return new MCPError(MCPErrorCode.INTERNAL_ERROR, error.message,
|
|
22
|
+
// The stack is logged above; it is MCP internals, not something the
|
|
23
|
+
// caller's response should carry.
|
|
24
|
+
{ originalError: error.name, context });
|
|
26
25
|
}
|
|
27
26
|
const errorMessage = String(error);
|
|
28
27
|
logger.error('Converting unknown error to MCP error', { error: errorMessage, context });
|
package/dist/utils/imageUtils.js
CHANGED
|
@@ -19,7 +19,7 @@ export function resolveByName(name, candidates, kind) {
|
|
|
19
19
|
if (matches.length > 1) {
|
|
20
20
|
return {
|
|
21
21
|
error: 'AmbiguousMatch',
|
|
22
|
-
message: `Multiple ${kind.toLowerCase().replace('testsuite', 'test suite')}s match "${name}"
|
|
22
|
+
message: `Multiple ${kind.toLowerCase().replace('testsuite', 'test suite')}s match "${name}".`,
|
|
23
23
|
candidates: matches.map(m => ({ uuid: m.uuid, name: m.name })),
|
|
24
24
|
};
|
|
25
25
|
}
|
package/dist/utils/urlParser.js
CHANGED
|
@@ -134,6 +134,39 @@ export function retargetTunnelUrl(tunnelOrigin, requestedUrl) {
|
|
|
134
134
|
return tunnelOrigin;
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Rewrite every localhost / 127.0.0.1 URL inside FREE TEXT onto the tunnel.
|
|
139
|
+
*
|
|
140
|
+
* The goal text is read by a browser running in OUR cloud, not on the user's
|
|
141
|
+
* machine, so "go to http://localhost:3017" tells it to dial its own loopback.
|
|
142
|
+
* It gets connection refused and the run is recorded as the app failing, while
|
|
143
|
+
* the tunnel was working the whole time (15 of 431 judged prod runs in 30 days,
|
|
144
|
+
* platform-98fv.16).
|
|
145
|
+
*
|
|
146
|
+
* Deliberately narrow:
|
|
147
|
+
* - only a real origin match (scheme + host + optional port) is touched, so
|
|
148
|
+
* a word like "localhostname" survives;
|
|
149
|
+
* - path, query and fragment are preserved, because the goal usually depends
|
|
150
|
+
* on them;
|
|
151
|
+
* - with no tunnel URL, the text is returned UNCHANGED. Inventing a
|
|
152
|
+
* destination would be worse than leaving a wrong one visible.
|
|
153
|
+
*/
|
|
154
|
+
export function rewriteLocalhostInText(text, tunnelUrl) {
|
|
155
|
+
if (!text || !tunnelUrl)
|
|
156
|
+
return text;
|
|
157
|
+
let origin;
|
|
158
|
+
try {
|
|
159
|
+
const u = new URL(tunnelUrl);
|
|
160
|
+
origin = `${u.protocol}//${u.host}`;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
// scheme://(localhost|127.0.0.1)[:port] — the trailing boundary stops
|
|
166
|
+
// "localhostname" matching, while still allowing a path/query/fragment.
|
|
167
|
+
const LOCAL_ORIGIN = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(?=[/?#\s]|$)/gi;
|
|
168
|
+
return text.replace(LOCAL_ORIGIN, origin);
|
|
169
|
+
}
|
|
137
170
|
/**
|
|
138
171
|
* Generate a tunneled URL for a localhost URL
|
|
139
172
|
*/
|