@browserstack/mcp-server 1.5.1-beta.2 → 2.0.0-beta.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 +54 -1
- package/capability/tm.capability-index.json +50420 -0
- package/capability/tra.capability-index.json +52096 -0
- package/dist/lib/central-oauth.d.ts +133 -0
- package/dist/lib/central-oauth.js +246 -0
- package/dist/lib/instrumentation.d.ts +14 -1
- package/dist/lib/instrumentation.js +8 -1
- package/dist/server-factory.js +5 -0
- package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
- package/dist/tools/ask-browserstack/central-oauth.js +55 -177
- package/dist/tools/capability-registry/bind.d.ts +28 -0
- package/dist/tools/capability-registry/bind.js +407 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +101 -0
- package/dist/tools/capability-registry/egress.d.ts +51 -0
- package/dist/tools/capability-registry/egress.js +197 -0
- package/dist/tools/capability-registry/index-loader.d.ts +162 -0
- package/dist/tools/capability-registry/index-loader.js +523 -0
- package/dist/tools/capability-registry/oauth.d.ts +63 -0
- package/dist/tools/capability-registry/oauth.js +176 -0
- package/dist/tools/capability-registry/redact.d.ts +34 -0
- package/dist/tools/capability-registry/redact.js +98 -0
- package/dist/tools/capability-registry/register.d.ts +48 -0
- package/dist/tools/capability-registry/register.js +956 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +104 -0
- package/dist/tools/capability-registry/routing-token.d.ts +25 -0
- package/dist/tools/capability-registry/routing-token.js +87 -0
- package/dist/tools/capability-registry/search.d.ts +197 -0
- package/dist/tools/capability-registry/search.js +907 -0
- package/dist/tools/capability-registry/types.d.ts +341 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +15 -12
- package/dist/tools/tool-handoff.js +21 -18
- package/package.json +10 -3
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the index comes from, and where each product lives.
|
|
3
|
+
*
|
|
4
|
+
* A product may DECLARE its host in its own index file (`<product>.base_url`, authored in
|
|
5
|
+
* the harness's product.yaml and carried through by the export). That is the default, not
|
|
6
|
+
* the last word: config overrides it, and for a region-sharded product account discovery
|
|
7
|
+
* outranks it — see `resolveBaseUrl`. The same artifact therefore still ships to every
|
|
8
|
+
* environment, because anything environment- or account-specific is resolved here.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import logger from "../../logger.js";
|
|
14
|
+
import { getTMBaseURL } from "../../lib/tm-base-url.js";
|
|
15
|
+
import { discoverBaseUrl } from "./discovery.js";
|
|
16
|
+
import { fetchTransport } from "./egress.js";
|
|
17
|
+
import { FLAT_SUFFIX, INDEX_FILE, InvocationError } from "./index-loader.js";
|
|
18
|
+
/**
|
|
19
|
+
* The environment this DEPLOYMENT points at, e.g. "preprod".
|
|
20
|
+
*
|
|
21
|
+
* Process-level on purpose, and the distinction from region matters: an environment is a
|
|
22
|
+
* property of the deployment (this instance talks to preprod), whereas a REGION is a
|
|
23
|
+
* property of the account (this user's data lives in EU). That is why region discovery is
|
|
24
|
+
* per request and never cached under REMOTE_MCP, while the environment is read once here.
|
|
25
|
+
*/
|
|
26
|
+
export function selectedEnvironment() {
|
|
27
|
+
return (process.env.CAPABILITY_REGISTRY_ENV || "").trim();
|
|
28
|
+
}
|
|
29
|
+
/** product -> env -> host, the analogue of Atlas's `harness.extra_environments`. */
|
|
30
|
+
function environmentMap() {
|
|
31
|
+
const raw = process.env.CAPABILITY_REGISTRY_BASE_URLS;
|
|
32
|
+
if (!raw)
|
|
33
|
+
return {};
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// A malformed map must not silently mean "no override" — that would send a preprod
|
|
40
|
+
// deployment at production.
|
|
41
|
+
throw new InvocationError("CAPABILITY_REGISTRY_BASE_URLS is not valid JSON; expected {product: {env: url}}");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Built-in host discovery, for a product whose regional hosts are not in its index yet.
|
|
46
|
+
*
|
|
47
|
+
* The general mechanism lives in `discovery.ts` and is driven by the index's `base_urls`.
|
|
48
|
+
* This map is the bridge for products that predate that: tm's hosts are still hardcoded in
|
|
49
|
+
* `lib/tm-base-url.ts` and shared with the hand-written Test Management tools. Once tm's
|
|
50
|
+
* product.yaml declares `base_urls`, this entry can go and nothing else changes.
|
|
51
|
+
*/
|
|
52
|
+
const BUILTIN_DISCOVERY = {
|
|
53
|
+
tm: getTMBaseURL,
|
|
54
|
+
};
|
|
55
|
+
/** True when this product's host is account-specific and must be discovered per call. */
|
|
56
|
+
export function isRegionSharded(product, source) {
|
|
57
|
+
return (source?.base_urls?.length ?? 0) > 1 || product in BUILTIN_DISCOVERY;
|
|
58
|
+
}
|
|
59
|
+
function credentialsFrom(config) {
|
|
60
|
+
return {
|
|
61
|
+
username: config["browserstack-username"],
|
|
62
|
+
accessKey: config["browserstack-access-key"],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a product's host.
|
|
67
|
+
*
|
|
68
|
+
* Atlas resolves: an explicit per-session override, then the host for the session's
|
|
69
|
+
* environment (`harness.extra_environments[env][product]`), then the profile's own
|
|
70
|
+
* `base_url`. The same rungs, in the same order, with one addition:
|
|
71
|
+
*
|
|
72
|
+
* 1. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT> explicit, environment-agnostic
|
|
73
|
+
* 2. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV> this environment's host
|
|
74
|
+
* 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same, as one map
|
|
75
|
+
* 4. account discovery — the index's `base_urls`, probed
|
|
76
|
+
* 5. the single host declared in the index, `base_url`
|
|
77
|
+
* 6. refuse, by name
|
|
78
|
+
*
|
|
79
|
+
* DISCOVERY OUTRANKS THE DECLARED HOST, which is where this departs from Atlas. A declared
|
|
80
|
+
* host is one fixed origin, so for a region-sharded product it would send every account
|
|
81
|
+
* outside the default region to the wrong host — a failure that only shows up on those
|
|
82
|
+
* accounts, and so passes any test run from inside the default one. Discovery answers with
|
|
83
|
+
* the region the account is actually on; `base_url` is the fallback for when the probe
|
|
84
|
+
* cannot answer at all.
|
|
85
|
+
*
|
|
86
|
+
* Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404
|
|
87
|
+
* that reads like the caller's problem, when it is our missing configuration.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveBaseUrl(product, config, source, transport) {
|
|
90
|
+
const key = product.toUpperCase();
|
|
91
|
+
const environment = selectedEnvironment();
|
|
92
|
+
const trim = (url) => url.replace(/\/$/, "");
|
|
93
|
+
const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`];
|
|
94
|
+
if (explicit)
|
|
95
|
+
return trim(explicit);
|
|
96
|
+
if (environment) {
|
|
97
|
+
const suffixed = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()}`];
|
|
98
|
+
if (suffixed)
|
|
99
|
+
return trim(suffixed);
|
|
100
|
+
const mapped = environmentMap()[product]?.[environment];
|
|
101
|
+
if (mapped)
|
|
102
|
+
return trim(String(mapped));
|
|
103
|
+
// An environment was named and nothing defines its host. Falling back to discovery or
|
|
104
|
+
// to the declared production host here would send a preprod deployment at production,
|
|
105
|
+
// silently.
|
|
106
|
+
throw new InvocationError(`environment '${environment}' has no host for product '${product}'. Set ` +
|
|
107
|
+
`CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()} or add it to ` +
|
|
108
|
+
`CAPABILITY_REGISTRY_BASE_URLS.`);
|
|
109
|
+
}
|
|
110
|
+
/** A declared host is a better answer than none — but only after discovery has tried. */
|
|
111
|
+
const fallback = (error) => {
|
|
112
|
+
if (!source?.base_url)
|
|
113
|
+
throw error;
|
|
114
|
+
logger.warn("host discovery failed for product %s (%s); falling back to the declared host %s", product, error instanceof Error ? error.message : String(error), source.base_url);
|
|
115
|
+
return trim(source.base_url);
|
|
116
|
+
};
|
|
117
|
+
if ((source?.base_urls?.length ?? 0) > 0) {
|
|
118
|
+
try {
|
|
119
|
+
return await discoverBaseUrl(product, source, credentialsFrom(config), transport || fetchTransport());
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return fallback(error);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const builtin = BUILTIN_DISCOVERY[product];
|
|
126
|
+
if (builtin) {
|
|
127
|
+
try {
|
|
128
|
+
return trim(await builtin(config));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return fallback(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (source?.base_url)
|
|
135
|
+
return trim(source.base_url);
|
|
136
|
+
throw new InvocationError(`no host is configured for product '${product}': its index declares none and there ` +
|
|
137
|
+
`is no override. Set CAPABILITY_REGISTRY_BASE_URL_${key}.`);
|
|
138
|
+
}
|
|
139
|
+
/** The directory holding one subdirectory per product. */
|
|
140
|
+
const INDEX_ROOT = "capability";
|
|
141
|
+
function isDirectory(path) {
|
|
142
|
+
try {
|
|
143
|
+
return statSync(path).isDirectory();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Every product index under one directory, sorted so load order is stable.
|
|
151
|
+
*
|
|
152
|
+
* `<product>.capability-index.json` is the stored layout — the export publishes that exact
|
|
153
|
+
* filename, so an artifact drops in unrenamed. `<product>/index.json` is also accepted, so
|
|
154
|
+
* a checkout using the earlier nested layout keeps working.
|
|
155
|
+
*/
|
|
156
|
+
function filesIn(directory) {
|
|
157
|
+
if (!isDirectory(directory))
|
|
158
|
+
return [];
|
|
159
|
+
const found = [];
|
|
160
|
+
for (const entry of readdirSync(directory).sort()) {
|
|
161
|
+
const path = join(directory, entry);
|
|
162
|
+
if (entry.endsWith(FLAT_SUFFIX)) {
|
|
163
|
+
found.push(path);
|
|
164
|
+
}
|
|
165
|
+
else if (isDirectory(path) && existsSync(join(path, INDEX_FILE))) {
|
|
166
|
+
found.push(join(path, INDEX_FILE));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Locate the artifact(s).
|
|
173
|
+
*
|
|
174
|
+
* ONE FILE PER PRODUCT, so this returns a list. Explicit env wins; otherwise look for a
|
|
175
|
+
* `capability/` directory beside the compiled module and at the package root, then fall
|
|
176
|
+
* back to a single `capability-index.json` for the pre-release layout. The package-root
|
|
177
|
+
* candidates exist because `tsc` compiles TS and does not copy JSON into `dist`.
|
|
178
|
+
*
|
|
179
|
+
* Returns an empty list when nothing is found — the caller logs that and registers no
|
|
180
|
+
* tools, rather than throwing.
|
|
181
|
+
*/
|
|
182
|
+
export function indexPaths() {
|
|
183
|
+
const configuredFile = process.env.CAPABILITY_REGISTRY_INDEX;
|
|
184
|
+
if (configuredFile) {
|
|
185
|
+
return existsSync(configuredFile) ? [resolve(configuredFile)] : [];
|
|
186
|
+
}
|
|
187
|
+
const configuredDir = process.env.CAPABILITY_REGISTRY_INDEX_DIR;
|
|
188
|
+
if (configuredDir)
|
|
189
|
+
return filesIn(resolve(configuredDir));
|
|
190
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
191
|
+
const roots = [
|
|
192
|
+
here,
|
|
193
|
+
join(here, "..", "..", ".."), // dist/ or src/ -> package root
|
|
194
|
+
join(here, "..", "..", "..", ".."),
|
|
195
|
+
];
|
|
196
|
+
for (const root of roots) {
|
|
197
|
+
// `capabilities` (plural) is the layout this shipped with before the move to
|
|
198
|
+
// `capability/<product>/index.json`; still read so an existing checkout keeps working.
|
|
199
|
+
for (const name of [INDEX_ROOT, "capabilities"]) {
|
|
200
|
+
const found = filesIn(join(root, name));
|
|
201
|
+
if (found.length > 0)
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
const loose = filesIn(root);
|
|
205
|
+
if (loose.length > 0)
|
|
206
|
+
return loose;
|
|
207
|
+
}
|
|
208
|
+
for (const root of roots) {
|
|
209
|
+
const legacy = join(root, "capability-index.json");
|
|
210
|
+
if (existsSync(legacy))
|
|
211
|
+
return [legacy];
|
|
212
|
+
}
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
/** Off by default is wrong for a shipped feature, but a kill switch is not. */
|
|
216
|
+
export function isEnabled() {
|
|
217
|
+
return ((process.env.CAPABILITY_REGISTRY_DISABLED || "").toLowerCase() !== "true");
|
|
218
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which regional host does THIS account live on?
|
|
3
|
+
*
|
|
4
|
+
* Generalised from `lib/tm-base-url.ts`, which asks the question for Test Management by
|
|
5
|
+
* walking test-management{,-eu,-in}.browserstack.com and keeping the one that authenticates.
|
|
6
|
+
* The mechanism is not tm-specific — any product sharded by region answers the same way —
|
|
7
|
+
* so the candidate hosts move into the product's own index (`base_urls`) and this module
|
|
8
|
+
* performs the walk for all of them.
|
|
9
|
+
*
|
|
10
|
+
* A PROBE, NOT A LOOKUP. An account's data lives in exactly one region and the other regions
|
|
11
|
+
* reject its credentials, so the right host identifies itself. There is no mapping table to
|
|
12
|
+
* keep in sync, which is the point: a table would go stale silently.
|
|
13
|
+
*/
|
|
14
|
+
import { Credentials, Transport } from "./egress.js";
|
|
15
|
+
import { Capability, ProductIndex } from "./types.js";
|
|
16
|
+
/** What the resolver needs from a product's index to find its host. */
|
|
17
|
+
export type HostSource = Pick<ProductIndex, "base_url" | "base_urls" | "probe_path" | "auth"> & {
|
|
18
|
+
capabilities?: Capability[];
|
|
19
|
+
};
|
|
20
|
+
/** Exposed for tests; a long-lived process must not pin a stale region forever. */
|
|
21
|
+
export declare function clearDiscoveryCache(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Pick the endpoint to probe with.
|
|
24
|
+
*
|
|
25
|
+
* An explicit `probe_path` in the index wins. Otherwise derive one, and derive it
|
|
26
|
+
* CONSERVATIVELY — the probe reads a 2xx as "this is the account's region", so an endpoint
|
|
27
|
+
* that can fail for a reason unrelated to region would walk straight past the right host:
|
|
28
|
+
*
|
|
29
|
+
* * reads only, and never with a path placeholder — there is no id to supply yet;
|
|
30
|
+
* * no required query parameters, for the same reason;
|
|
31
|
+
* * paginated, because a paged listing is a primary collection by construction;
|
|
32
|
+
* * nothing under /admin, which 403s for an ordinary user;
|
|
33
|
+
* * shortest path, to prefer the root collection over its variants.
|
|
34
|
+
*
|
|
35
|
+
* For tm this lands on `/api/v1/projects`, the same family as the hand-written probe.
|
|
36
|
+
*/
|
|
37
|
+
export declare function probePath(source: HostSource): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Return the first candidate host that accepts the caller's credentials.
|
|
40
|
+
*
|
|
41
|
+
* Probed with the SAME auth the real calls use (`Api-Token`), not a second scheme, so a
|
|
42
|
+
* host that answers here is one that will answer for the invocation that follows.
|
|
43
|
+
*/
|
|
44
|
+
export declare function discoverBaseUrl(product: string, source: HostSource, credentials: Credentials, transport: Transport): Promise<string>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which regional host does THIS account live on?
|
|
3
|
+
*
|
|
4
|
+
* Generalised from `lib/tm-base-url.ts`, which asks the question for Test Management by
|
|
5
|
+
* walking test-management{,-eu,-in}.browserstack.com and keeping the one that authenticates.
|
|
6
|
+
* The mechanism is not tm-specific — any product sharded by region answers the same way —
|
|
7
|
+
* so the candidate hosts move into the product's own index (`base_urls`) and this module
|
|
8
|
+
* performs the walk for all of them.
|
|
9
|
+
*
|
|
10
|
+
* A PROBE, NOT A LOOKUP. An account's data lives in exactly one region and the other regions
|
|
11
|
+
* reject its credentials, so the right host identifies itself. There is no mapping table to
|
|
12
|
+
* keep in sync, which is the point: a table would go stale silently.
|
|
13
|
+
*/
|
|
14
|
+
import appConfig from "../../config.js";
|
|
15
|
+
import logger from "../../logger.js";
|
|
16
|
+
import { authHeaders } from "./egress.js";
|
|
17
|
+
import { InvocationError } from "./index-loader.js";
|
|
18
|
+
/**
|
|
19
|
+
* Cached per product AND per user, so the answer cannot cross accounts.
|
|
20
|
+
*
|
|
21
|
+
* `lib/tm-base-url.ts` disables its cache entirely under REMOTE_MCP because a process-wide
|
|
22
|
+
* single slot would serve the first user's region to everyone after them. Keying by user
|
|
23
|
+
* fixes that by construction, so the cache stays useful in remote mode too.
|
|
24
|
+
*/
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
/** Exposed for tests; a long-lived process must not pin a stale region forever. */
|
|
27
|
+
export function clearDiscoveryCache() {
|
|
28
|
+
cache.clear();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Pick the endpoint to probe with.
|
|
32
|
+
*
|
|
33
|
+
* An explicit `probe_path` in the index wins. Otherwise derive one, and derive it
|
|
34
|
+
* CONSERVATIVELY — the probe reads a 2xx as "this is the account's region", so an endpoint
|
|
35
|
+
* that can fail for a reason unrelated to region would walk straight past the right host:
|
|
36
|
+
*
|
|
37
|
+
* * reads only, and never with a path placeholder — there is no id to supply yet;
|
|
38
|
+
* * no required query parameters, for the same reason;
|
|
39
|
+
* * paginated, because a paged listing is a primary collection by construction;
|
|
40
|
+
* * nothing under /admin, which 403s for an ordinary user;
|
|
41
|
+
* * shortest path, to prefer the root collection over its variants.
|
|
42
|
+
*
|
|
43
|
+
* For tm this lands on `/api/v1/projects`, the same family as the hand-written probe.
|
|
44
|
+
*/
|
|
45
|
+
export function probePath(source) {
|
|
46
|
+
if (source.probe_path)
|
|
47
|
+
return source.probe_path;
|
|
48
|
+
const usable = (source.capabilities || []).filter((capability) => capability.mode === "read" &&
|
|
49
|
+
capability.paginated &&
|
|
50
|
+
!capability.path.includes("{") &&
|
|
51
|
+
!capability.path.includes("/admin") &&
|
|
52
|
+
!(capability.query || []).some((param) => param.required));
|
|
53
|
+
usable.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path));
|
|
54
|
+
return usable[0]?.path;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Return the first candidate host that accepts the caller's credentials.
|
|
58
|
+
*
|
|
59
|
+
* Probed with the SAME auth the real calls use (`Api-Token`), not a second scheme, so a
|
|
60
|
+
* host that answers here is one that will answer for the invocation that follows.
|
|
61
|
+
*/
|
|
62
|
+
export async function discoverBaseUrl(product, source, credentials, transport) {
|
|
63
|
+
const candidates = (source.base_urls || []).map((url) => url.replace(/\/$/, ""));
|
|
64
|
+
if (candidates.length === 0) {
|
|
65
|
+
throw new InvocationError(`product '${product}' declares no base_urls to probe`);
|
|
66
|
+
}
|
|
67
|
+
// One candidate is not a region question; skip the round trip.
|
|
68
|
+
if (candidates.length === 1)
|
|
69
|
+
return candidates[0];
|
|
70
|
+
const key = `${product}\n${credentials.username}`;
|
|
71
|
+
const cached = cache.get(key);
|
|
72
|
+
if (cached) {
|
|
73
|
+
logger.debug("using cached %s host for this account: %s", product, cached);
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
const path = probePath(source);
|
|
77
|
+
if (!path) {
|
|
78
|
+
throw new InvocationError(`product '${product}' declares several base_urls but no endpoint to probe them with; ` +
|
|
79
|
+
`set probe_path in its index`);
|
|
80
|
+
}
|
|
81
|
+
// The same scheme the invocation will use, so a host that answers here answers there.
|
|
82
|
+
// Minted ONCE for the whole probe, not per candidate: the token is the caller's identity
|
|
83
|
+
// and does not vary by which region answers.
|
|
84
|
+
const headers = await authHeaders(credentials, source.auth, product);
|
|
85
|
+
const failures = [];
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
const response = await transport("GET", `${candidate}${path}`, headers, {});
|
|
88
|
+
if (response.status >= 200 && response.status < 300) {
|
|
89
|
+
// Under REMOTE_MCP the key already carries the user, so this is safe to keep.
|
|
90
|
+
if (!appConfig.REMOTE_MCP || credentials.username)
|
|
91
|
+
cache.set(key, candidate);
|
|
92
|
+
logger.info("resolved %s to %s for this account", product, candidate);
|
|
93
|
+
return candidate;
|
|
94
|
+
}
|
|
95
|
+
failures.push(`${candidate}: HTTP ${response.status || "unreachable"}`);
|
|
96
|
+
}
|
|
97
|
+
// Every region refused. Saying which, and with what, is the difference between a
|
|
98
|
+
// debuggable report and "it did not work".
|
|
99
|
+
throw new InvocationError(`could not determine which region this account's ${product} lives on. Probed ${path} ` +
|
|
100
|
+
`on each host — ${failures.join("; ")}`);
|
|
101
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound call: auth, attribution, and one HTTP request.
|
|
3
|
+
*
|
|
4
|
+
* AUTH IS THE CALLER'S OWN CREDENTIALS, FORWARDED — never a token this server mints. How
|
|
5
|
+
* they are presented is the PRODUCT's to declare, in the OpenAPI `securityScheme` terms its
|
|
6
|
+
* own spec already uses. Absent a declaration the default is tm's: `Api-Token:
|
|
7
|
+
* <username>:<access_key>`, accepted by every /api/v1 route and validated against IAAM
|
|
8
|
+
* OAuth2 v2 — the same identity resolution a minted bearer token produces, one hop earlier.
|
|
9
|
+
* Verified in browserstack/teststack: the 59 v1 controllers inheriting
|
|
10
|
+
* ApplicationApiController resolve it in `current_user`, the 5 inheriting
|
|
11
|
+
* Api::V1::ApiController in `authenticate_token`.
|
|
12
|
+
*
|
|
13
|
+
* That default is right for tm and wrong to assume of everyone. Load Testing's
|
|
14
|
+
* /api/v1/agent/* surface is reported to take HTTP Basic, and before this there was nowhere
|
|
15
|
+
* to say so: the requirement lived in a pull request description while the server sent
|
|
16
|
+
* Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
|
|
17
|
+
* credentials are wrong.
|
|
18
|
+
*/
|
|
19
|
+
import { TokenTransport } from "../../lib/central-oauth.js";
|
|
20
|
+
import { AuthScheme } from "./types.js";
|
|
21
|
+
export type { Credentials } from "../../lib/central-oauth.js";
|
|
22
|
+
import type { Credentials } from "../../lib/central-oauth.js";
|
|
23
|
+
export interface HttpResponse {
|
|
24
|
+
status: number;
|
|
25
|
+
body: unknown;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
export type Transport = (method: string, url: string, headers: Record<string, string>, query: Record<string, unknown>, body?: unknown) => Promise<HttpResponse>;
|
|
29
|
+
/** The historical default, and what an index without an `auth` block still gets. */
|
|
30
|
+
export declare const DEFAULT_AUTH: AuthScheme;
|
|
31
|
+
/**
|
|
32
|
+
* Fill a credential template.
|
|
33
|
+
*
|
|
34
|
+
* An unknown placeholder is REFUSED, never passed through. Emitting `{user_id}` literally
|
|
35
|
+
* would send a header that looks well-formed and comes back 401 — indistinguishable from
|
|
36
|
+
* bad credentials, which is the failure this whole mechanism exists to remove. The harness
|
|
37
|
+
* really does carry templates this server cannot fill (`{user_id}_{group_id}`), so this is
|
|
38
|
+
* the common case, not a hypothetical.
|
|
39
|
+
*/
|
|
40
|
+
export declare function renderTemplate(template: string, credentials: Credentials): string;
|
|
41
|
+
/**
|
|
42
|
+
* The headers for one outbound call.
|
|
43
|
+
*
|
|
44
|
+
* ASYNC BECAUSE ONE SCHEME NEEDS A ROUND TRIP. `apiKey` and `http` are pure string work, but
|
|
45
|
+
* `oauth2` exchanges the caller's credentials for a JWT before anything can be sent, and
|
|
46
|
+
* there was nowhere to put that while this was synchronous. Both call sites were already
|
|
47
|
+
* inside async functions, so the cost is two `await`s.
|
|
48
|
+
*/
|
|
49
|
+
export declare function authHeaders(credentials: Credentials, auth?: AuthScheme, product?: string, tokenTransport?: TokenTransport): Promise<Record<string, string>>;
|
|
50
|
+
/** A fetch-based transport. Redirects are NOT followed. */
|
|
51
|
+
export declare function fetchTransport(timeoutMs?: number): Transport;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound call: auth, attribution, and one HTTP request.
|
|
3
|
+
*
|
|
4
|
+
* AUTH IS THE CALLER'S OWN CREDENTIALS, FORWARDED — never a token this server mints. How
|
|
5
|
+
* they are presented is the PRODUCT's to declare, in the OpenAPI `securityScheme` terms its
|
|
6
|
+
* own spec already uses. Absent a declaration the default is tm's: `Api-Token:
|
|
7
|
+
* <username>:<access_key>`, accepted by every /api/v1 route and validated against IAAM
|
|
8
|
+
* OAuth2 v2 — the same identity resolution a minted bearer token produces, one hop earlier.
|
|
9
|
+
* Verified in browserstack/teststack: the 59 v1 controllers inheriting
|
|
10
|
+
* ApplicationApiController resolve it in `current_user`, the 5 inheriting
|
|
11
|
+
* Api::V1::ApiController in `authenticate_token`.
|
|
12
|
+
*
|
|
13
|
+
* That default is right for tm and wrong to assume of everyone. Load Testing's
|
|
14
|
+
* /api/v1/agent/* surface is reported to take HTTP Basic, and before this there was nowhere
|
|
15
|
+
* to say so: the requirement lived in a pull request description while the server sent
|
|
16
|
+
* Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
|
|
17
|
+
* credentials are wrong.
|
|
18
|
+
*/
|
|
19
|
+
import { fetchTokenTransport, } from "../../lib/central-oauth.js";
|
|
20
|
+
import { InvocationError } from "./index-loader.js";
|
|
21
|
+
import { bearerToken } from "./oauth.js";
|
|
22
|
+
/** What this server can put into a credential template. Nothing else is fillable. */
|
|
23
|
+
const PLACEHOLDERS = ["username", "access_key"];
|
|
24
|
+
/** The historical default, and what an index without an `auth` block still gets. */
|
|
25
|
+
export const DEFAULT_AUTH = {
|
|
26
|
+
type: "apiKey",
|
|
27
|
+
in: "header",
|
|
28
|
+
name: "Api-Token",
|
|
29
|
+
template: "{username}:{access_key}",
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Fill a credential template.
|
|
33
|
+
*
|
|
34
|
+
* An unknown placeholder is REFUSED, never passed through. Emitting `{user_id}` literally
|
|
35
|
+
* would send a header that looks well-formed and comes back 401 — indistinguishable from
|
|
36
|
+
* bad credentials, which is the failure this whole mechanism exists to remove. The harness
|
|
37
|
+
* really does carry templates this server cannot fill (`{user_id}_{group_id}`), so this is
|
|
38
|
+
* the common case, not a hypothetical.
|
|
39
|
+
*/
|
|
40
|
+
export function renderTemplate(template, credentials) {
|
|
41
|
+
const unknown = [...template.matchAll(/\{([a-z_]+)\}/g)]
|
|
42
|
+
.map((match) => match[1])
|
|
43
|
+
.filter((nameed) => !PLACEHOLDERS.includes(nameed));
|
|
44
|
+
if (unknown.length > 0) {
|
|
45
|
+
throw new InvocationError(`auth template uses placeholder(s) this server cannot fill: ` +
|
|
46
|
+
`${[...new Set(unknown)].sort().join(", ")}. Available: ` +
|
|
47
|
+
`${PLACEHOLDERS.map((placeholder) => `{${placeholder}}`).join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
return template
|
|
50
|
+
.replaceAll("{username}", credentials.username)
|
|
51
|
+
.replaceAll("{access_key}", credentials.accessKey);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The headers for one outbound call.
|
|
55
|
+
*
|
|
56
|
+
* ASYNC BECAUSE ONE SCHEME NEEDS A ROUND TRIP. `apiKey` and `http` are pure string work, but
|
|
57
|
+
* `oauth2` exchanges the caller's credentials for a JWT before anything can be sent, and
|
|
58
|
+
* there was nowhere to put that while this was synchronous. Both call sites were already
|
|
59
|
+
* inside async functions, so the cost is two `await`s.
|
|
60
|
+
*/
|
|
61
|
+
export async function authHeaders(credentials, auth = DEFAULT_AUTH, product = "this product", tokenTransport = fetchTokenTransport()) {
|
|
62
|
+
if (!credentials?.username || !credentials?.accessKey) {
|
|
63
|
+
// Refusing here beats sending unauthenticated and surfacing the product's 401, which
|
|
64
|
+
// reads like the user's problem when it is our missing configuration.
|
|
65
|
+
throw new InvocationError("this request is not authenticated: BrowserStack username and access key are required");
|
|
66
|
+
}
|
|
67
|
+
const common = {
|
|
68
|
+
// Attribution, so the downstream service can see the call came from an agent.
|
|
69
|
+
"request-source": "ai-chatbot",
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
};
|
|
72
|
+
if (auth.type === "oauth2") {
|
|
73
|
+
// Refused rather than ignored, which is the same call `in: cookie` and `in: query` get
|
|
74
|
+
// below: a field that looks like it selects behaviour and does not is how someone
|
|
75
|
+
// spends an afternoon on a 401 they were never going to be able to explain.
|
|
76
|
+
const stray = ["in", "name", "template"].filter((field) => auth[field] !== undefined);
|
|
77
|
+
if (stray.length > 0) {
|
|
78
|
+
throw new InvocationError(`oauth2 auth does not take ${stray.join(", ")}: a bearer token's presentation is ` +
|
|
79
|
+
`fixed at 'Authorization: Bearer' (RFC 6750). Remove ${stray.length > 1 ? "them" : "it"}, ` +
|
|
80
|
+
`or use apiKey if this product really does read a different header.`);
|
|
81
|
+
}
|
|
82
|
+
const token = await bearerToken(product, auth, credentials, tokenTransport);
|
|
83
|
+
return { Authorization: `Bearer ${token}`, ...common };
|
|
84
|
+
}
|
|
85
|
+
const value = renderTemplate(auth.template || DEFAULT_AUTH.template, credentials);
|
|
86
|
+
if (auth.type === "apiKey") {
|
|
87
|
+
// Header only. `cookie` needs a session this server does not have, and `query` would
|
|
88
|
+
// put the credential in a URL, where access logs and proxies keep it.
|
|
89
|
+
if (auth.in && auth.in !== "header") {
|
|
90
|
+
throw new InvocationError(`unsupported auth location '${auth.in}': this server can only send credentials ` +
|
|
91
|
+
`in a header`);
|
|
92
|
+
}
|
|
93
|
+
if (!auth.name) {
|
|
94
|
+
throw new InvocationError("apiKey auth declares no header name");
|
|
95
|
+
}
|
|
96
|
+
return { [auth.name]: value, ...common };
|
|
97
|
+
}
|
|
98
|
+
if (auth.type === "http" && (auth.scheme || "").toLowerCase() === "basic") {
|
|
99
|
+
return {
|
|
100
|
+
Authorization: `Basic ${Buffer.from(value).toString("base64")}`,
|
|
101
|
+
...common,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// By name, and refusing: sending nothing would be a 401 the caller reads as their own
|
|
105
|
+
// fault, and guessing a scheme is how credentials end up somewhere they should not be.
|
|
106
|
+
throw new InvocationError(`unsupported auth scheme for this product: ` +
|
|
107
|
+
`${JSON.stringify({ type: auth.type, scheme: auth.scheme })}`);
|
|
108
|
+
}
|
|
109
|
+
/** Longest response text kept when it is not JSON. Enough for an error, not a whole page. */
|
|
110
|
+
const TEXT_BODY_LIMIT = 2000;
|
|
111
|
+
/**
|
|
112
|
+
* Read the response, and NEVER silently discard it.
|
|
113
|
+
*
|
|
114
|
+
* This used to parse the body only when the content-type said JSON and return `null`
|
|
115
|
+
* otherwise, which meant an error could arrive as `{status: 500, body: null}` — a status
|
|
116
|
+
* code and nothing else. That is worse than it sounds: an unhandled exception in a Rails
|
|
117
|
+
* app renders `text/html`, so the one case where you most need the message is exactly the
|
|
118
|
+
* case where the content-type is not JSON. It also made two very different situations
|
|
119
|
+
* indistinguishable — "the product sent no message" and "we threw the message away" — and
|
|
120
|
+
* a live probe of `get_test_case_linked_issues` had to leave that ambiguity open in its findings
|
|
121
|
+
* because nothing downstream could tell which had happened.
|
|
122
|
+
*
|
|
123
|
+
* So: JSON is parsed as before. Anything textual is kept as a truncated string. Malformed
|
|
124
|
+
* JSON keeps its raw text rather than becoming `null`, because a body that fails to parse
|
|
125
|
+
* is itself the diagnosis. Binary is described rather than decoded — dumping PDF bytes
|
|
126
|
+
* into an agent's context helps nobody, but knowing a PDF arrived does.
|
|
127
|
+
*/
|
|
128
|
+
async function readBody(response) {
|
|
129
|
+
const contentType = response.headers.get("content-type") || "";
|
|
130
|
+
// Anything that is not plausibly text: report what came back without decoding it.
|
|
131
|
+
const textual = !contentType ||
|
|
132
|
+
/^text\//i.test(contentType) ||
|
|
133
|
+
/\b(json|xml|yaml|csv|javascript|x-www-form-urlencoded)\b/i.test(contentType);
|
|
134
|
+
if (!textual) {
|
|
135
|
+
const size = response.headers.get("content-length");
|
|
136
|
+
return `<non-text response: ${contentType}${size ? `, ${size} bytes` : ""}>`;
|
|
137
|
+
}
|
|
138
|
+
const raw = await response.text().catch(() => "");
|
|
139
|
+
if (!raw.trim())
|
|
140
|
+
return null;
|
|
141
|
+
if (contentType.includes("json")) {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(raw);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Declared JSON that is not JSON. The text is the evidence; keep it.
|
|
147
|
+
return truncate(raw);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return truncate(raw);
|
|
151
|
+
}
|
|
152
|
+
function truncate(text) {
|
|
153
|
+
return text.length <= TEXT_BODY_LIMIT
|
|
154
|
+
? text
|
|
155
|
+
: `${text.slice(0, TEXT_BODY_LIMIT)}… [truncated, ${text.length} chars total]`;
|
|
156
|
+
}
|
|
157
|
+
/** A fetch-based transport. Redirects are NOT followed. */
|
|
158
|
+
export function fetchTransport(timeoutMs = 45_000) {
|
|
159
|
+
return async (method, url, headers, query, body) => {
|
|
160
|
+
const target = new URL(url);
|
|
161
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
162
|
+
if (value !== undefined && value !== null)
|
|
163
|
+
target.searchParams.set(key, String(value));
|
|
164
|
+
}
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
167
|
+
try {
|
|
168
|
+
const response = await fetch(target.toString(), {
|
|
169
|
+
method,
|
|
170
|
+
headers,
|
|
171
|
+
// Only send a body when there IS one: a literal `null` payload with a JSON
|
|
172
|
+
// content-type is rejected by several endpoints.
|
|
173
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
174
|
+
// A redirect from an authenticated API is usually a login bounce, and following it
|
|
175
|
+
// turns a clear 401/302 into a 200 carrying an HTML sign-in page — which the
|
|
176
|
+
// resolver would then read as an empty result set rather than a failure.
|
|
177
|
+
redirect: "manual",
|
|
178
|
+
signal: controller.signal,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
status: response.status,
|
|
182
|
+
body: await readBody(response),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Upstream detail stays out of the reply; the resolver treats status 0 as a failed call.
|
|
187
|
+
return {
|
|
188
|
+
status: 0,
|
|
189
|
+
body: null,
|
|
190
|
+
error: "the product could not be reached",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|