@browserstack/mcp-server 2.0.0 → 2.1.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/dist/config.d.ts +27 -2
- package/dist/config.js +19 -6
- package/dist/lib/utils.js +8 -0
- package/dist/tools/ask-browserstack/config.d.ts +0 -14
- package/dist/tools/ask-browserstack/config.js +0 -16
- package/dist/tools/ask-browserstack/deferred.d.ts +74 -0
- package/dist/tools/ask-browserstack/deferred.js +242 -0
- package/dist/tools/ask-browserstack/register.d.ts +3 -2
- package/dist/tools/ask-browserstack/register.js +219 -72
- package/dist/tools/ask-browserstack/relay.js +11 -17
- package/dist/tools/ask-browserstack/stream.d.ts +27 -5
- package/dist/tools/ask-browserstack/stream.js +25 -14
- package/dist/tools/ask-browserstack/types.d.ts +54 -3
- package/dist/tools/ask-browserstack/types.js +11 -1
- package/dist/tools/automate-utils/fetch-screenshots.js +17 -15
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -11,10 +11,35 @@ export declare class Config {
|
|
|
11
11
|
readonly O11Y_TFA_RCA_BASE_URL: string;
|
|
12
12
|
readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
|
|
13
13
|
readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
|
|
14
|
-
readonly ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean;
|
|
15
14
|
readonly ASK_BROWSERSTACK_ATLAS_URL: string | undefined;
|
|
16
15
|
readonly ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined;
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Which relay transport to use, overriding what the deployment implies.
|
|
18
|
+
*
|
|
19
|
+
* `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
|
|
20
|
+
* different questions: "am I the hosted process" and "can I hold a connection open
|
|
21
|
+
* across a human". Only the second decides the transport, and they come apart the
|
|
22
|
+
* moment anyone wants to exercise the deferred, two-call flow from a local stdio
|
|
23
|
+
* install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
|
|
24
|
+
* flag by design (the hosted deployment runs remote-mcp-server instead).
|
|
25
|
+
*
|
|
26
|
+
* "deferred" | "elicit". Anything else is ignored.
|
|
27
|
+
*/
|
|
28
|
+
readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined;
|
|
29
|
+
constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined,
|
|
30
|
+
/**
|
|
31
|
+
* Which relay transport to use, overriding what the deployment implies.
|
|
32
|
+
*
|
|
33
|
+
* `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
|
|
34
|
+
* different questions: "am I the hosted process" and "can I hold a connection open
|
|
35
|
+
* across a human". Only the second decides the transport, and they come apart the
|
|
36
|
+
* moment anyone wants to exercise the deferred, two-call flow from a local stdio
|
|
37
|
+
* install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
|
|
38
|
+
* flag by design (the hosted deployment runs remote-mcp-server instead).
|
|
39
|
+
*
|
|
40
|
+
* "deferred" | "elicit". Anything else is ignored.
|
|
41
|
+
*/
|
|
42
|
+
ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined);
|
|
18
43
|
}
|
|
19
44
|
declare const config: Config;
|
|
20
45
|
export default config;
|
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
|
-
|
|
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,
|
|
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;
|
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
|
}
|
|
@@ -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
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-call shape: ask now, decide later, with nothing held open in between.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
|
|
5
|
+
* `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
|
|
6
|
+
* the hosted deployment needs sessions, ingress affinity keyed on the caller's
|
|
7
|
+
* credentials, and a cap on how many sessions may be alive. Deferred removes the
|
|
8
|
+
* suspension rather than routing around it: Atlas returns at its first ask, we hand that
|
|
9
|
+
* ask back, and the caller returns with the decision in a SECOND call. Between the two
|
|
10
|
+
* this process holds nothing, so any pod can serve either.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT COSTS, stated plainly because it is not free:
|
|
13
|
+
* - Approval rides the CLIENT's own permission prompt on the second call, not an
|
|
14
|
+
* elicitation. A client configured not to prompt (`--permission-mode auto`) approves
|
|
15
|
+
* with no human. Elicitation fails closed by construction; this fails closed only if
|
|
16
|
+
* the client asks. That is the trade, and it is why stdio keeps elicitation.
|
|
17
|
+
* - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
|
|
18
|
+
* with five writes is five approvals and six calls.
|
|
19
|
+
*
|
|
20
|
+
* The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
|
|
21
|
+
* a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
|
|
22
|
+
*/
|
|
23
|
+
import { DeferredTransport } from "./stream.js";
|
|
24
|
+
import { AgentRequest, AskResult, RelayMode } from "./types.js";
|
|
25
|
+
/** What a parked run needs the caller to send back. */
|
|
26
|
+
export interface ParkedAsk {
|
|
27
|
+
perm_id: string;
|
|
28
|
+
description: string;
|
|
29
|
+
product: string;
|
|
30
|
+
mode: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Atlas's `needs_approval` payload → our result, or `null` when it is not one.
|
|
34
|
+
*
|
|
35
|
+
* Null rather than a throw: a deferred request whose run never needed approval comes
|
|
36
|
+
* back as an ORDINARY result, and that is a success to pass straight through — not an
|
|
37
|
+
* unexpected shape. A read-only task must cost one call, not two.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parkedResult(body: unknown, product: string): {
|
|
40
|
+
runId: string;
|
|
41
|
+
ask: ParkedAsk;
|
|
42
|
+
} | null;
|
|
43
|
+
/** CONTRACT §5's shape, for a run that is parked rather than finished. */
|
|
44
|
+
export declare function needsApprovalResult(runId: string, ask: ParkedAsk, product: string): AskResult;
|
|
45
|
+
/** The FIRST call: run until the first ask, then answer. Nothing is held open. */
|
|
46
|
+
export declare function runDeferred(url: string, headers: Record<string, string>, body: AgentRequest, transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
|
|
47
|
+
/**
|
|
48
|
+
* The SECOND call: deliver the decision, then pick the run back up.
|
|
49
|
+
*
|
|
50
|
+
* Two requests, in this order, and the order is the safety property: the decision is
|
|
51
|
+
* posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
|
|
52
|
+
* answer we already have. A lost reattach costs the caller the answer; a lost decision
|
|
53
|
+
* would cost them the run.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runResumed(agentUrl: string, headers: Record<string, string>, runId: string, permId: string, decision: "allow" | "deny", transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
|
|
56
|
+
/** A resume that did not happen. Every field says "nothing changed", because in each
|
|
57
|
+
* case nothing did — and a caller that cannot tell a failed resume from a refusal
|
|
58
|
+
* either retries a write that landed or abandons one that never did. */
|
|
59
|
+
export declare function resumeFailed(error: string): AskResult;
|
|
60
|
+
/** A decision that went out and whose reply never came back.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
|
|
63
|
+
* is a claim this case cannot make. The decision request carries the continuation now,
|
|
64
|
+
* so a lost reply covers everything from "it never arrived" to "the write landed and
|
|
65
|
+
* the answer was lost" — and telling a model nothing happened is what gets the change
|
|
66
|
+
* made twice.
|
|
67
|
+
*
|
|
68
|
+
* `applied_before_stop: null` for the same reason: null is "not measured", which is
|
|
69
|
+
* true, where false would be a measurement nobody took. */
|
|
70
|
+
export declare function decisionUnknown(): AskResult;
|
|
71
|
+
/** Starting a run with no task. Only reachable now that `query` is optional, which it
|
|
72
|
+
* has to be so a model can follow `next_step` — which omits it — literally. */
|
|
73
|
+
export declare function missingQuery(): AskResult;
|
|
74
|
+
export declare function incompleteResume(): AskResult;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-call shape: ask now, decide later, with nothing held open in between.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
|
|
5
|
+
* `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
|
|
6
|
+
* the hosted deployment needs sessions, ingress affinity keyed on the caller's
|
|
7
|
+
* credentials, and a cap on how many sessions may be alive. Deferred removes the
|
|
8
|
+
* suspension rather than routing around it: Atlas returns at its first ask, we hand that
|
|
9
|
+
* ask back, and the caller returns with the decision in a SECOND call. Between the two
|
|
10
|
+
* this process holds nothing, so any pod can serve either.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT COSTS, stated plainly because it is not free:
|
|
13
|
+
* - Approval rides the CLIENT's own permission prompt on the second call, not an
|
|
14
|
+
* elicitation. A client configured not to prompt (`--permission-mode auto`) approves
|
|
15
|
+
* with no human. Elicitation fails closed by construction; this fails closed only if
|
|
16
|
+
* the client asks. That is the trade, and it is why stdio keeps elicitation.
|
|
17
|
+
* - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
|
|
18
|
+
* with five writes is five approvals and six calls.
|
|
19
|
+
*
|
|
20
|
+
* The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
|
|
21
|
+
* a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
|
|
22
|
+
*/
|
|
23
|
+
import logger from "../../logger.js";
|
|
24
|
+
import { RELAY_CONTRACT, decisionUrl, parseAsk, } from "./stream.js";
|
|
25
|
+
import { RELAY_OFF_DETAILS, buildResult } from "./relay.js";
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
28
|
+
? value
|
|
29
|
+
: {};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Atlas's `needs_approval` payload → our result, or `null` when it is not one.
|
|
33
|
+
*
|
|
34
|
+
* Null rather than a throw: a deferred request whose run never needed approval comes
|
|
35
|
+
* back as an ORDINARY result, and that is a success to pass straight through — not an
|
|
36
|
+
* unexpected shape. A read-only task must cost one call, not two.
|
|
37
|
+
*/
|
|
38
|
+
export function parkedResult(body, product) {
|
|
39
|
+
const payload = asRecord(body);
|
|
40
|
+
if (payload.status !== "needs_approval")
|
|
41
|
+
return null;
|
|
42
|
+
const runId = String(payload.run_id || "");
|
|
43
|
+
const asks = Array.isArray(payload.asks) ? payload.asks : [];
|
|
44
|
+
// Validated, not cast, for the same reason the streaming path validates: an ask with
|
|
45
|
+
// no usable `perm_id` cannot be answered, so handing it to a caller would invite a
|
|
46
|
+
// second call that can only 404.
|
|
47
|
+
const ask = asks.length ? parseAsk(asks[0]) : null;
|
|
48
|
+
if (!runId || !ask) {
|
|
49
|
+
logger.error("askBrowserStackAI: deferred run parked without an answerable ask; treating as a plain result");
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
runId,
|
|
54
|
+
ask: {
|
|
55
|
+
perm_id: ask.perm_id,
|
|
56
|
+
description: ask.description,
|
|
57
|
+
product: ask.product || product,
|
|
58
|
+
mode: ask.mode,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** CONTRACT §5's shape, for a run that is parked rather than finished. */
|
|
63
|
+
export function needsApprovalResult(runId, ask, product) {
|
|
64
|
+
return {
|
|
65
|
+
// NOT ok, and not an error either: nothing was applied and nothing failed. A caller
|
|
66
|
+
// that branches on `ok` alone must not read a parked run as a finished one.
|
|
67
|
+
ok: false,
|
|
68
|
+
status: "needs_approval",
|
|
69
|
+
// Addressed to the MODEL, and imperative, because the model is what reads it and
|
|
70
|
+
// the one thing that must happen next is not something it can do by itself.
|
|
71
|
+
answer: `A HUMAN MUST APPROVE THIS BEFORE IT CAN CONTINUE. BrowserStack AI stopped at: ` +
|
|
72
|
+
`${ask.description}. Nothing has been changed yet.\n\n` +
|
|
73
|
+
`Put this to the user and WAIT for their answer. If you have a way to ask them a ` +
|
|
74
|
+
`structured question or show a confirmation — any prompt, question or approval ` +
|
|
75
|
+
`affordance your client offers — use that; otherwise ask plainly in your reply. ` +
|
|
76
|
+
`Quote what is being done either way.\n\n` +
|
|
77
|
+
`Do NOT decide on their behalf, and do not treat "it is what they asked for" as ` +
|
|
78
|
+
`approval: they are being asked to confirm this specific change, which is a ` +
|
|
79
|
+
`different question. Once they have answered, follow \`next_step\`.`,
|
|
80
|
+
approvals: [],
|
|
81
|
+
approvals_source: "atlas",
|
|
82
|
+
elicitations: [],
|
|
83
|
+
needs_approval: [{ description: ask.description, mode: ask.mode }],
|
|
84
|
+
applied_before_stop: false,
|
|
85
|
+
permission_relay: {
|
|
86
|
+
used: true,
|
|
87
|
+
reason: "",
|
|
88
|
+
// Terse on purpose: the actionable half lives in `next_step`, and an explanation
|
|
89
|
+
// of the transport here is what the model read instead of the instruction.
|
|
90
|
+
detail: "Approval is pending a human answer; see `next_step`.",
|
|
91
|
+
},
|
|
92
|
+
atlas_response: null,
|
|
93
|
+
run_id: runId,
|
|
94
|
+
perm_id: ask.perm_id,
|
|
95
|
+
next_step: {
|
|
96
|
+
instruction:
|
|
97
|
+
// Deliberately names no tool and no client. The affordance differs everywhere
|
|
98
|
+
// — a structured question, a confirmation dialog, an approval prompt, or
|
|
99
|
+
// nothing at all — and naming one would be wrong on every other client and
|
|
100
|
+
// would rot as clients change. Describe the ACT, let the model pick the means.
|
|
101
|
+
"AFTER the user has answered — not before — call askBrowserStackAI again " +
|
|
102
|
+
"with exactly these params, setting `decision` to what THEY said. If they " +
|
|
103
|
+
'declined, send "deny": that is a valid answer and stops the task cleanly. ' +
|
|
104
|
+
"If they have not answered yet, do not call anything.",
|
|
105
|
+
call: "askBrowserStackAI",
|
|
106
|
+
params: {
|
|
107
|
+
product,
|
|
108
|
+
run_id: runId,
|
|
109
|
+
perm_id: ask.perm_id,
|
|
110
|
+
decision: "allow | deny — the user's answer, not your own judgement",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The FIRST call: run until the first ask, then answer. Nothing is held open. */
|
|
116
|
+
export async function runDeferred(url, headers, body, transport, mode, product) {
|
|
117
|
+
const response = await transport(url, headers, {
|
|
118
|
+
...body,
|
|
119
|
+
permission_relay: { mode: "deferred", contract: RELAY_CONTRACT },
|
|
120
|
+
});
|
|
121
|
+
const parked = parkedResult(response.body, product);
|
|
122
|
+
if (parked) {
|
|
123
|
+
logger.info("askBrowserStackAI: run parked awaiting approval (run=%s)", parked.runId);
|
|
124
|
+
return needsApprovalResult(parked.runId, parked.ask, product);
|
|
125
|
+
}
|
|
126
|
+
// No ask: an ordinary result, passed through the same builder the streaming path
|
|
127
|
+
// uses so an entitlement refusal, a 401 and a plain failure all read identically
|
|
128
|
+
// whichever transport produced them.
|
|
129
|
+
return buildResult(response, [], mode, product);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The SECOND call: deliver the decision, then pick the run back up.
|
|
133
|
+
*
|
|
134
|
+
* Two requests, in this order, and the order is the safety property: the decision is
|
|
135
|
+
* posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
|
|
136
|
+
* answer we already have. A lost reattach costs the caller the answer; a lost decision
|
|
137
|
+
* would cost them the run.
|
|
138
|
+
*/
|
|
139
|
+
export async function runResumed(agentUrl, headers, runId, permId, decision, transport, mode, product) {
|
|
140
|
+
// ONE CALL. This used to POST the decision, check for a 204, and then reattach to
|
|
141
|
+
// `GET /agent/{run_id}/stream` to collect what the run did next — two requests, and a
|
|
142
|
+
// reattach that only existed because the run was a paused coroutine in one pod. The
|
|
143
|
+
// ask parks in Atlas's checkpoint now, so the decision request IS the one that carries
|
|
144
|
+
// the run forward and the continuation comes back in its own reply, from any replica.
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await transport(decisionUrl(agentUrl, runId), headers, {
|
|
148
|
+
perm_id: permId,
|
|
149
|
+
decision,
|
|
150
|
+
reason: "",
|
|
151
|
+
// Required. The run resumes on a fresh Agent that must be scoped before it can be
|
|
152
|
+
// built, and Atlas deliberately will not take that scope from persisted state.
|
|
153
|
+
product,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// CAUGHT HERE rather than left to the handler's outer catch, which cannot tell this
|
|
158
|
+
// apart from a request that never left and so reports `not_reached` — "nothing was
|
|
159
|
+
// changed" — for a decision that may well have completed.
|
|
160
|
+
logger.warn("askBrowserStackAI: no reply to the decision on run %s; outcome unknown", runId);
|
|
161
|
+
return decisionUnknown();
|
|
162
|
+
}
|
|
163
|
+
if (response.status === 404) {
|
|
164
|
+
// The only failure worth its own words: there is no such parked run. It expired, it
|
|
165
|
+
// was already answered, or this is not the caller that owns it. None of those is an
|
|
166
|
+
// approval, and none of them changed anything.
|
|
167
|
+
return resumeFailed(`That approval could not be applied (HTTP 404). The run may have expired — ` +
|
|
168
|
+
`BrowserStack AI stops waiting after five minutes — or it may already have ` +
|
|
169
|
+
`been answered. Nothing was changed by this call. Start the task again if it ` +
|
|
170
|
+
`still needs doing.`);
|
|
171
|
+
}
|
|
172
|
+
// PARKED AGAIN: the task needs more than one approval. Handed straight back rather
|
|
173
|
+
// than answered here, because the whole point is that a human decides each one.
|
|
174
|
+
const parked = parkedResult(response.body, product);
|
|
175
|
+
if (parked) {
|
|
176
|
+
logger.info("askBrowserStackAI: run parked again after a decision (run=%s)", runId);
|
|
177
|
+
return needsApprovalResult(parked.runId, parked.ask, product);
|
|
178
|
+
}
|
|
179
|
+
// Finished. Through the SAME builder the start path uses, so an entitlement refusal,
|
|
180
|
+
// a 401 and a plain failure read identically whichever call produced them.
|
|
181
|
+
return buildResult(response, [], mode, product);
|
|
182
|
+
}
|
|
183
|
+
/** A resume that did not happen. Every field says "nothing changed", because in each
|
|
184
|
+
* case nothing did — and a caller that cannot tell a failed resume from a refusal
|
|
185
|
+
* either retries a write that landed or abandons one that never did. */
|
|
186
|
+
export function resumeFailed(error) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
status: "error",
|
|
190
|
+
answer: null,
|
|
191
|
+
approvals: [],
|
|
192
|
+
approvals_source: "mcp",
|
|
193
|
+
elicitations: [],
|
|
194
|
+
needs_approval: [],
|
|
195
|
+
applied_before_stop: false,
|
|
196
|
+
permission_relay: { used: true, reason: "", detail: "" },
|
|
197
|
+
atlas_response: null,
|
|
198
|
+
error,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** A decision that went out and whose reply never came back.
|
|
202
|
+
*
|
|
203
|
+
* Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
|
|
204
|
+
* is a claim this case cannot make. The decision request carries the continuation now,
|
|
205
|
+
* so a lost reply covers everything from "it never arrived" to "the write landed and
|
|
206
|
+
* the answer was lost" — and telling a model nothing happened is what gets the change
|
|
207
|
+
* made twice.
|
|
208
|
+
*
|
|
209
|
+
* `applied_before_stop: null` for the same reason: null is "not measured", which is
|
|
210
|
+
* true, where false would be a measurement nobody took. */
|
|
211
|
+
export function decisionUnknown() {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
status: "error",
|
|
215
|
+
answer: null,
|
|
216
|
+
approvals: [],
|
|
217
|
+
approvals_source: "mcp",
|
|
218
|
+
elicitations: [],
|
|
219
|
+
needs_approval: [],
|
|
220
|
+
applied_before_stop: null,
|
|
221
|
+
permission_relay: {
|
|
222
|
+
used: true,
|
|
223
|
+
reason: "outcome_unknown",
|
|
224
|
+
detail: RELAY_OFF_DETAILS.outcome_unknown,
|
|
225
|
+
},
|
|
226
|
+
atlas_response: null,
|
|
227
|
+
error: "The approval was sent to BrowserStack AI but no reply came back, so it is not " +
|
|
228
|
+
"known whether the step completed. Nothing should be retried on this run.",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Starting a run with no task. Only reachable now that `query` is optional, which it
|
|
232
|
+
* has to be so a model can follow `next_step` — which omits it — literally. */
|
|
233
|
+
export function missingQuery() {
|
|
234
|
+
return resumeFailed("`query` is required to start a run: say what you want in plain language. " +
|
|
235
|
+
"(It is only omitted when answering a pending approval, which also needs " +
|
|
236
|
+
"`run_id`, `perm_id` and `decision`.) Nothing was sent, so nothing changed.");
|
|
237
|
+
}
|
|
238
|
+
export function incompleteResume() {
|
|
239
|
+
return resumeFailed("Resuming needs all three of `run_id`, `perm_id` and `decision`. Nothing was " +
|
|
240
|
+
"sent, so nothing changed and the pending approval is still waiting — ask the " +
|
|
241
|
+
"user whether to allow it and call again with all three.");
|
|
242
|
+
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
27
|
import { BrowserStackConfig } from "../../lib/types.js";
|
|
28
28
|
import { Credentials } from "./egress.js";
|
|
29
|
-
import type { AgentStreamTransport,
|
|
29
|
+
import type { AgentStreamTransport, DeferredTransport } from "./stream.js";
|
|
30
30
|
import { RelayMode } from "./types.js";
|
|
31
31
|
export interface AskDeps {
|
|
32
32
|
/** Resolved per call: a deployment's host is configuration, not a constructor argument. */
|
|
@@ -52,7 +52,8 @@ export interface AskDeps {
|
|
|
52
52
|
* — ask, elicit, decide, result — without a socket.
|
|
53
53
|
*/
|
|
54
54
|
streamTransport?: AgentStreamTransport;
|
|
55
|
-
|
|
55
|
+
/** The deferred START (one request, one JSON response). Substituted in tests. */
|
|
56
|
+
deferredTransport?: DeferredTransport;
|
|
56
57
|
}
|
|
57
58
|
export declare function relayMode(server: McpServer): RelayMode;
|
|
58
59
|
export declare function addAskBrowserStackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
|
|
@@ -28,10 +28,11 @@ import { z } from "zod";
|
|
|
28
28
|
import appConfig from "../../config.js";
|
|
29
29
|
import { trackMCP } from "../../lib/instrumentation.js";
|
|
30
30
|
import logger from "../../logger.js";
|
|
31
|
-
import { AskError, ELICITATION_TIMEOUT_MS, agentUrl,
|
|
31
|
+
import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, authTokenUrl, isEnabled, } from "./config.js";
|
|
32
32
|
import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js";
|
|
33
33
|
import { agentHeaders } from "./egress.js";
|
|
34
|
-
import { EVENT_PERMISSION, EVENT_RESULT, EVENT_RUN, decisionUrl, fetchAgentStreamTransport,
|
|
34
|
+
import { EVENT_PERMISSION, EVENT_RESULT, EVENT_RUN, decisionUrl, RELAY_CONTRACT, fetchAgentStreamTransport, fetchDeferredTransport, parseAsk, } from "./stream.js";
|
|
35
|
+
import { decisionUnknown, incompleteResume, missingQuery, parkedResult, resumeFailed, runDeferred, runResumed, } from "./deferred.js";
|
|
35
36
|
import { buildResult, decide, elicitationMessage, elicitationShape, errorResult, } from "./relay.js";
|
|
36
37
|
import { PRODUCTS, } from "./types.js";
|
|
37
38
|
/**
|
|
@@ -61,9 +62,10 @@ const DESCRIPTION =
|
|
|
61
62
|
"predictable than handing the job to an agent. " +
|
|
62
63
|
"Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
|
|
63
64
|
"which calls to make, then returns its answer plus the steps it took. Anything that would " +
|
|
64
|
-
"change data pauses and asks you to confirm it first
|
|
65
|
-
"
|
|
66
|
-
"
|
|
65
|
+
"change data pauses and asks you to confirm it first; deletes are refused outright. " +
|
|
66
|
+
"Put that confirmation to the user and answer with what THEY say — never decide on " +
|
|
67
|
+
"their behalf. If it cannot be put to anyone, the run is read-only and everything it " +
|
|
68
|
+
"wanted to change comes back in `needs_approval` instead. One task per call.";
|
|
67
69
|
/**
|
|
68
70
|
* `isError` marks a call that FAILED, not one that was refused.
|
|
69
71
|
*
|
|
@@ -161,36 +163,6 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
|
|
|
161
163
|
approvals.push({ description: ask.description, decision, reason });
|
|
162
164
|
return { perm_id: ask.perm_id, decision, reason };
|
|
163
165
|
}
|
|
164
|
-
/**
|
|
165
|
-
* Decide whether to offer the approval channel at all.
|
|
166
|
-
*
|
|
167
|
-
* STDIO ALWAYS. HOSTED ONLY WHEN ITS OPERATOR OPTS IN — and the reason is a property of
|
|
168
|
-
* the HOST, not of this tool.
|
|
169
|
-
*
|
|
170
|
-
* Elicitation is a SERVER-INITIATED message whose answer arrives on a SEPARATE POST. A
|
|
171
|
-
* stateless host builds a fresh `McpServer` per POST, so that answer reaches an instance
|
|
172
|
-
* which never asked anything, while the one actually suspended on `await` waits out its
|
|
173
|
-
* timeout. Nothing in this package can fix that; what it holds is a live Promise resolver
|
|
174
|
-
* and a paused function in the host's heap, and a paused call cannot be moved.
|
|
175
|
-
*
|
|
176
|
-
* This is why `841c6358` was right to remove sessions from the hosted server on the
|
|
177
|
-
* grounds that "we use neither server-initiated messages nor subscriptions/sampling" —
|
|
178
|
-
* this feature is the exception that commit did not have to consider.
|
|
179
|
-
*
|
|
180
|
-
* MEASURED, not assumed: with the host keeping one server per session
|
|
181
|
-
* (browserstack/remote-mcp-server#96), a tool call and its elicitation answer were served
|
|
182
|
-
* by the same instance over hosted Streamable HTTP, and the relay completed. So the
|
|
183
|
-
* refusal below is now conditional rather than absolute.
|
|
184
|
-
*
|
|
185
|
-
* It stays OFF by default because it depends on a deployment property this package cannot
|
|
186
|
-
* observe. A hosted operator turns it on only once their host keeps sessions AND pins a
|
|
187
|
-
* session to a pod — sessions are per-process, so without affinity the answer POST can
|
|
188
|
-
* land on a replica that has never seen it. That failure is intermittent and reads like a
|
|
189
|
-
* client bug, which is exactly why it must not be the default.
|
|
190
|
-
*
|
|
191
|
-
* When refused, Atlas runs read-only — a supported path that already works — and
|
|
192
|
-
* `permission_relay.reason` says `remote_mode` so nobody mistakes it for a human's no.
|
|
193
|
-
*/
|
|
194
166
|
/**
|
|
195
167
|
* CONTRACT v2 (A1) — drive one run over the stream.
|
|
196
168
|
*
|
|
@@ -208,13 +180,90 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
|
|
|
208
180
|
* simply stops is indistinguishable from a network drop, and reporting it as a finished
|
|
209
181
|
* run with no answer would be the transport quietly speaking for the agent.
|
|
210
182
|
*/
|
|
211
|
-
async function runStreamed(server, streamTransport,
|
|
183
|
+
async function runStreamed(server, streamTransport, postTransport, url, headers, body, approvals, mode, product, relatedRequestId) {
|
|
212
184
|
let runId = "";
|
|
213
185
|
let result;
|
|
214
186
|
let sawResult = false;
|
|
215
187
|
// 200 unless the reply was not a stream at all, in which case the transport carries
|
|
216
188
|
// the real status — `relay.ts` needs it to tell 401 from 403 from a plain failure.
|
|
217
189
|
let resultStatus = 200;
|
|
190
|
+
let pending = null;
|
|
191
|
+
// Set once a decision reply carries the run, which only a converged Atlas does. When
|
|
192
|
+
// it is set it is the authoritative outcome and the stream's own `result` is stale.
|
|
193
|
+
let carried = null;
|
|
194
|
+
/**
|
|
195
|
+
* Put ONE ask to the human and deliver their answer. Shared by both loops so the two
|
|
196
|
+
* servers cannot drift apart in how an approval is asked for or recorded.
|
|
197
|
+
*
|
|
198
|
+
* Returns exactly one of:
|
|
199
|
+
* `terminal` — stop, with this result (a lost reply, or a run that is not there).
|
|
200
|
+
* `carried` — the reply contained the run: `next` is the following ask, if any.
|
|
201
|
+
* neither — a bare 204; this server continues on the stream.
|
|
202
|
+
*/
|
|
203
|
+
const answerAsk = async (ask) => {
|
|
204
|
+
// `relayOneAsk` RETHROWS on an unexpected elicitation failure. Letting it escape
|
|
205
|
+
// would abandon the run and leave Atlas parked until its gate expires — turning a
|
|
206
|
+
// client hiccup into a five-minute stall — so it is caught and converted into the
|
|
207
|
+
// explicit deny the throw used to imply. `relayOneAsk` has already recorded the
|
|
208
|
+
// approvals entry, so only the wire decision is missing.
|
|
209
|
+
let decision;
|
|
210
|
+
try {
|
|
211
|
+
decision = await relayOneAsk(server, ask, approvals, relatedRequestId);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
logger.warn("askBrowserStackAI: elicitation failed, denying explicitly: %s", error instanceof Error ? error.message : String(error));
|
|
215
|
+
decision = { perm_id: ask.perm_id, decision: "deny", reason: "error" };
|
|
216
|
+
}
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await postTransport(decisionUrl(url, runId), headers, {
|
|
220
|
+
perm_id: decision.perm_id,
|
|
221
|
+
decision: decision.decision,
|
|
222
|
+
reason: decision.reason || "",
|
|
223
|
+
product,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// This request can carry the continuation, so a lost reply is NOT "nothing
|
|
228
|
+
// happened". Letting it reach the outer catch would report `not_reached` — which
|
|
229
|
+
// says nothing was changed — for a write that may well have landed.
|
|
230
|
+
logger.warn("askBrowserStackAI: no reply to the decision on run %s; outcome unknown", runId);
|
|
231
|
+
return { terminal: decisionUnknown() };
|
|
232
|
+
}
|
|
233
|
+
if (response.status === 404) {
|
|
234
|
+
// No such parked run: expired, already answered, or not ours. None of those is
|
|
235
|
+
// an approval and none of them changed anything — but a denial we could not
|
|
236
|
+
// deliver is not a denial either, so say which.
|
|
237
|
+
return {
|
|
238
|
+
terminal: resumeFailed("That approval could not be applied (HTTP 404). The run may have expired — " +
|
|
239
|
+
"BrowserStack AI stops waiting after five minutes — or it may already " +
|
|
240
|
+
"have been answered. Nothing was changed beyond any step you already " +
|
|
241
|
+
"approved."),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// 204 MEANS AN OLDER SERVER: it acknowledged the decision and is continuing on the
|
|
245
|
+
// stream, so there is no continuation to read here and nothing to supersede.
|
|
246
|
+
if (response.status === 204)
|
|
247
|
+
return {};
|
|
248
|
+
const parked = parkedResult(response.body, product);
|
|
249
|
+
return {
|
|
250
|
+
carried: true,
|
|
251
|
+
response,
|
|
252
|
+
// Another ask: the task needs more than one approval, and each is a separate
|
|
253
|
+
// question for the human.
|
|
254
|
+
next: parked
|
|
255
|
+
? {
|
|
256
|
+
perm_id: parked.ask.perm_id,
|
|
257
|
+
description: parked.ask.description,
|
|
258
|
+
product: parked.ask.product,
|
|
259
|
+
mode: parked.ask.mode,
|
|
260
|
+
}
|
|
261
|
+
: null,
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
// PHASE 1 — the start, streamed. Kept a stream rather than folded into the decision
|
|
265
|
+
// loop below because a long turn needs the heartbeats: a proxy that gives up
|
|
266
|
+
// mid-thinking looks exactly like a hang.
|
|
218
267
|
for await (const event of streamTransport(url, headers, body)) {
|
|
219
268
|
if (event.event === EVENT_RUN) {
|
|
220
269
|
runId = String(event.data?.run_id || "");
|
|
@@ -243,34 +292,55 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
|
|
|
243
292
|
logger.error("askBrowserStackAI: permission ask arrived before run_id; cannot answer");
|
|
244
293
|
continue;
|
|
245
294
|
}
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
295
|
+
// ANSWERED HERE, ON THE STREAM, and that placement is a compatibility decision
|
|
296
|
+
// rather than a preference.
|
|
297
|
+
//
|
|
298
|
+
// A converged Atlas emits its asks as the stream ENDS and carries the run forward
|
|
299
|
+
// on the decision request. An Atlas that predates that holds the stream OPEN
|
|
300
|
+
// waiting for the decision to arrive, and acknowledges it with a bare 204 before
|
|
301
|
+
// continuing on the same socket. Collecting asks and answering only after the
|
|
302
|
+
// stream closed worked against the first and DEADLOCKED against the second: both
|
|
303
|
+
// sides waited, until the server's own five-minute gate denied the ask and the
|
|
304
|
+
// user got a refusal nobody made.
|
|
305
|
+
//
|
|
306
|
+
// Answering as each ask arrives works against both, and the reply says which one
|
|
307
|
+
// we are talking to: a 204 carries nothing, so the run continues on the stream,
|
|
308
|
+
// while anything else IS the continuation and the stream is already over. That
|
|
309
|
+
// removes the deploy ordering constraint in both directions — this client is safe
|
|
310
|
+
// to publish before or after the server changes.
|
|
311
|
+
const answered = await answerAsk(ask);
|
|
312
|
+
if (answered.terminal)
|
|
313
|
+
return answered.terminal;
|
|
314
|
+
if (answered.carried) {
|
|
315
|
+
// The reply carried the run: a converged Atlas. Anything still on the stream is
|
|
316
|
+
// the tail of a response that has already been superseded by this one.
|
|
317
|
+
pending = answered.next ?? null;
|
|
318
|
+
carried = answered.response ?? null;
|
|
319
|
+
break;
|
|
260
320
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
321
|
+
// A bare 204: this server continues on the stream, so keep reading it.
|
|
322
|
+
}
|
|
323
|
+
// PHASE 2 — only reachable against a converged Atlas, where the decision reply IS the
|
|
324
|
+
// run. Each further ask comes back in a reply rather than on the stream, so the
|
|
325
|
+
// prompt-and-post pair repeats here instead of in the loop above.
|
|
326
|
+
while (pending) {
|
|
327
|
+
const answered = await answerAsk(pending);
|
|
328
|
+
if (answered.terminal)
|
|
329
|
+
return answered.terminal;
|
|
330
|
+
if (!answered.carried) {
|
|
331
|
+
// A 204 cannot arise here: only a converged Atlas reaches phase 2, and it always
|
|
332
|
+
// replies with the continuation. Treated as a run that ended without saying
|
|
333
|
+
// anything rather than assumed away.
|
|
334
|
+
break;
|
|
272
335
|
}
|
|
336
|
+
pending = answered.next ?? null;
|
|
337
|
+
carried = answered.response ?? null;
|
|
273
338
|
}
|
|
339
|
+
// The decision reply supersedes the stream's own `result`, which said "needs
|
|
340
|
+
// approval" — true when it was written, and the question it asked has since been
|
|
341
|
+
// answered.
|
|
342
|
+
if (carried)
|
|
343
|
+
return buildResult(carried, approvals, mode, product);
|
|
274
344
|
if (!sawResult) {
|
|
275
345
|
return errorResult("BrowserStack AI ended the run without a result. Nothing was changed " +
|
|
276
346
|
"beyond any step you already approved.", approvals);
|
|
@@ -280,13 +350,35 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
|
|
|
280
350
|
return buildResult({ status: resultStatus, body: result }, approvals, mode, product);
|
|
281
351
|
}
|
|
282
352
|
export function relayMode(server) {
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
353
|
+
// An explicit transport wins over whatever the deployment implies. `REMOTE_MCP`
|
|
354
|
+
// cannot select `deferred` from a local stdio install — that flag makes this
|
|
355
|
+
// entrypoint exit, because the hosted deployment runs remote-mcp-server instead — so
|
|
356
|
+
// without this there is no way to exercise the two-call flow anywhere it can be
|
|
357
|
+
// watched. "elicit" forces the other direction for the same reason.
|
|
358
|
+
const forced = appConfig.ASK_BROWSERSTACK_RELAY_TRANSPORT;
|
|
359
|
+
if (forced === "deferred")
|
|
360
|
+
return "deferred";
|
|
361
|
+
if (forced !== "elicit" && appConfig.REMOTE_MCP) {
|
|
362
|
+
// DEFERRED, unconditionally, and not because elicitation would be worse here — it
|
|
363
|
+
// would be better. It is what makes this process stateful: a suspended
|
|
364
|
+
// `elicitation/create` pins an McpServer in one pod's heap until the answer
|
|
365
|
+
// arrives, so the hosted deployment would have to keep sessions, pin requests to a
|
|
366
|
+
// pod and cap how many can be alive. Deferred removes the suspension, and with it
|
|
367
|
+
// the reason any of that exists.
|
|
368
|
+
//
|
|
369
|
+
// THERE IS NO OPT-IN ANY MORE. `ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY` gated this
|
|
370
|
+
// while relaying meant elicitation — it was a guard against switching on the
|
|
371
|
+
// stateful thing, and that thing is gone. Two better switches remain: this tool's
|
|
372
|
+
// own `ASK_BROWSERSTACK_DISABLED`, which is read per call and so does not need a
|
|
373
|
+
// pod roll, and Atlas's `delegation.permission_relay`, which turns the relay off
|
|
374
|
+
// for every client at once from a config change.
|
|
375
|
+
return "deferred";
|
|
376
|
+
}
|
|
377
|
+
// stdio keeps elicitation, deliberately: one process, nothing to pin, and the client
|
|
378
|
+
// renders the prompt so the model cannot fabricate the answer. Nothing is gained by
|
|
379
|
+
// making a local install pay the two-call cost.
|
|
380
|
+
//
|
|
381
|
+
// The real gate here: can THIS client be asked? A client that never declared
|
|
290
382
|
// `elicitation` gets a read-only run whatever the deployment.
|
|
291
383
|
return server.server.getClientCapabilities()?.elicitation
|
|
292
384
|
? "offered"
|
|
@@ -298,7 +390,9 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
298
390
|
// JSON, the parser sees no `text/event-stream`, and the run degrades to a read-only
|
|
299
391
|
// answer carrying that response's own status.
|
|
300
392
|
const streamTransport = deps.streamTransport || fetchAgentStreamTransport();
|
|
301
|
-
|
|
393
|
+
// Resolved here with the others: one place decides which transports this
|
|
394
|
+
// registration uses, so a test substituting one is not surprised by another.
|
|
395
|
+
const deferredTransport = deps.deferredTransport || fetchDeferredTransport();
|
|
302
396
|
const tools = {};
|
|
303
397
|
/** Instrumentation in the house style, and never fatal to the call it wraps. */
|
|
304
398
|
const track = (name) => {
|
|
@@ -314,9 +408,35 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
314
408
|
.enum(PRODUCTS)
|
|
315
409
|
.describe("Which product to work in: tm (Test Management), " +
|
|
316
410
|
"tra (Test Reporting & Analytics)."),
|
|
411
|
+
// OPTIONAL, because a resume does not carry one. `next_step` tells the model to
|
|
412
|
+
// call again "with exactly these params" — `product`, `run_id`, `perm_id`,
|
|
413
|
+
// `decision` — and a required `query` made following that instruction literally
|
|
414
|
+
// fail schema validation before the handler ever ran. It is still required to
|
|
415
|
+
// START a run; the handler enforces that, where a useful message can be given
|
|
416
|
+
// instead of an InvalidParams from the SDK.
|
|
317
417
|
query: z
|
|
318
418
|
.string()
|
|
419
|
+
.optional()
|
|
319
420
|
.describe("What you want, in plain language. One thing per call."),
|
|
421
|
+
// --- resuming a parked run (the hosted, deferred path only) ---
|
|
422
|
+
//
|
|
423
|
+
// Both together or neither. They exist because that deployment cannot hold a
|
|
424
|
+
// prompt open mid-run: it returns the pending approval instead, and the caller
|
|
425
|
+
// comes back. On a local stdio install these are never needed — the prompt
|
|
426
|
+
// appears inside the first call — so they are optional and unmentioned in the
|
|
427
|
+
// description's main flow.
|
|
428
|
+
run_id: z
|
|
429
|
+
.string()
|
|
430
|
+
.optional()
|
|
431
|
+
.describe("`run_id` from a needs_approval result."),
|
|
432
|
+
perm_id: z
|
|
433
|
+
.string()
|
|
434
|
+
.optional()
|
|
435
|
+
.describe("`perm_id` from that same result."),
|
|
436
|
+
decision: z
|
|
437
|
+
.enum(["allow", "deny"])
|
|
438
|
+
.optional()
|
|
439
|
+
.describe("The user's answer. Never assume allow — ask them."),
|
|
320
440
|
}, {
|
|
321
441
|
// It can write now, which is the whole point of the relay. Destructive operations
|
|
322
442
|
// stay refused, so `destructiveHint` is false for the same reason invokeEndpoint
|
|
@@ -329,7 +449,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
329
449
|
openWorldHint: true,
|
|
330
450
|
idempotentHint: false,
|
|
331
451
|
title: "Ask BrowserStack AI (Alpha)",
|
|
332
|
-
}, async ({ product, query }, extra) => {
|
|
452
|
+
}, async ({ product, query, run_id, perm_id, decision }, extra) => {
|
|
333
453
|
track("askBrowserStackAI");
|
|
334
454
|
const approvals = [];
|
|
335
455
|
// Negotiated before anything else so the failure paths below report the mode they
|
|
@@ -341,6 +461,27 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
341
461
|
// lazily mid-call would put a token round-trip inside the window where a human is
|
|
342
462
|
// being prompted, and a mint that failed there would strand an open port.
|
|
343
463
|
const headers = agentHeaders(await deps.mintToken());
|
|
464
|
+
// RESUMING a parked run: no new run, no task — just the decision and the
|
|
465
|
+
// remainder. FIRST, because the arguments are mutually exclusive with starting
|
|
466
|
+
// one and a caller that sent both meant to resume — and because `query` is not
|
|
467
|
+
// required on this path, so nothing above may depend on it.
|
|
468
|
+
if (run_id) {
|
|
469
|
+
if (!perm_id || !decision) {
|
|
470
|
+
return toResult(incompleteResume());
|
|
471
|
+
}
|
|
472
|
+
// A `query` sent here is ignored rather than rejected: the run's task is in
|
|
473
|
+
// Atlas's checkpoint, so a model that resent it is redundant, not wrong.
|
|
474
|
+
return toResult(await runResumed(url, headers, run_id, perm_id, decision,
|
|
475
|
+
// The SAME transport the start uses: since the converged gate both calls
|
|
476
|
+
// POST and read back either a parked run or a finished one.
|
|
477
|
+
deferredTransport, mode, product));
|
|
478
|
+
}
|
|
479
|
+
// STARTING one, which is the only path that needs a task. Enforced here rather
|
|
480
|
+
// than in the schema so the model gets a sentence it can act on instead of an
|
|
481
|
+
// SDK InvalidParams — and so `next_step`, which omits `query`, stays valid.
|
|
482
|
+
if (!query || !query.trim()) {
|
|
483
|
+
return toResult(missingQuery());
|
|
484
|
+
}
|
|
344
485
|
const body = { task: query, product };
|
|
345
486
|
// Attribution, and now belt-and-braces rather than the source of truth: the minted
|
|
346
487
|
// JWT is user-attested, so Atlas sets `principal_verified=True` and takes the acting
|
|
@@ -351,12 +492,18 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
351
492
|
const username = (deps.credentialsFor().username || "").trim();
|
|
352
493
|
if (username)
|
|
353
494
|
body.user_id = username;
|
|
495
|
+
// DEFERRED: the hosted shape. One request, one JSON answer, nothing held open
|
|
496
|
+
// while a human decides — see `deferred.ts` for why that matters here and why
|
|
497
|
+
// stdio does not use it.
|
|
498
|
+
if (mode === "deferred") {
|
|
499
|
+
return toResult(await runDeferred(url, headers, body, deferredTransport, mode, product));
|
|
500
|
+
}
|
|
354
501
|
// A1: asking for a stream costs nothing to set up — no port, no listener, no
|
|
355
502
|
// per-run bearer, because nothing dials in. Which is the whole point: the
|
|
356
503
|
// callback this replaces could never reach a laptop behind NAT, so the feature
|
|
357
504
|
// was read-only for every real user regardless of what was configured.
|
|
358
505
|
if (mode === "offered") {
|
|
359
|
-
body.permission_relay = { mode: "stream" };
|
|
506
|
+
body.permission_relay = { mode: "stream", contract: RELAY_CONTRACT };
|
|
360
507
|
}
|
|
361
508
|
else {
|
|
362
509
|
// Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's
|
|
@@ -366,7 +513,7 @@ export function addAskBrowserStackAITool(server, deps, config) {
|
|
|
366
513
|
// `product` reaches the result so an entitlement refusal can name it: the flags
|
|
367
514
|
// are per product, and a bare "not enabled" sends the user to their admin
|
|
368
515
|
// asking about the wrong thing.
|
|
369
|
-
return toResult(await runStreamed(server, streamTransport,
|
|
516
|
+
return toResult(await runStreamed(server, streamTransport, deferredTransport, url, headers, body, approvals, mode, product,
|
|
370
517
|
// The tool call's own id, so each elicitation is routed onto THIS request's
|
|
371
518
|
// stream. Over Streamable HTTP there is nowhere else for it to go.
|
|
372
519
|
extra?.requestId));
|
|
@@ -48,13 +48,6 @@ export const RELAY_OFF_DETAILS = {
|
|
|
48
48
|
malformed: "BrowserStack could not use the approval channel this client offered and ran read-only. " +
|
|
49
49
|
"That is a bug on this side, not something you did; everything in `needs_approval` was " +
|
|
50
50
|
"refused because of it.",
|
|
51
|
-
// Neither the human nor the client is the constraint here — the DEPLOYMENT is, and no
|
|
52
|
-
// change either of them can make will help.
|
|
53
|
-
remote_mode: "NOBODY DECLINED THIS, AND YOUR CLIENT IS NOT THE PROBLEM. This BrowserStack MCP server " +
|
|
54
|
-
"is running in its hosted, multi-tenant mode, which has no way to put an approval " +
|
|
55
|
-
"prompt in front of you, so it ran read-only and everything in `needs_approval` was " +
|
|
56
|
-
"refused for that reason alone. Mid-run approval works when the server runs locally over stdio; retrying " +
|
|
57
|
-
"against this deployment will keep failing the same way.",
|
|
58
51
|
// Not a refusal by anyone and not a relay problem at all: the account is not on the
|
|
59
52
|
// product's agent flag. The product-specific sentence and what to do about it live in
|
|
60
53
|
// `error`, so this one points there rather than duplicating the plumbing.
|
|
@@ -63,6 +56,17 @@ export const RELAY_OFF_DETAILS = {
|
|
|
63
56
|
"refused before the agent started. `error` names the product. This is an entitlement on " +
|
|
64
57
|
"the account, not a problem with your credentials and not a decision anyone made about " +
|
|
65
58
|
"your request.",
|
|
59
|
+
// THE ONE CASE WHERE WE DO NOT KNOW. The decision was put on the wire and the reply
|
|
60
|
+
// never came back — a timeout, or a proxy cutting an idle connection. Since the
|
|
61
|
+
// converged gate the decision request carries the whole continuation, so "no reply"
|
|
62
|
+
// spans everything from "it never arrived" to "the write succeeded and we lost the
|
|
63
|
+
// answer". `not_reached` claims the first of those, which is exactly the claim that
|
|
64
|
+
// gets a write done twice, so this exists to stop the two being collapsed.
|
|
65
|
+
outcome_unknown: "THE ANSWER WAS DELIVERED BUT ITS OUTCOME IS UNKNOWN. The approval reached " +
|
|
66
|
+
"BrowserStack and the reply was lost before it came back, so the step may or may " +
|
|
67
|
+
"not have completed. DO NOT repeat this task — a retry could perform the same " +
|
|
68
|
+
"change a second time. Check the state in the product, and only then decide " +
|
|
69
|
+
"whether anything still needs doing.",
|
|
66
70
|
// The request never got as far as the agent. Distinct from `disabled` (the agent ran, with
|
|
67
71
|
// the relay switched off) and from a decline (someone was asked and said no), because the
|
|
68
72
|
// three call for completely different things from whoever reads them.
|
|
@@ -413,16 +417,6 @@ function relayVerdict(payload, mode, reachedAgent, notEntitled) {
|
|
|
413
417
|
detail: RELAY_OFF_DETAILS.not_reached,
|
|
414
418
|
};
|
|
415
419
|
}
|
|
416
|
-
// BEFORE `no_human`, deliberately, when both are true. In the hosted deployment even a
|
|
417
|
-
// client that CAN be prompted is of no use, so the deployment is the binding constraint and
|
|
418
|
-
// the one the reader can act on; telling them to switch clients would waste their time.
|
|
419
|
-
if (mode === "remote_mode") {
|
|
420
|
-
return {
|
|
421
|
-
used: false,
|
|
422
|
-
reason: "remote_mode",
|
|
423
|
-
detail: RELAY_OFF_DETAILS.remote_mode,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
420
|
if (mode === "no_human") {
|
|
427
421
|
// CONTRACT §7's last row: no elicitation capability means the field was never sent.
|
|
428
422
|
return {
|
|
@@ -48,6 +48,14 @@ export declare const WHOLE_RUN_TIMEOUT_MS = 1800000;
|
|
|
48
48
|
export declare const EVENT_RUN = "run";
|
|
49
49
|
export declare const EVENT_PERMISSION = "permission";
|
|
50
50
|
export declare const EVENT_RESULT = "result";
|
|
51
|
+
/**
|
|
52
|
+
* The wire version this client speaks, sent in `permission_relay`. See
|
|
53
|
+
* `PermissionRelay.contract` for why Atlas cannot infer it.
|
|
54
|
+
*
|
|
55
|
+
* Bump only when a change leaves an older client unable to COMPLETE the exchange —
|
|
56
|
+
* not merely unaware of a new field.
|
|
57
|
+
*/
|
|
58
|
+
export declare const RELAY_CONTRACT = 2;
|
|
51
59
|
/** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */
|
|
52
60
|
export declare const PERM_ID_PATTERN: RegExp;
|
|
53
61
|
/**
|
|
@@ -75,12 +83,15 @@ export declare function parseAsk(data: unknown): PermissionAsk | null;
|
|
|
75
83
|
* was: the tests must be able to drive a whole approval round trip without a socket.
|
|
76
84
|
*/
|
|
77
85
|
export type AgentStreamTransport = (url: string, headers: Record<string, string>, body: AgentRequest) => AsyncIterable<StreamEvent>;
|
|
78
|
-
/**
|
|
79
|
-
|
|
86
|
+
/** What a decision POST carries. `product` is required: the run comes back on a fresh
|
|
87
|
+
* Agent that has to be scoped to a harness before it can be built, and scoping is the
|
|
88
|
+
* one thing that must not be re-derived from persisted state. */
|
|
89
|
+
export interface DecisionBody {
|
|
80
90
|
perm_id: string;
|
|
81
91
|
decision: string;
|
|
82
92
|
reason: string;
|
|
83
|
-
|
|
93
|
+
product: string;
|
|
94
|
+
}
|
|
84
95
|
/**
|
|
85
96
|
* Split a buffer into complete SSE frames, returning the leftover.
|
|
86
97
|
*
|
|
@@ -110,7 +121,18 @@ export declare function parseFrame(frame: string): StreamEvent | null;
|
|
|
110
121
|
* is unchanged and still enforced where it belongs.
|
|
111
122
|
*/
|
|
112
123
|
export declare function fetchAgentStreamTransport(timeoutMs?: number): AgentStreamTransport;
|
|
113
|
-
/** The decision POST. 30s, because it is an ordinary short request. */
|
|
114
|
-
export declare function fetchDecisionTransport(timeoutMs?: number): DecisionTransport;
|
|
115
124
|
/** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
|
|
116
125
|
export declare function decisionUrl(agentUrl: string, runId: string): string;
|
|
126
|
+
/**
|
|
127
|
+
* One POST, one JSON response, nothing held open.
|
|
128
|
+
*
|
|
129
|
+
* Used for BOTH calls of the two-call shape — the start (`POST /agent`) and every
|
|
130
|
+
* decision (`POST /agent/{run_id}/permission`) — because since the converged gate they
|
|
131
|
+
* answer the same thing: a parked run, or a finished one. A separate seam from the
|
|
132
|
+
* streaming transport because it is a different kind of call, not a different URL.
|
|
133
|
+
*/
|
|
134
|
+
export type DeferredTransport = (url: string, headers: Record<string, string>, body: AgentRequest | DecisionBody) => Promise<{
|
|
135
|
+
status: number;
|
|
136
|
+
body: unknown;
|
|
137
|
+
}>;
|
|
138
|
+
export declare function fetchDeferredTransport(timeoutMs?: number): DeferredTransport;
|
|
@@ -35,6 +35,14 @@ export const WHOLE_RUN_TIMEOUT_MS = 1_800_000;
|
|
|
35
35
|
export const EVENT_RUN = "run";
|
|
36
36
|
export const EVENT_PERMISSION = "permission";
|
|
37
37
|
export const EVENT_RESULT = "result";
|
|
38
|
+
/**
|
|
39
|
+
* The wire version this client speaks, sent in `permission_relay`. See
|
|
40
|
+
* `PermissionRelay.contract` for why Atlas cannot infer it.
|
|
41
|
+
*
|
|
42
|
+
* Bump only when a change leaves an older client unable to COMPLETE the exchange —
|
|
43
|
+
* not merely unaware of a new field.
|
|
44
|
+
*/
|
|
45
|
+
export const RELAY_CONTRACT = 2;
|
|
38
46
|
/** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */
|
|
39
47
|
export const PERM_ID_PATTERN = /^perm-[0-9a-f]{32}$/;
|
|
40
48
|
/**
|
|
@@ -206,13 +214,21 @@ export function fetchAgentStreamTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
|
|
|
206
214
|
};
|
|
207
215
|
};
|
|
208
216
|
}
|
|
209
|
-
/**
|
|
210
|
-
export function
|
|
217
|
+
/** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
|
|
218
|
+
export function decisionUrl(agentUrl, runId) {
|
|
219
|
+
return `${agentUrl.replace(/\/+$/, "")}/${encodeURIComponent(runId)}/permission`;
|
|
220
|
+
}
|
|
221
|
+
// Bounded by the WHOLE RUN, like the streaming transport, because that is what these
|
|
222
|
+
// requests now contain. 120s was sized for a request that returned as soon as the run
|
|
223
|
+
// parked; since the converged gate the DECISION request carries everything after the
|
|
224
|
+
// approval — the write and the rest of the task — so the old bound cut off successful
|
|
225
|
+
// runs partway and reported them as unreachable.
|
|
226
|
+
export function fetchDeferredTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
|
|
211
227
|
return async (url, headers, body) => {
|
|
212
228
|
try {
|
|
213
|
-
//
|
|
214
|
-
// reads the STATUS
|
|
215
|
-
// and a
|
|
229
|
+
// `raise_error: false` for the same reason the decision transport does it: the
|
|
230
|
+
// caller reads the STATUS, and a thrown AxiosError would collapse a 403
|
|
231
|
+
// (not entitled) and a 503 (delegation off) into the unreachable case below.
|
|
216
232
|
const response = await apiClient.post({
|
|
217
233
|
url,
|
|
218
234
|
headers,
|
|
@@ -220,17 +236,12 @@ export function fetchDecisionTransport(timeoutMs = 30_000) {
|
|
|
220
236
|
timeout: timeoutMs,
|
|
221
237
|
raise_error: false,
|
|
222
238
|
});
|
|
223
|
-
return response.status;
|
|
239
|
+
return { status: response.status, body: response.data };
|
|
224
240
|
}
|
|
225
241
|
catch {
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
return 0;
|
|
242
|
+
// Same sentence as the streaming transport, and for the same reason: the upstream
|
|
243
|
+
// detail names our plumbing rather than anything the reader can act on.
|
|
244
|
+
throw new AskError("BrowserStack AI could not be reached");
|
|
230
245
|
}
|
|
231
246
|
};
|
|
232
247
|
}
|
|
233
|
-
/** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
|
|
234
|
-
export function decisionUrl(agentUrl, runId) {
|
|
235
|
-
return `${agentUrl.replace(/\/+$/, "")}/${encodeURIComponent(runId)}/permission`;
|
|
236
|
-
}
|
|
@@ -57,6 +57,16 @@ export interface PermissionDecision {
|
|
|
57
57
|
*/
|
|
58
58
|
export interface PermissionRelay {
|
|
59
59
|
mode?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The wire this client speaks. Atlas reads an absent or lower value as a build that
|
|
62
|
+
* CANNOT complete an approval and refuses to park rather than offering one.
|
|
63
|
+
*
|
|
64
|
+
* Declared, not inferred, because the two are identical on the way in: this client's
|
|
65
|
+
* stdio path sends `{"mode": "stream"}` exactly as 1.5.1 does. Measured against
|
|
66
|
+
* 1.5.1 talking to a converged Atlas — it answers a decision without `product`, gets
|
|
67
|
+
* a 400, logs it to stderr and then reports `ok: true` for a run that did nothing.
|
|
68
|
+
*/
|
|
69
|
+
contract?: number;
|
|
60
70
|
}
|
|
61
71
|
/**
|
|
62
72
|
* CONTRACT §1 — the `POST /agent` body.
|
|
@@ -122,9 +132,23 @@ export type RelayMode =
|
|
|
122
132
|
"offered"
|
|
123
133
|
/** The client declares no `elicitation` capability, so nobody could be prompted. */
|
|
124
134
|
| "no_human"
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The hosted server, relaying WITHOUT holding anything open.
|
|
137
|
+
*
|
|
138
|
+
* Elicitation is what makes this process stateful: a suspended `elicitation/create`
|
|
139
|
+
* pins an McpServer in one pod's heap until the answer arrives, which is why the
|
|
140
|
+
* hosted deployment needs sessions, ingress affinity and a session cap. `deferred`
|
|
141
|
+
* removes the suspension instead of routing around it — Atlas returns at its first
|
|
142
|
+
* ask, this tool hands that ask back, and the caller returns with the decision in a
|
|
143
|
+
* second call. Nothing is held between the two, so any pod can serve either.
|
|
144
|
+
*
|
|
145
|
+
* The trade, stated because it is real: approval rides the CLIENT's own permission
|
|
146
|
+
* prompt on that second call rather than an elicitation, so a client configured not
|
|
147
|
+
* to prompt (`--permission-mode auto`) approves without a human. Elicitation fails
|
|
148
|
+
* closed by construction; this fails closed only if the client asks.
|
|
149
|
+
*/
|
|
150
|
+
| "deferred";
|
|
151
|
+
export declare const ASK_STATUSES: readonly ["ok", "blocked", "error", "rate_limited", "needs_approval"];
|
|
128
152
|
export type AskStatus = (typeof ASK_STATUSES)[number];
|
|
129
153
|
/** CONTRACT §5 — the single tool result. */
|
|
130
154
|
export interface AskResult {
|
|
@@ -185,6 +209,33 @@ export interface AskResult {
|
|
|
185
209
|
* side would otherwise silently become `null` here rather than reaching the caller.
|
|
186
210
|
*/
|
|
187
211
|
atlas_response: unknown;
|
|
212
|
+
/**
|
|
213
|
+
* Deferred only: the parked run, and the ask waiting on it.
|
|
214
|
+
*
|
|
215
|
+
* Present exactly when `status === "needs_approval"`. Both are echoed back on the
|
|
216
|
+
* caller's second call — `perm_id` alongside `run_id` so the answer names WHICH ask it
|
|
217
|
+
* answers, which matters the moment a task needs more than one.
|
|
218
|
+
*/
|
|
219
|
+
run_id?: string;
|
|
220
|
+
perm_id?: string;
|
|
221
|
+
/**
|
|
222
|
+
* Deferred only: what the caller must do next, and with what.
|
|
223
|
+
*
|
|
224
|
+
* Carried as its own field rather than buried in prose because the model reads this
|
|
225
|
+
* result and decides what to do from it. The first version explained the TRANSPORT
|
|
226
|
+
* ("this deployment cannot hold a prompt open…") which is true and useless: it told
|
|
227
|
+
* the model how the plumbing works, not that a person has to answer. Measured
|
|
228
|
+
* consequence — the model read the ask, judged it matched the user's request, and
|
|
229
|
+
* answered `allow` itself in the same turn without asking anyone.
|
|
230
|
+
*
|
|
231
|
+
* This cannot MAKE a model ask; nothing in the protocol can, which is the standing
|
|
232
|
+
* argument for elicitation. It removes the excuse.
|
|
233
|
+
*/
|
|
234
|
+
next_step?: {
|
|
235
|
+
instruction: string;
|
|
236
|
+
call: string;
|
|
237
|
+
params: Record<string, string>;
|
|
238
|
+
};
|
|
188
239
|
/**
|
|
189
240
|
* Why the call failed, when it did.
|
|
190
241
|
*
|
|
@@ -11,4 +11,14 @@
|
|
|
11
11
|
// describe text in register.ts, and the two a11y handoffs in tool-handoff.ts — which stop
|
|
12
12
|
// pointing here precisely because the call would now be rejected.
|
|
13
13
|
export const PRODUCTS = ["tm", "tra"];
|
|
14
|
-
export const ASK_STATUSES = [
|
|
14
|
+
export const ASK_STATUSES = [
|
|
15
|
+
"ok",
|
|
16
|
+
"blocked",
|
|
17
|
+
"error",
|
|
18
|
+
"rate_limited",
|
|
19
|
+
// Deferred only: the run reached an ask and is PARKED, not finished. Distinct from
|
|
20
|
+
// `blocked` (a human said no) and from `error` (nothing is waiting) — this one is the
|
|
21
|
+
// only status a caller is invited to come back from, and the only one carrying a
|
|
22
|
+
// `run_id`.
|
|
23
|
+
"needs_approval",
|
|
24
|
+
];
|
|
@@ -2,6 +2,7 @@ import { assertOkResponse, maybeCompressBase64 } from "../../lib/utils.js";
|
|
|
2
2
|
import { SessionType } from "../../lib/constants.js";
|
|
3
3
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
4
4
|
import { apiClient } from "../../lib/apiClient.js";
|
|
5
|
+
import globalConfig from "../../config.js";
|
|
5
6
|
async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
6
7
|
const authString = getBrowserStackAuth(config);
|
|
7
8
|
const auth = Buffer.from(authString).toString("base64");
|
|
@@ -36,23 +37,24 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
|
36
37
|
}
|
|
37
38
|
return urls;
|
|
38
39
|
}
|
|
40
|
+
async function fetchAndCompress(url) {
|
|
41
|
+
const response = await apiClient.get({ url, responseType: "arraybuffer" });
|
|
42
|
+
// Axios returns response.data as a Buffer for binary data
|
|
43
|
+
const base64 = Buffer.from(response.data).toString("base64");
|
|
44
|
+
return { url, base64: await maybeCompressBase64(base64) };
|
|
45
|
+
}
|
|
39
46
|
//Converts screenshot URLs to base64 encoded images
|
|
40
47
|
async function convertUrlsToBase64(urls) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
url,
|
|
52
|
-
base64: compressedBase64,
|
|
53
|
-
};
|
|
54
|
-
}));
|
|
55
|
-
return screenshots;
|
|
48
|
+
// Hosted pods are memory-constrained: process one image at a time. Local runs
|
|
49
|
+
// keep the concurrent path.
|
|
50
|
+
if (globalConfig.REMOTE_MCP) {
|
|
51
|
+
const screenshots = [];
|
|
52
|
+
for (const url of urls) {
|
|
53
|
+
screenshots.push(await fetchAndCompress(url));
|
|
54
|
+
}
|
|
55
|
+
return screenshots;
|
|
56
|
+
}
|
|
57
|
+
return Promise.all(urls.map(fetchAndCompress));
|
|
56
58
|
}
|
|
57
59
|
//Fetches and converts screenshot URLs to base64 encoded images
|
|
58
60
|
export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
|