@browserstack/mcp-server 2.2.0-beta.1 → 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.
Files changed (44) hide show
  1. package/README.md +42 -432
  2. package/capability/loadtesting.capability-index.json +25 -2
  3. package/capability/tm.capability-index.json +1 -2
  4. package/dist/config.d.ts +27 -2
  5. package/dist/config.js +19 -6
  6. package/dist/lib/instrumentation.js +29 -3
  7. package/dist/lib/utils.js +8 -0
  8. package/dist/tools/accessiblity-utils/scanner.js +1 -1
  9. package/dist/tools/ask-browserstack/central-oauth.d.ts +46 -25
  10. package/dist/tools/ask-browserstack/central-oauth.js +177 -55
  11. package/dist/tools/ask-browserstack/config.d.ts +0 -14
  12. package/dist/tools/ask-browserstack/config.js +0 -16
  13. package/dist/tools/ask-browserstack/deferred.d.ts +74 -0
  14. package/dist/tools/ask-browserstack/deferred.js +242 -0
  15. package/dist/tools/ask-browserstack/register.d.ts +3 -2
  16. package/dist/tools/ask-browserstack/register.js +219 -72
  17. package/dist/tools/ask-browserstack/relay.js +11 -17
  18. package/dist/tools/ask-browserstack/stream.d.ts +27 -5
  19. package/dist/tools/ask-browserstack/stream.js +25 -14
  20. package/dist/tools/ask-browserstack/types.d.ts +54 -3
  21. package/dist/tools/ask-browserstack/types.js +11 -1
  22. package/dist/tools/automate-utils/fetch-screenshots.js +17 -15
  23. package/dist/tools/capability-registry/bind.js +7 -43
  24. package/dist/tools/capability-registry/discovery.js +1 -3
  25. package/dist/tools/capability-registry/egress.d.ts +5 -12
  26. package/dist/tools/capability-registry/egress.js +1 -24
  27. package/dist/tools/capability-registry/redact.js +7 -5
  28. package/dist/tools/capability-registry/register.js +41 -289
  29. package/dist/tools/capability-registry/resolve.d.ts +1 -1
  30. package/dist/tools/capability-registry/resolve.js +3 -24
  31. package/dist/tools/capability-registry/search.d.ts +1 -28
  32. package/dist/tools/capability-registry/search.js +4 -202
  33. package/dist/tools/capability-registry/types.d.ts +3 -42
  34. package/dist/tools/testmanagement.js +5 -5
  35. package/dist/tools/tool-handoff.d.ts +12 -15
  36. package/dist/tools/tool-handoff.js +18 -21
  37. package/package.json +3 -4
  38. package/capability/tra.capability-index.json +0 -52074
  39. package/dist/lib/central-oauth.d.ts +0 -133
  40. package/dist/lib/central-oauth.js +0 -246
  41. package/dist/tools/capability-registry/oauth.d.ts +0 -63
  42. package/dist/tools/capability-registry/oauth.js +0 -176
  43. package/dist/tools/capability-registry/routing-token.d.ts +0 -25
  44. package/dist/tools/capability-registry/routing-token.js +0 -87
package/dist/config.js CHANGED
@@ -47,9 +47,9 @@ export class Config {
47
47
  O11Y_TFA_RCA_BASE_URL;
48
48
  BROWSERSTACK_AUTOMATION_BASE_URL;
49
49
  BROWSERSTACK_O11Y_UI_BASE_URL;
50
- ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
51
50
  ASK_BROWSERSTACK_ATLAS_URL;
52
51
  ASK_BROWSERSTACK_AUTH_TOKEN_URL;
52
+ ASK_BROWSERSTACK_RELAY_TRANSPORT;
53
53
  constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL,
54
54
  // askBrowserStackAI's process-startup settings. Declared here rather than read from
55
55
  // process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
@@ -58,7 +58,20 @@ export class Config {
58
58
  // ASK_BROWSERSTACK_DISABLED is deliberately NOT here: it is a kill switch, and reading
59
59
  // it per call keeps it effective without a restart. Fixing it at boot would mean a pod
60
60
  // roll to disable the tool, which is slowest exactly when you need it fastest.
61
- ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY, ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL) {
61
+ ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL,
62
+ /**
63
+ * Which relay transport to use, overriding what the deployment implies.
64
+ *
65
+ * `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
66
+ * different questions: "am I the hosted process" and "can I hold a connection open
67
+ * across a human". Only the second decides the transport, and they come apart the
68
+ * moment anyone wants to exercise the deferred, two-call flow from a local stdio
69
+ * install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
70
+ * flag by design (the hosted deployment runs remote-mcp-server instead).
71
+ *
72
+ * "deferred" | "elicit". Anything else is ignored.
73
+ */
74
+ ASK_BROWSERSTACK_RELAY_TRANSPORT) {
62
75
  this.DEV_MODE = DEV_MODE;
63
76
  this.browserstackLocalOptions = browserstackLocalOptions;
64
77
  this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
@@ -67,9 +80,9 @@ export class Config {
67
80
  this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
68
81
  this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
69
82
  this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
70
- this.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
71
83
  this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
72
84
  this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
85
+ this.ASK_BROWSERSTACK_RELAY_TRANSPORT = ASK_BROWSERSTACK_RELAY_TRANSPORT;
73
86
  }
74
87
  }
75
88
  const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
@@ -83,12 +96,12 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
83
96
  : DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
84
97
  process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
85
98
  ? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
86
- : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, (process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() ===
87
- "true", process.env.ASK_BROWSERSTACK_ATLAS_URL &&
99
+ : DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, process.env.ASK_BROWSERSTACK_ATLAS_URL &&
88
100
  process.env.ASK_BROWSERSTACK_ATLAS_URL.trim().length > 0
89
101
  ? process.env.ASK_BROWSERSTACK_ATLAS_URL
90
102
  : undefined, process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL &&
91
103
  process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
92
104
  ? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
93
- : undefined);
105
+ : undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() ||
106
+ undefined);
94
107
  export default config;
@@ -6,6 +6,7 @@ const require = createRequire(import.meta.url);
6
6
  const packageJson = require("../../package.json");
7
7
  import { apiClient } from "./apiClient.js";
8
8
  import globalConfig from "../config.js";
9
+ import { redact } from "../tools/capability-registry/redact.js";
9
10
  const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event";
10
11
  function baseProperties(toolName, clientInfo) {
11
12
  return {
@@ -25,8 +26,13 @@ function errorProperties(error) {
25
26
  function sendEvent(event, config) {
26
27
  let authHeader;
27
28
  if (config) {
28
- const authString = getBrowserStackAuth(config);
29
- authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
29
+ try {
30
+ const authString = getBrowserStackAuth(config);
31
+ authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
32
+ }
33
+ catch {
34
+ // noop
35
+ }
30
36
  }
31
37
  apiClient
32
38
  .post({
@@ -80,6 +86,19 @@ export function trackMCP(toolName, clientInfo, error, config, extras) {
80
86
  };
81
87
  sendEvent(event, config);
82
88
  }
89
+ /** The text a tool showed the user. */
90
+ function errorTextOf(result) {
91
+ const content = result?.content;
92
+ if (!Array.isArray(content))
93
+ return undefined;
94
+ const text = content
95
+ .map((part) => typeof part?.text === "string"
96
+ ? part.text
97
+ : "")
98
+ .filter(Boolean)
99
+ .join(" ");
100
+ return redact(text);
101
+ }
83
102
  function isErrorResult(result) {
84
103
  return (typeof result === "object" &&
85
104
  result !== null &&
@@ -93,10 +112,14 @@ export async function withToolCall(toolName, getClientInfo, config, fn) {
93
112
  const ctx = { toolName, clientInfo: {}, config };
94
113
  const startedAt = performance.now();
95
114
  let outcome = "ok";
115
+ let errorText;
96
116
  try {
97
117
  const result = await callContext.run(ctx, fn);
98
- if (isErrorResult(result))
118
+ if (isErrorResult(result)) {
99
119
  outcome = "error_result";
120
+ // A returned failure never sets ctx.error, so the reason is read off the result.
121
+ errorText ??= errorTextOf(result);
122
+ }
100
123
  return result;
101
124
  }
102
125
  catch (error) {
@@ -123,6 +146,9 @@ export async function withToolCall(toolName, getClientInfo, config, fn) {
123
146
  duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
124
147
  outcome,
125
148
  ...(ctx.error !== undefined ? errorProperties(ctx.error) : {}),
149
+ ...(outcome === "error_result" && ctx.error === undefined && errorText
150
+ ? { error_message: errorText }
151
+ : {}),
126
152
  ...(ctx.extras ?? {}),
127
153
  },
128
154
  };
package/dist/lib/utils.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { getBrowserStackAuth } from "./get-auth.js";
2
2
  import { trackMCP } from "../index.js";
3
3
  import logger from "../logger.js";
4
+ import globalConfig from "../config.js";
4
5
  export function sanitizeUrlParam(param) {
5
6
  // Remove any characters that could be used for command injection
6
7
  return param.replace(/[;&|`$(){}[\]<>]/g, "");
7
8
  }
8
9
  const ONE_MB = 1048576;
10
+ let sharpConfigured = false;
9
11
  //Compresses a base64 image intelligently to keep it under 1 MB if needed.
10
12
  export async function maybeCompressBase64(base64) {
11
13
  const buffer = Buffer.from(base64, "base64");
@@ -17,6 +19,12 @@ export async function maybeCompressBase64(base64) {
17
19
  const quality = Math.min(95, Math.max(30, estimatedQuality));
18
20
  try {
19
21
  const { default: sharp } = await import("sharp");
22
+ if (!sharpConfigured && globalConfig.REMOTE_MCP) {
23
+ // Bound memory on constrained hosted pods: no libvips cache, single worker thread.
24
+ sharp.cache(false);
25
+ sharp.concurrency(1);
26
+ sharpConfigured = true;
27
+ }
20
28
  const compressedBuffer = await sharp(buffer).png({ quality }).toBuffer();
21
29
  return compressedBuffer.toString("base64");
22
30
  }
@@ -74,7 +74,7 @@ export class AccessibilityScanner {
74
74
  });
75
75
  const data = response.data;
76
76
  if (!data.success)
77
- throw new Error(`Unable to start scan: ${data.errors?.join(", ")}`);
77
+ throw new Error(`Unable to start scan: ${data.errors?.join(", ") || JSON.stringify(data)}`);
78
78
  return data;
79
79
  }
80
80
  catch (err) {
@@ -1,15 +1,5 @@
1
1
  /**
2
- * Atlas's view of the central-OAuth mint.
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, mintCentralToken as mintShared } from "../../lib/central-oauth.js";
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 — measured, not assumed: the paired scope mints, and the
28
- * other half alone comes back `400 invalid_request`. `ai_agent_notify` is what Atlas matches
29
- * on (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim
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
- /** The exact form body of the `client_credentials` grant, at Atlas's scope. */
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
- * NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
96
- * process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
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: Parameters<typeof mintShared>[0]["transport"], now?: number): Promise<string>;
120
+ export declare function mintCentralToken(url: string, credentials: Credentials, transport: TokenTransport, now?: number): Promise<string>;
@@ -1,15 +1,5 @@
1
1
  /**
2
- * Atlas's view of the central-OAuth mint.
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 { CentralAuthError, mintCentralToken as mintShared, mintForm as mintFormShared, REQUESTED_EXPIRES_IN, } from "../../lib/central-oauth.js";
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 — measured, not assumed: the paired scope mints, and the
31
- * other half alone comes back `400 invalid_request`. `ai_agent_notify` is what Atlas matches
32
- * on (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim
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
- /** The exact form body of the `client_credentials` grant, at Atlas's scope. */
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 mintFormShared(credentials, CENTRAL_SCOPE, REQUESTED_EXPIRES_IN);
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
- /** Every failure kind gets Atlas's own sentence; none of them carries the response body. */
113
- function toAskError(error) {
114
- if (!(error instanceof CentralAuthError))
115
- return error;
116
- switch (error.kind) {
117
- case "no_credentials":
118
- return new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
119
- "BROWSERSTACK_ACCESS_KEY are required to sign in");
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
- * NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
136
- * process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
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
- try {
141
- return await mintShared({
142
- url,
143
- scope: CENTRAL_SCOPE,
144
- credentials,
145
- transport,
146
- refreshSkewMs: REFRESH_SKEW_MS,
147
- cacheEnabled: !appConfig.REMOTE_MCP,
148
- now,
149
- onMinted: (lifetimeMs) => logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000)),
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
- catch (error) {
153
- throw toAskError(error);
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
  }
@@ -30,20 +30,6 @@ export declare class AskError extends Error {
30
30
  * other three are on the config singleton (rules/tool-design.md).
31
31
  */
32
32
  export declare function isEnabled(): boolean;
33
- /**
34
- * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
35
- *
36
- * OFF BY DEFAULT, because it depends on something outside this package: the host has to
37
- * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
38
- * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
39
- * that never asked anything, leaving the real one suspended until it times out. So this
40
- * must stay opt-in per deployment rather than become a default that silently hangs.
41
- *
42
- * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
43
- * declared the `elicitation` capability, and a client that did not still gets a read-only
44
- * run. This flag only removes the blanket refusal.
45
- */
46
- export declare function allowRemoteRelay(): boolean;
47
33
  /**
48
34
  * ============================================================================
49
35
  * PRODUCTION DEFAULTS
@@ -34,22 +34,6 @@ export class AskError extends Error {
34
34
  export function isEnabled() {
35
35
  return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true";
36
36
  }
37
- /**
38
- * May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
39
- *
40
- * OFF BY DEFAULT, because it depends on something outside this package: the host has to
41
- * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
42
- * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
43
- * that never asked anything, leaving the real one suspended until it times out. So this
44
- * must stay opt-in per deployment rather than become a default that silently hangs.
45
- *
46
- * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
47
- * declared the `elicitation` capability, and a client that did not still gets a read-only
48
- * run. This flag only removes the blanket refusal.
49
- */
50
- export function allowRemoteRelay() {
51
- return appConfig.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
52
- }
53
37
  /**
54
38
  * ============================================================================
55
39
  * PRODUCTION DEFAULTS