@browserstack/mcp-server 2.2.0-beta.2 → 2.2.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/README.md +42 -432
- package/capability/loadtesting.capability-index.json +3001 -0
- package/capability/tm.capability-index.json +1 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +46 -25
- package/dist/tools/ask-browserstack/central-oauth.js +177 -55
- package/dist/tools/capability-registry/bind.js +7 -43
- package/dist/tools/capability-registry/discovery.js +1 -3
- package/dist/tools/capability-registry/egress.d.ts +5 -12
- package/dist/tools/capability-registry/egress.js +1 -24
- package/dist/tools/capability-registry/register.js +41 -289
- package/dist/tools/capability-registry/resolve.d.ts +1 -1
- package/dist/tools/capability-registry/resolve.js +3 -24
- package/dist/tools/capability-registry/search.d.ts +1 -28
- package/dist/tools/capability-registry/search.js +4 -202
- package/dist/tools/capability-registry/types.d.ts +3 -42
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +12 -15
- package/dist/tools/tool-handoff.js +18 -21
- package/package.json +3 -4
- package/capability/tra.capability-index.json +0 -52096
- package/dist/lib/central-oauth.d.ts +0 -133
- package/dist/lib/central-oauth.js +0 -246
- package/dist/tools/capability-registry/oauth.d.ts +0 -63
- package/dist/tools/capability-registry/oauth.js +0 -176
- package/dist/tools/capability-registry/routing-token.d.ts +0 -25
- package/dist/tools/capability-registry/routing-token.js +0 -87
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
"version": "1.46",
|
|
4
4
|
"build_id": "cb63a4a5_2026-09-16T18:02:57Z",
|
|
5
5
|
"tm": {
|
|
6
|
-
"
|
|
7
|
-
"summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. A run is executed against a named configuration and environment, and records the app version and build its results came from. Destructive capabilities exist and always need explicit human approval.",
|
|
6
|
+
"summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. Destructive capabilities exist and always need explicit human approval.",
|
|
8
7
|
"capabilities": [
|
|
9
8
|
{
|
|
10
9
|
"name": "assign_test_run_owner",
|
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* The MECHANISM — the form, the transport, the classification, the cache — moved to
|
|
5
|
-
* `src/lib/central-oauth.ts` when the capability registry needed the same exchange for
|
|
6
|
-
* products whose APIs take a Bearer JWT rather than `Api-Token`. Two minters would have
|
|
7
|
-
* drifted, and the one that drifted would have been the one with fewer tests.
|
|
8
|
-
*
|
|
9
|
-
* What stays here is everything ATLAS-SPECIFIC and nothing else: the scope pair, the skew
|
|
10
|
-
* that exists because Atlas holds the token for a whole run, and the messages — which say
|
|
11
|
-
* "nothing reached the agent" and name `ai_agent_notify`, and would be actively wrong in
|
|
12
|
-
* front of a capability-registry user.
|
|
2
|
+
* Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
|
|
13
3
|
*
|
|
14
4
|
* This replaces a shared delegation token, and the upgrade is not cosmetic.
|
|
15
5
|
* `validate_delegation_token` refuses any token without `user.user_id`/`user.group_id`, so
|
|
@@ -17,17 +7,20 @@
|
|
|
17
7
|
* user from signed claims rather than from anything we put in the request body, and reuses
|
|
18
8
|
* this same JWT as its `egress_token` — so the product call a human approves runs as that
|
|
19
9
|
* human, not as a shared service account.
|
|
10
|
+
*
|
|
11
|
+
* SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
|
|
12
|
+
* it the hard way: "The body can echo the credential back on some errors, so it is NOT
|
|
13
|
+
* logged or raised — only the status." Neither the access key nor the minted token is ever
|
|
14
|
+
* logged, returned, or put in an error message. Only a status code is.
|
|
20
15
|
*/
|
|
21
|
-
import { Credentials
|
|
22
|
-
export { fetchTokenTransport, refusalIsAboutScope, REQUESTED_EXPIRES_IN, resetTokenCache, TOKEN_TIMEOUT_MS, type TokenResponse, type TokenTransport, } from "../../lib/central-oauth.js";
|
|
16
|
+
import { Credentials } from "./egress.js";
|
|
23
17
|
/**
|
|
24
18
|
* BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
|
|
25
19
|
*
|
|
26
20
|
* `oauth_user_profile` stays because it is what makes the pair obtainable through the
|
|
27
|
-
* username+access_key flow at all
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* in `web/oauth.py`); both halves move together with Atlas.
|
|
21
|
+
* username+access_key flow at all. `ai_agent_notify` is what Atlas matches on
|
|
22
|
+
* (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in
|
|
23
|
+
* `web/oauth.py`); both halves move together with Atlas.
|
|
31
24
|
*
|
|
32
25
|
* THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
|
|
33
26
|
* discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
|
|
@@ -49,11 +42,10 @@ export { fetchTokenTransport, refusalIsAboutScope, REQUESTED_EXPIRES_IN, resetTo
|
|
|
49
42
|
* application — and it is reported as one, naming the scope. It is never retried with a
|
|
50
43
|
* different scope: a silent downgrade to a different authorization is exactly the kind of
|
|
51
44
|
* thing nobody notices until it matters.
|
|
52
|
-
*
|
|
53
|
-
* Named here rather than taken from the shared default, so that changing the registry's
|
|
54
|
-
* scopes can never silently change what Atlas is authorised for.
|
|
55
45
|
*/
|
|
56
46
|
export declare const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
|
|
47
|
+
/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
|
|
48
|
+
export declare const REQUESTED_EXPIRES_IN = 3600;
|
|
57
49
|
/**
|
|
58
50
|
* Treat a token as stale this long before it actually expires.
|
|
59
51
|
*
|
|
@@ -64,6 +56,26 @@ export declare const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
|
|
|
64
56
|
* expired credential, which is the exact mid-flight expiry this cache exists to prevent.
|
|
65
57
|
*/
|
|
66
58
|
export declare const REFRESH_SKEW_MS: number;
|
|
59
|
+
/** The token endpoint gets its own, much shorter budget than `/agent`. */
|
|
60
|
+
export declare const TOKEN_TIMEOUT_MS = 15000;
|
|
61
|
+
export interface TokenResponse {
|
|
62
|
+
status: number;
|
|
63
|
+
body: unknown;
|
|
64
|
+
/** Only when there was no response at all to speak for itself. */
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
67
|
+
export type TokenTransport = (url: string, form: Record<string, string>) => Promise<TokenResponse>;
|
|
68
|
+
/**
|
|
69
|
+
* Was this refusal about the SCOPE or about the CREDENTIAL?
|
|
70
|
+
*
|
|
71
|
+
* The two need completely different fixes — provisioning versus a password — so collapsing
|
|
72
|
+
* them into one message sends someone to the wrong place entirely. Our form has five fields
|
|
73
|
+
* and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
|
|
74
|
+
* only really be about the scope.
|
|
75
|
+
*
|
|
76
|
+
* Nothing from the body is ever surfaced; the code is used to classify and then discarded.
|
|
77
|
+
*/
|
|
78
|
+
export declare function refusalIsAboutScope(status: number, body: unknown): boolean;
|
|
67
79
|
/**
|
|
68
80
|
* The ways authentication can fail, kept apart because a user cannot act on them otherwise.
|
|
69
81
|
*
|
|
@@ -87,13 +99,22 @@ export declare const AUTH_UNREACHABLE_DETAIL: string;
|
|
|
87
99
|
*/
|
|
88
100
|
export declare const AUTH_SERVER_ERROR_DETAIL: (status: number) => string;
|
|
89
101
|
export declare const AUTH_UNUSABLE_DETAIL: (status: number) => string;
|
|
90
|
-
/**
|
|
102
|
+
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
103
|
+
export declare function resetTokenCache(): void;
|
|
104
|
+
/**
|
|
105
|
+
* The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
|
|
106
|
+
*
|
|
107
|
+
* `raise_error: false` keeps the status-first contract this transport has always had: the
|
|
108
|
+
* caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
|
|
109
|
+
* so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
|
|
110
|
+
*/
|
|
111
|
+
export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
|
|
112
|
+
/** The exact form body of the `client_credentials` grant. */
|
|
91
113
|
export declare function mintForm(credentials: Credentials): Record<string, string>;
|
|
92
114
|
/**
|
|
93
115
|
* Return a valid token, minting one only when the cache has nothing fresh.
|
|
94
116
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* data in module-level state there, so remote mode mints per call.
|
|
117
|
+
* Minting per tool call would add a round trip to every request and make the token endpoint
|
|
118
|
+
* a hot dependency of the whole surface.
|
|
98
119
|
*/
|
|
99
|
-
export declare function mintCentralToken(url: string, credentials: Credentials, transport:
|
|
120
|
+
export declare function mintCentralToken(url: string, credentials: Credentials, transport: TokenTransport, now?: number): Promise<string>;
|
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* The MECHANISM — the form, the transport, the classification, the cache — moved to
|
|
5
|
-
* `src/lib/central-oauth.ts` when the capability registry needed the same exchange for
|
|
6
|
-
* products whose APIs take a Bearer JWT rather than `Api-Token`. Two minters would have
|
|
7
|
-
* drifted, and the one that drifted would have been the one with fewer tests.
|
|
8
|
-
*
|
|
9
|
-
* What stays here is everything ATLAS-SPECIFIC and nothing else: the scope pair, the skew
|
|
10
|
-
* that exists because Atlas holds the token for a whole run, and the messages — which say
|
|
11
|
-
* "nothing reached the agent" and name `ai_agent_notify`, and would be actively wrong in
|
|
12
|
-
* front of a capability-registry user.
|
|
2
|
+
* Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
|
|
13
3
|
*
|
|
14
4
|
* This replaces a shared delegation token, and the upgrade is not cosmetic.
|
|
15
5
|
* `validate_delegation_token` refuses any token without `user.user_id`/`user.group_id`, so
|
|
@@ -17,20 +7,24 @@
|
|
|
17
7
|
* user from signed claims rather than from anything we put in the request body, and reuses
|
|
18
8
|
* this same JWT as its `egress_token` — so the product call a human approves runs as that
|
|
19
9
|
* human, not as a shared service account.
|
|
10
|
+
*
|
|
11
|
+
* SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
|
|
12
|
+
* it the hard way: "The body can echo the credential back on some errors, so it is NOT
|
|
13
|
+
* logged or raised — only the status." Neither the access key nor the minted token is ever
|
|
14
|
+
* logged, returned, or put in an error message. Only a status code is.
|
|
20
15
|
*/
|
|
21
|
-
import {
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { apiClient } from "../../lib/apiClient.js";
|
|
22
18
|
import appConfig from "../../config.js";
|
|
23
19
|
import logger from "../../logger.js";
|
|
24
20
|
import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
|
|
25
|
-
export { fetchTokenTransport, refusalIsAboutScope, REQUESTED_EXPIRES_IN, resetTokenCache, TOKEN_TIMEOUT_MS, } from "../../lib/central-oauth.js";
|
|
26
21
|
/**
|
|
27
22
|
* BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
|
|
28
23
|
*
|
|
29
24
|
* `oauth_user_profile` stays because it is what makes the pair obtainable through the
|
|
30
|
-
* username+access_key flow at all
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* in `web/oauth.py`); both halves move together with Atlas.
|
|
25
|
+
* username+access_key flow at all. `ai_agent_notify` is what Atlas matches on
|
|
26
|
+
* (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in
|
|
27
|
+
* `web/oauth.py`); both halves move together with Atlas.
|
|
34
28
|
*
|
|
35
29
|
* THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
|
|
36
30
|
* discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
|
|
@@ -52,11 +46,10 @@ export { fetchTokenTransport, refusalIsAboutScope, REQUESTED_EXPIRES_IN, resetTo
|
|
|
52
46
|
* application — and it is reported as one, naming the scope. It is never retried with a
|
|
53
47
|
* different scope: a silent downgrade to a different authorization is exactly the kind of
|
|
54
48
|
* thing nobody notices until it matters.
|
|
55
|
-
*
|
|
56
|
-
* Named here rather than taken from the shared default, so that changing the registry's
|
|
57
|
-
* scopes can never silently change what Atlas is authorised for.
|
|
58
49
|
*/
|
|
59
50
|
export const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
|
|
51
|
+
/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
|
|
52
|
+
export const REQUESTED_EXPIRES_IN = 3600;
|
|
60
53
|
/**
|
|
61
54
|
* Treat a token as stale this long before it actually expires.
|
|
62
55
|
*
|
|
@@ -67,6 +60,49 @@ export const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
|
|
|
67
60
|
* expired credential, which is the exact mid-flight expiry this cache exists to prevent.
|
|
68
61
|
*/
|
|
69
62
|
export const REFRESH_SKEW_MS = AGENT_TIMEOUT_MS + 60_000;
|
|
63
|
+
/** The token endpoint gets its own, much shorter budget than `/agent`. */
|
|
64
|
+
export const TOKEN_TIMEOUT_MS = 15_000;
|
|
65
|
+
/**
|
|
66
|
+
* The OAuth2 error codes we are willing to read out of a failure body.
|
|
67
|
+
*
|
|
68
|
+
* `error` is a fixed enum token in the spec, so it cannot carry a credential; `error_description`
|
|
69
|
+
* is free text and demonstrably CAN ("access_key <key> is invalid"), which is why only the
|
|
70
|
+
* code is ever looked at and only when it is one of these. Anything else is ignored entirely
|
|
71
|
+
* and the classification falls back to the status.
|
|
72
|
+
*/
|
|
73
|
+
const SCOPE_ERROR_CODES = [
|
|
74
|
+
"invalid_scope",
|
|
75
|
+
"unauthorized_client",
|
|
76
|
+
"invalid_request",
|
|
77
|
+
];
|
|
78
|
+
const CREDENTIAL_ERROR_CODES = [
|
|
79
|
+
"invalid_client",
|
|
80
|
+
"invalid_grant",
|
|
81
|
+
"access_denied",
|
|
82
|
+
];
|
|
83
|
+
/**
|
|
84
|
+
* Was this refusal about the SCOPE or about the CREDENTIAL?
|
|
85
|
+
*
|
|
86
|
+
* The two need completely different fixes — provisioning versus a password — so collapsing
|
|
87
|
+
* them into one message sends someone to the wrong place entirely. Our form has five fields
|
|
88
|
+
* and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
|
|
89
|
+
* only really be about the scope.
|
|
90
|
+
*
|
|
91
|
+
* Nothing from the body is ever surfaced; the code is used to classify and then discarded.
|
|
92
|
+
*/
|
|
93
|
+
export function refusalIsAboutScope(status, body) {
|
|
94
|
+
const payload = typeof body === "object" && body !== null
|
|
95
|
+
? body
|
|
96
|
+
: {};
|
|
97
|
+
const code = typeof payload.error === "string" ? payload.error : "";
|
|
98
|
+
if (SCOPE_ERROR_CODES.includes(code))
|
|
99
|
+
return true;
|
|
100
|
+
if (CREDENTIAL_ERROR_CODES.includes(code))
|
|
101
|
+
return false;
|
|
102
|
+
// No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so
|
|
103
|
+
// the status is the next best evidence.
|
|
104
|
+
return status === 400;
|
|
105
|
+
}
|
|
70
106
|
/**
|
|
71
107
|
* The ways authentication can fail, kept apart because a user cannot act on them otherwise.
|
|
72
108
|
*
|
|
@@ -105,51 +141,137 @@ export const AUTH_SERVER_ERROR_DETAIL = (status) => `BrowserStack auth is unavai
|
|
|
105
141
|
`BrowserStack auth is back.`;
|
|
106
142
|
export const AUTH_UNUSABLE_DETAIL = (status) => `BrowserStack auth answered HTTP ${status} without issuing a token. NOTHING REACHED THE ` +
|
|
107
143
|
`AGENT — no request was made, no prompt appeared and nothing was changed.`;
|
|
108
|
-
|
|
144
|
+
const cache = new Map();
|
|
145
|
+
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
146
|
+
export function resetTokenCache() {
|
|
147
|
+
cache.clear();
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The cache key.
|
|
151
|
+
*
|
|
152
|
+
* Keyed on the access key so that ROTATING it mints immediately rather than leaving a
|
|
153
|
+
* revoked credential working until expiry — but on a SHA-256 of it, never the value, so the
|
|
154
|
+
* secret is not left sitting in a map key for the life of the process.
|
|
155
|
+
*/
|
|
156
|
+
function cacheKey(url, credentials) {
|
|
157
|
+
const digest = createHash("sha256")
|
|
158
|
+
.update(credentials.accessKey)
|
|
159
|
+
.digest("hex");
|
|
160
|
+
return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
|
|
164
|
+
*
|
|
165
|
+
* `raise_error: false` keeps the status-first contract this transport has always had: the
|
|
166
|
+
* caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
|
|
167
|
+
* so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
|
|
168
|
+
*/
|
|
169
|
+
export function fetchTokenTransport(timeoutMs = TOKEN_TIMEOUT_MS) {
|
|
170
|
+
return async (url, form) => {
|
|
171
|
+
try {
|
|
172
|
+
const response = await apiClient.post({
|
|
173
|
+
url,
|
|
174
|
+
headers: {
|
|
175
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
176
|
+
Accept: "application/json",
|
|
177
|
+
},
|
|
178
|
+
body: new URLSearchParams(form).toString(),
|
|
179
|
+
timeout: timeoutMs,
|
|
180
|
+
raise_error: false,
|
|
181
|
+
});
|
|
182
|
+
return { status: response.status, body: response.data ?? null };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
|
|
186
|
+
// carried: it can name the URL and, on some stacks, echo the request body.
|
|
187
|
+
return { status: 0, body: null, error: "auth could not be reached" };
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** The exact form body of the `client_credentials` grant. */
|
|
109
192
|
export function mintForm(credentials) {
|
|
110
|
-
return
|
|
193
|
+
return {
|
|
194
|
+
grant_type: "client_credentials",
|
|
195
|
+
username: credentials.username,
|
|
196
|
+
access_key: credentials.accessKey,
|
|
197
|
+
scope: CENTRAL_SCOPE,
|
|
198
|
+
expires_in: String(REQUESTED_EXPIRES_IN),
|
|
199
|
+
};
|
|
111
200
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
case "unreachable":
|
|
121
|
-
return new AskError(AUTH_UNREACHABLE_DETAIL);
|
|
122
|
-
case "server_error":
|
|
123
|
-
return new AskError(AUTH_SERVER_ERROR_DETAIL(error.status));
|
|
124
|
-
case "refused_scope":
|
|
125
|
-
return new AskError(AUTH_SCOPE_REFUSED_DETAIL(error.status));
|
|
126
|
-
case "refused_credentials":
|
|
127
|
-
return new AskError(AUTH_REJECTED_DETAIL(error.status));
|
|
128
|
-
default:
|
|
129
|
-
return new AskError(AUTH_UNUSABLE_DETAIL(error.status));
|
|
201
|
+
async function mintOnce(url, credentials, transport) {
|
|
202
|
+
const response = await transport(url, mintForm(credentials));
|
|
203
|
+
if (response.status === 0)
|
|
204
|
+
throw new AskError(AUTH_UNREACHABLE_DETAIL);
|
|
205
|
+
// 5xx BEFORE the refusal branch: a server error is not a refusal, and reading it as one
|
|
206
|
+
// is worse than saying nothing — it names the caller's credentials as the fault.
|
|
207
|
+
if (response.status >= 500) {
|
|
208
|
+
throw new AskError(AUTH_SERVER_ERROR_DETAIL(response.status));
|
|
130
209
|
}
|
|
210
|
+
if (response.status !== 200) {
|
|
211
|
+
// ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a
|
|
212
|
+
// credential one, and nothing out of it is ever put in the message — a non-200 body can
|
|
213
|
+
// echo the access key straight back.
|
|
214
|
+
throw new AskError(refusalIsAboutScope(response.status, response.body)
|
|
215
|
+
? AUTH_SCOPE_REFUSED_DETAIL(response.status)
|
|
216
|
+
: AUTH_REJECTED_DETAIL(response.status));
|
|
217
|
+
}
|
|
218
|
+
const body = typeof response.body === "object" && response.body !== null
|
|
219
|
+
? response.body
|
|
220
|
+
: {};
|
|
221
|
+
const token = body.access_token;
|
|
222
|
+
if (typeof token !== "string" || !token) {
|
|
223
|
+
throw new AskError(AUTH_UNUSABLE_DETAIL(response.status));
|
|
224
|
+
}
|
|
225
|
+
// Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and
|
|
226
|
+
// caching for the requested hour when it granted less would hand out a dead token.
|
|
227
|
+
const granted = Number(body.expires_in);
|
|
228
|
+
const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN;
|
|
229
|
+
return { token, lifetimeMs: seconds * 1000 };
|
|
131
230
|
}
|
|
132
231
|
/**
|
|
133
232
|
* Return a valid token, minting one only when the cache has nothing fresh.
|
|
134
233
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* data in module-level state there, so remote mode mints per call.
|
|
234
|
+
* Minting per tool call would add a round trip to every request and make the token endpoint
|
|
235
|
+
* a hot dependency of the whole surface.
|
|
138
236
|
*/
|
|
139
237
|
export async function mintCentralToken(url, credentials, transport, now = Date.now()) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
238
|
+
// Refused before any network call, and by name: these ARE the auth credential now, not
|
|
239
|
+
// merely attribution, so an empty one is our missing configuration rather than the user's
|
|
240
|
+
// rejected password, and must not read like one.
|
|
241
|
+
if (!credentials?.username || !credentials?.accessKey) {
|
|
242
|
+
throw new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
|
|
243
|
+
"BROWSERSTACK_ACCESS_KEY are required to sign in");
|
|
244
|
+
}
|
|
245
|
+
// NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
|
|
246
|
+
// process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
|
|
247
|
+
// data in module-level state there, so remote mode mints per call. Keying on
|
|
248
|
+
// username + sha256(accessKey) already means one user can never be SERVED another's token,
|
|
249
|
+
// but containment is not the contract; not holding it at all is.
|
|
250
|
+
if (appConfig.REMOTE_MCP) {
|
|
251
|
+
return mintOnce(url, credentials, transport).then(({ token }) => {
|
|
252
|
+
logger.info("askBrowserStackAI: signed in as %s", credentials.username);
|
|
253
|
+
return token;
|
|
150
254
|
});
|
|
151
255
|
}
|
|
152
|
-
|
|
153
|
-
|
|
256
|
+
const key = cacheKey(url, credentials);
|
|
257
|
+
const entry = cache.get(key);
|
|
258
|
+
if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) {
|
|
259
|
+
return entry.token;
|
|
154
260
|
}
|
|
261
|
+
// Double-checked through a shared promise: concurrent callers await the same mint.
|
|
262
|
+
if (entry?.inflight)
|
|
263
|
+
return entry.inflight;
|
|
264
|
+
const pending = mintOnce(url, credentials, transport)
|
|
265
|
+
.then(({ token, lifetimeMs }) => {
|
|
266
|
+
cache.set(key, { token, expiresAt: now + lifetimeMs });
|
|
267
|
+
logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
|
|
268
|
+
return token;
|
|
269
|
+
})
|
|
270
|
+
.catch((error) => {
|
|
271
|
+
// Never leave a rejected promise cached, or every later call inherits this failure.
|
|
272
|
+
cache.delete(key);
|
|
273
|
+
throw error;
|
|
274
|
+
});
|
|
275
|
+
cache.set(key, { token: "", expiresAt: 0, inflight: pending });
|
|
276
|
+
return pending;
|
|
155
277
|
}
|
|
@@ -230,15 +230,6 @@ function coerceType(value, param, label = param.name) {
|
|
|
230
230
|
if (expected === "integer" && !Number.isInteger(parsed)) {
|
|
231
231
|
throw new InvocationError(`'${label}' must be a whole number, not ${JSON.stringify(raw)}`);
|
|
232
232
|
}
|
|
233
|
-
// Past 2^53 `Number` rounds: TRA's 17-digit unique-error cluster ids came back as a
|
|
234
|
-
// neighbouring id, so the call silently targeted the wrong cluster. Keep the caller's
|
|
235
|
-
// exact digits instead — path and query serialise a string identically, and a JSON body
|
|
236
|
-
// carrying a digit string still binds to a Long on the server.
|
|
237
|
-
if (expected === "integer" &&
|
|
238
|
-
!Number.isSafeInteger(parsed) &&
|
|
239
|
-
/^-?\d+$/.test(raw)) {
|
|
240
|
-
return raw;
|
|
241
|
-
}
|
|
242
233
|
return parsed;
|
|
243
234
|
}
|
|
244
235
|
if (expected === "boolean") {
|
|
@@ -289,12 +280,6 @@ function place(root, pointer, value) {
|
|
|
289
280
|
}
|
|
290
281
|
cursor[segments[segments.length - 1]] = value;
|
|
291
282
|
}
|
|
292
|
-
const MAP_KEY = /^[A-Za-z0-9][\w.-]*$/;
|
|
293
|
-
function keyedParam(group, params) {
|
|
294
|
-
if (group !== "body")
|
|
295
|
-
return undefined;
|
|
296
|
-
return params.find((param) => /^\{\w+\}$/.test(param.name));
|
|
297
|
-
}
|
|
298
283
|
const GROUPS = [
|
|
299
284
|
{ group: "path_params", declared: "path_params" },
|
|
300
285
|
{ group: "query", declared: "query" },
|
|
@@ -313,10 +298,9 @@ export function bind(capability, args) {
|
|
|
313
298
|
}
|
|
314
299
|
const params = capability[declared] || [];
|
|
315
300
|
const byName = new Map(params.map((param) => [param.name, param]));
|
|
316
|
-
const keyed = keyedParam(group, params);
|
|
317
301
|
// Unknown arguments are an error rather than being dropped: silently ignoring a
|
|
318
302
|
// misspelled filter would return a larger result set that looks like a correct answer.
|
|
319
|
-
const unknown = Object.keys(supplied).filter((name) => !byName.has(name)
|
|
303
|
+
const unknown = Object.keys(supplied).filter((name) => !byName.has(name));
|
|
320
304
|
if (unknown.length > 0) {
|
|
321
305
|
// A CALLER WHO SENT THE WRAPPER WE BUILD DESERVES TO BE TOLD SO.
|
|
322
306
|
//
|
|
@@ -347,10 +331,7 @@ export function bind(capability, args) {
|
|
|
347
331
|
: ""));
|
|
348
332
|
}
|
|
349
333
|
for (const [name, raw] of Object.entries(supplied)) {
|
|
350
|
-
const param = byName.get(name)
|
|
351
|
-
if (param === keyed && !MAP_KEY.test(name)) {
|
|
352
|
-
throw new InvocationError(`'${name}' is not a usable ${keyed.name} key: use the id exactly as a read returned it`);
|
|
353
|
-
}
|
|
334
|
+
const param = byName.get(name);
|
|
354
335
|
const value = coerce(raw, param);
|
|
355
336
|
if (group === "path_params") {
|
|
356
337
|
// Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the
|
|
@@ -358,10 +339,7 @@ export function bind(capability, args) {
|
|
|
358
339
|
path = path.replaceAll(`{${name}}`, encodeURIComponent(pathSegment(value, name)));
|
|
359
340
|
}
|
|
360
341
|
else if (group === "body") {
|
|
361
|
-
|
|
362
|
-
place(body, param === keyed && !byName.has(name)
|
|
363
|
-
? pointer.replace(keyed.name, name)
|
|
364
|
-
: pointer, value);
|
|
342
|
+
place(body, param.json_path || `/${name}`, value);
|
|
365
343
|
}
|
|
366
344
|
else {
|
|
367
345
|
query[name] = value;
|
|
@@ -376,14 +354,9 @@ export function bind(capability, args) {
|
|
|
376
354
|
if (group === "query")
|
|
377
355
|
continue;
|
|
378
356
|
const supplied = args[group] || {};
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
for (const param of params) {
|
|
383
|
-
const present = param.name in supplied ||
|
|
384
|
-
(param === keyed &&
|
|
385
|
-
Object.keys(supplied).some((name) => !declaredNames.has(name)));
|
|
386
|
-
if (param.required && !present)
|
|
357
|
+
for (const param of capability[declared] ||
|
|
358
|
+
[]) {
|
|
359
|
+
if (param.required && !(param.name in supplied))
|
|
387
360
|
missing.push(param.name);
|
|
388
361
|
}
|
|
389
362
|
}
|
|
@@ -394,14 +367,5 @@ export function bind(capability, args) {
|
|
|
394
367
|
if (leftover) {
|
|
395
368
|
throw new InvocationError(`path placeholder(s) not supplied: ${leftover.join(", ")}`);
|
|
396
369
|
}
|
|
397
|
-
|
|
398
|
-
// all, and a route with a required @RequestBody (TRA close_build, whose guidance says to
|
|
399
|
-
// send `{}`) answered 400 "Invalid request payload". GET/HEAD never carry one.
|
|
400
|
-
const sentEmptyBody = args.body !== undefined &&
|
|
401
|
-
!["GET", "HEAD"].includes(capability.method.toUpperCase());
|
|
402
|
-
return {
|
|
403
|
-
path,
|
|
404
|
-
query,
|
|
405
|
-
body: Object.keys(body).length > 0 || sentEmptyBody ? body : undefined,
|
|
406
|
-
};
|
|
370
|
+
return { path, query, body: Object.keys(body).length > 0 ? body : undefined };
|
|
407
371
|
}
|
|
@@ -79,9 +79,7 @@ export async function discoverBaseUrl(product, source, credentials, transport) {
|
|
|
79
79
|
`set probe_path in its index`);
|
|
80
80
|
}
|
|
81
81
|
// The same scheme the invocation will use, so a host that answers here answers there.
|
|
82
|
-
|
|
83
|
-
// and does not vary by which region answers.
|
|
84
|
-
const headers = await authHeaders(credentials, source.auth, product);
|
|
82
|
+
const headers = authHeaders(credentials, source.auth);
|
|
85
83
|
const failures = [];
|
|
86
84
|
for (const candidate of candidates) {
|
|
87
85
|
const response = await transport("GET", `${candidate}${path}`, headers, {});
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
|
|
17
17
|
* credentials are wrong.
|
|
18
18
|
*/
|
|
19
|
-
import { TokenTransport } from "../../lib/central-oauth.js";
|
|
20
19
|
import { AuthScheme } from "./types.js";
|
|
21
|
-
export
|
|
22
|
-
|
|
20
|
+
export interface Credentials {
|
|
21
|
+
username: string;
|
|
22
|
+
accessKey: string;
|
|
23
|
+
}
|
|
23
24
|
export interface HttpResponse {
|
|
24
25
|
status: number;
|
|
25
26
|
body: unknown;
|
|
@@ -38,14 +39,6 @@ export declare const DEFAULT_AUTH: AuthScheme;
|
|
|
38
39
|
* the common case, not a hypothetical.
|
|
39
40
|
*/
|
|
40
41
|
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>>;
|
|
42
|
+
export declare function authHeaders(credentials: Credentials, auth?: AuthScheme): Record<string, string>;
|
|
50
43
|
/** A fetch-based transport. Redirects are NOT followed. */
|
|
51
44
|
export declare function fetchTransport(timeoutMs?: number): Transport;
|
|
@@ -16,9 +16,7 @@
|
|
|
16
16
|
* Api-Token regardless, and the mismatch surfaced as a 401 that reads like the user's
|
|
17
17
|
* credentials are wrong.
|
|
18
18
|
*/
|
|
19
|
-
import { fetchTokenTransport, } from "../../lib/central-oauth.js";
|
|
20
19
|
import { InvocationError } from "./index-loader.js";
|
|
21
|
-
import { bearerToken } from "./oauth.js";
|
|
22
20
|
/** What this server can put into a credential template. Nothing else is fillable. */
|
|
23
21
|
const PLACEHOLDERS = ["username", "access_key"];
|
|
24
22
|
/** The historical default, and what an index without an `auth` block still gets. */
|
|
@@ -50,15 +48,7 @@ export function renderTemplate(template, credentials) {
|
|
|
50
48
|
.replaceAll("{username}", credentials.username)
|
|
51
49
|
.replaceAll("{access_key}", credentials.accessKey);
|
|
52
50
|
}
|
|
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()) {
|
|
51
|
+
export function authHeaders(credentials, auth = DEFAULT_AUTH) {
|
|
62
52
|
if (!credentials?.username || !credentials?.accessKey) {
|
|
63
53
|
// Refusing here beats sending unauthenticated and surfacing the product's 401, which
|
|
64
54
|
// reads like the user's problem when it is our missing configuration.
|
|
@@ -69,19 +59,6 @@ export async function authHeaders(credentials, auth = DEFAULT_AUTH, product = "t
|
|
|
69
59
|
"request-source": "ai-chatbot",
|
|
70
60
|
"Content-Type": "application/json",
|
|
71
61
|
};
|
|
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
62
|
const value = renderTemplate(auth.template || DEFAULT_AUTH.template, credentials);
|
|
86
63
|
if (auth.type === "apiKey") {
|
|
87
64
|
// Header only. `cookie` needs a session this server does not have, and `query` would
|