@browserstack/mcp-server 1.3.1-beta.1 → 1.4.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +5 -2
- package/dist/server-factory.js +5 -0
- package/dist/tools/accessibility.js +5 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +114 -0
- package/dist/tools/ask-browserstack/central-oauth.js +271 -0
- package/dist/tools/ask-browserstack/config.d.ts +96 -0
- package/dist/tools/ask-browserstack/config.js +134 -0
- package/dist/tools/ask-browserstack/egress.d.ts +34 -0
- package/dist/tools/ask-browserstack/egress.js +31 -0
- package/dist/tools/ask-browserstack/register.d.ts +61 -0
- package/dist/tools/ask-browserstack/register.js +394 -0
- package/dist/tools/ask-browserstack/relay.d.ts +201 -0
- package/dist/tools/ask-browserstack/relay.js +575 -0
- package/dist/tools/ask-browserstack/stream.d.ts +116 -0
- package/dist/tools/ask-browserstack/stream.js +237 -0
- package/dist/tools/ask-browserstack/types.d.ts +196 -0
- package/dist/tools/ask-browserstack/types.js +10 -0
- package/dist/tools/get-failure-logs.js +3 -1
- package/dist/tools/rca-agent.js +5 -2
- package/dist/tools/selfheal.js +5 -2
- package/dist/tools/testmanagement.js +33 -15
- package/dist/tools/tool-handoff.d.ts +37 -0
- package/dist/tools/tool-handoff.js +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision mapping and the result assembly — the two places where being wrong is
|
|
3
|
+
* expensive, kept pure so they can be tested without a server, a socket or a client.
|
|
4
|
+
*/
|
|
5
|
+
import { ASK_STATUSES, } from "./types.js";
|
|
6
|
+
export const RELAY_ON_DETAIL = "This client can prompt you, so BrowserStack asked before each change and the answers " +
|
|
7
|
+
"are in `approvals`.";
|
|
8
|
+
/**
|
|
9
|
+
* Used when the channel worked and every ask came back with nobody behind it.
|
|
10
|
+
*
|
|
11
|
+
* `RELAY_ON_DETAIL` cannot be used here and saying it was a bug, observed live: it claims
|
|
12
|
+
* "BrowserStack asked before each change and the answers are in `approvals`" while the
|
|
13
|
+
* trail says `refused: nobody was there to be asked`. Both sentences were in the same
|
|
14
|
+
* result, contradicting each other — the same class of confusion as `disabled` vs a human
|
|
15
|
+
* saying no, and it is the reader who pays for it. The channel being usable and a person
|
|
16
|
+
* actually answering are different facts, and only the second one licenses the word
|
|
17
|
+
* "answers".
|
|
18
|
+
*/
|
|
19
|
+
export const RELAY_ON_NO_ANSWER_DETAIL = "BrowserStack asked before each change, but this client answered without a person " +
|
|
20
|
+
"present, so every change was refused. Nothing was modified. Approve from a client " +
|
|
21
|
+
"that can show you the prompt.";
|
|
22
|
+
/**
|
|
23
|
+
* One sentence per way the relay can fail to run, because they call for different things
|
|
24
|
+
* from the person reading them, and a caller who cannot tell them apart retries forever.
|
|
25
|
+
*
|
|
26
|
+
* `no_human` is ours (CONTRACT §7's last row); the rest are Atlas's (v1.1 §D).
|
|
27
|
+
*/
|
|
28
|
+
export const RELAY_OFF_DETAILS = {
|
|
29
|
+
no_human: "This client does not support MCP elicitation, so there was no way to ask you mid-run. " +
|
|
30
|
+
"BrowserStack ran read-only: anything that would have changed data was refused and is " +
|
|
31
|
+
"listed in `needs_approval`. Re-run the same request from a client that supports " +
|
|
32
|
+
"elicitation to be asked for confirmation instead.",
|
|
33
|
+
// The one that matters most to a human. NOBODY REFUSED ANYTHING HERE — the server has the
|
|
34
|
+
// knob off, so saying "your change was declined" would be a lie and retrying cannot help.
|
|
35
|
+
disabled: "NOBODY DECLINED THIS. BrowserStack's permission relay is switched off on the server " +
|
|
36
|
+
"(`delegation.permission_relay`), so it ignored the approval channel this client " +
|
|
37
|
+
"offered and ran read-only. Everything in `needs_approval` was refused for that " +
|
|
38
|
+
"configuration reason alone. Retrying will keep failing the same way until an " +
|
|
39
|
+
"administrator turns the relay on.",
|
|
40
|
+
// ONLY AN ATLAS THAT PREDATES A1 CAN SEND THIS. It described the old outbound transport:
|
|
41
|
+
// the server was handed a URL to call back and refused the address. Nothing dials out any
|
|
42
|
+
// more, so the condition cannot arise — the reason stays mapped because an older
|
|
43
|
+
// deployment is still entitled to an explanation rather than a raw enum.
|
|
44
|
+
host_not_allowed: "BrowserStack refused to call this client back, which means it is running a version " +
|
|
45
|
+
"that predates the current approval channel: that version could only reach a client on " +
|
|
46
|
+
"the same host. The run went read-only. Nothing was declined by a person, and the fix " +
|
|
47
|
+
"is a BrowserStack-side upgrade rather than anything about this request.",
|
|
48
|
+
malformed: "BrowserStack could not use the approval channel this client offered and ran read-only. " +
|
|
49
|
+
"That is a bug on this side, not something you did; everything in `needs_approval` was " +
|
|
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
|
+
// Not a refusal by anyone and not a relay problem at all: the account is not on the
|
|
59
|
+
// product's agent flag. The product-specific sentence and what to do about it live in
|
|
60
|
+
// `error`, so this one points there rather than duplicating the plumbing.
|
|
61
|
+
not_entitled: "NOBODY DECLINED THIS AND NOTHING RAN. BrowserStack AI is not enabled for this account, " +
|
|
62
|
+
"so the request was refused before the agent started. `error` says which product and " +
|
|
63
|
+
"what to do about it. This is an entitlement on the account, not a problem with your " +
|
|
64
|
+
"credentials and not a decision anyone made about your request.",
|
|
65
|
+
// The request never got as far as the agent. Distinct from `disabled` (the agent ran, with
|
|
66
|
+
// the relay switched off) and from a decline (someone was asked and said no), because the
|
|
67
|
+
// three call for completely different things from whoever reads them.
|
|
68
|
+
not_reached: "NOTHING WAS ASKED AND NOTHING WAS REFUSED. BrowserStack rejected this request before " +
|
|
69
|
+
"the agent started, so no step ran, no prompt appeared, and nothing was changed. " +
|
|
70
|
+
"`error` says why. This is not a decision anyone made about your request — fix what " +
|
|
71
|
+
"`error` names and run it again.",
|
|
72
|
+
};
|
|
73
|
+
/** Kept for anything still importing the old single constant. */
|
|
74
|
+
export const RELAY_OFF_DETAIL = RELAY_OFF_DETAILS.no_human;
|
|
75
|
+
/** A reason from a newer Atlas than this build. Say so plainly rather than crash. */
|
|
76
|
+
function unknownRelayDetail(used, reason) {
|
|
77
|
+
// Bounded: this string came off the wire and goes into a result a human reads.
|
|
78
|
+
const quoted = JSON.stringify(reason.slice(0, 64));
|
|
79
|
+
return used
|
|
80
|
+
? `BrowserStack used the approval channel and reported ${quoted}, which this version ` +
|
|
81
|
+
"does not recognise. The answers it did collect are in `approvals`."
|
|
82
|
+
: `BrowserStack did not use the approval channel this client offered, reporting ` +
|
|
83
|
+
`${quoted}, which this version does not recognise. The run was read-only, so ` +
|
|
84
|
+
"anything in `needs_approval` was refused without anyone being asked.";
|
|
85
|
+
}
|
|
86
|
+
/** The sentence that goes with a `{used, reason}` pair, whoever produced it. */
|
|
87
|
+
export function relayDetail(used, reason) {
|
|
88
|
+
if (used)
|
|
89
|
+
return reason ? unknownRelayDetail(true, reason) : RELAY_ON_DETAIL;
|
|
90
|
+
return RELAY_OFF_DETAILS[reason] ?? unknownRelayDetail(false, reason);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Did this request die before the agent ever started?
|
|
94
|
+
*
|
|
95
|
+
* Atlas omits its `permission_relay` verdict on refusals that never reach the delegation
|
|
96
|
+
* layer — 401 unauthorized, 400 bad body, 503 delegation-not-enabled all answer with a bare
|
|
97
|
+
* `{"detail": …}` — and a transport failure has no body at all. Read naively, "no verdict"
|
|
98
|
+
* looks identical to "an Atlas older than v1.1", and the optimistic fallback for THAT case
|
|
99
|
+
* then claims the channel was used and answers were collected when zero prompts appeared.
|
|
100
|
+
*
|
|
101
|
+
* Which is the same confusion the `disabled` sentence exists to prevent, one layer earlier:
|
|
102
|
+
* a caller who cannot tell "nobody was asked" from "somebody said no" retries forever.
|
|
103
|
+
*/
|
|
104
|
+
export function looksLikeDelegationResult(body) {
|
|
105
|
+
const payload = asRecord(body);
|
|
106
|
+
return ("ok" in payload ||
|
|
107
|
+
"status" in payload ||
|
|
108
|
+
"answer" in payload ||
|
|
109
|
+
"approvals" in payload);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Is this Atlas saying the ACCOUNT is not enabled for the product's agent?
|
|
113
|
+
*
|
|
114
|
+
* Atlas gained an entitlement gate on `POST /agent` (`flags.is_agent_enabled`, AIC-386) that
|
|
115
|
+
* the WebSocket path already had. The flags are PER PRODUCT — `aiHarnessAgent` for tm,
|
|
116
|
+
* `aiHarnessAgentTRA`, `aiHarnessAgentA11y` — so an account can be entitled for one product
|
|
117
|
+
* and not another. It is also FAIL-OPEN on Atlas's side: Redis down, a flag never seeded, or
|
|
118
|
+
* an unknown product all allow the request. So a 403 here is a real, deliberate "this account
|
|
119
|
+
* is not enabled", never an outage.
|
|
120
|
+
*
|
|
121
|
+
* KEYED STRUCTURALLY, NEVER ON THE PROSE. Matching the sentence would break silently the
|
|
122
|
+
* first time someone rewords it, falling through to a generic error.
|
|
123
|
+
*
|
|
124
|
+
* TODO(atlas/9.md): Atlas is deciding whether to add a `code` field to this body. When it
|
|
125
|
+
* lands, prefer it over the status — add the check as the first rung here and leave the
|
|
126
|
+
* status as the fallback for an older Atlas. Until then the status IS the structural signal.
|
|
127
|
+
*/
|
|
128
|
+
export function isNotEntitled(response) {
|
|
129
|
+
return response.status === 403;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The sentence the user asked for, with the product named.
|
|
133
|
+
*
|
|
134
|
+
* Entitlement is per product, so a bare "not enabled" sends someone to their admin asking
|
|
135
|
+
* about the wrong thing. The disambiguation from a 401 is deliberate and load-bearing:
|
|
136
|
+
* without it, a working access key gets rotated in response to a permissions problem.
|
|
137
|
+
*/
|
|
138
|
+
export const NOT_ENTITLED_DETAIL = (product) => {
|
|
139
|
+
const scope = product && product.trim() ? ` for \`${product.trim()}\`` : "";
|
|
140
|
+
return (`BrowserStack AI is not enabled${scope} on your account. Please contact your admin. ` +
|
|
141
|
+
`YOUR CREDENTIALS ARE FINE — they authenticated successfully; this is a per-product ` +
|
|
142
|
+
`entitlement on the account. Nothing was run and nobody declined anything.`);
|
|
143
|
+
};
|
|
144
|
+
export function neverReachedAgent(response) {
|
|
145
|
+
// No response at all: nothing could have run.
|
|
146
|
+
if (response.status === 0)
|
|
147
|
+
return true;
|
|
148
|
+
// THE BODY DECIDES, NOT THE STATUS.
|
|
149
|
+
//
|
|
150
|
+
// This rung first read "any non-2xx", and that was wrong in the one direction that
|
|
151
|
+
// matters. Atlas answers HTTP 502 with a COMPLETE result body when a delegation ran and a
|
|
152
|
+
// step then failed (`delegation/http.py:317-320`), and 429 carries a full body too. So an
|
|
153
|
+
// approved write whose egress failed came back saying nobody had been asked, while
|
|
154
|
+
// `approvals` in the same payload showed the prompt shown and approved — the exact lie
|
|
155
|
+
// this predicate was added to prevent, now told on the one run where it costs the most.
|
|
156
|
+
//
|
|
157
|
+
// The HTTP code describes the OUTCOME; the body describes whether there was a RUN. Only
|
|
158
|
+
// the second question is being asked here.
|
|
159
|
+
return !looksLikeDelegationResult(response.body);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Atlas's own verdict on the relay (v1.1 §D), when it gave one.
|
|
163
|
+
*
|
|
164
|
+
* Present ONLY when we supplied a `permission_relay` block, so its absence is either "we
|
|
165
|
+
* never offered one" or "this Atlas predates v1.1" — neither of which is an error. A block
|
|
166
|
+
* we cannot read is treated as no block at all rather than half-trusted.
|
|
167
|
+
*/
|
|
168
|
+
export function atlasRelayVerdict(payload) {
|
|
169
|
+
const raw = payload.permission_relay;
|
|
170
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
171
|
+
return null;
|
|
172
|
+
const record = raw;
|
|
173
|
+
if (typeof record.used !== "boolean")
|
|
174
|
+
return null;
|
|
175
|
+
return {
|
|
176
|
+
used: record.used,
|
|
177
|
+
reason: typeof record.reason === "string" ? record.reason : "",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Product-language framing for the prompt (v1.1 §G, approved).
|
|
182
|
+
*
|
|
183
|
+
* `product` is the ONLY thing added — it is all §2 carries, and the route, method and path
|
|
184
|
+
* never reach this side by design. The description itself is passed through untouched:
|
|
185
|
+
* paraphrasing or truncating it would mean the human approves something other than what the
|
|
186
|
+
* model actually said. Atlas sends it as the model wrote it (its route guard was removed —
|
|
187
|
+
* CONTRACT v2 §3, amended), so this prefix is the only thing in front of the model's own
|
|
188
|
+
* sentence. A placeholder can still arrive from an older Atlas and reads fine after it.
|
|
189
|
+
*/
|
|
190
|
+
export const PRODUCT_LABELS = {
|
|
191
|
+
tm: "Test Management",
|
|
192
|
+
a11y: "Accessibility",
|
|
193
|
+
tra: "Test Reporting & Analytics",
|
|
194
|
+
};
|
|
195
|
+
export function elicitationMessage(product, description) {
|
|
196
|
+
const label = PRODUCT_LABELS[product] || product.trim();
|
|
197
|
+
const who = label
|
|
198
|
+
? `BrowserStack AI (${label})`
|
|
199
|
+
: // An unnamed product beats an empty pair of brackets.
|
|
200
|
+
"BrowserStack AI";
|
|
201
|
+
return `${who} needs your approval to continue:\n\n${description}`;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* CONTRACT §7. THE ACTION IS THE WHOLE ANSWER.
|
|
205
|
+
*
|
|
206
|
+
* | accept | allow | "" |
|
|
207
|
+
* | decline | deny | declined |
|
|
208
|
+
* | cancel | deny | cancelled |
|
|
209
|
+
*
|
|
210
|
+
* Nothing is requested in the form any more, so nothing can contradict the action. There used
|
|
211
|
+
* to be a `confirm` boolean, and it had to go: with an `accept` action that ALREADY means the
|
|
212
|
+
* human approved, `accept` + `confirm: false` is genuinely ambiguous between "I approved, and
|
|
213
|
+
* a checkbox I never saw defaulted to false" and "I unticked it deliberately". The first is a
|
|
214
|
+
* FALSE DENIAL — indistinguishable in the result from a human refusing, which is the exact
|
|
215
|
+
* confusion D3 and N1 existed to remove — and a user hit it live. We cannot tell the two
|
|
216
|
+
* apart, and guessing either way is wrong for the other. `decline` already gives an
|
|
217
|
+
* unambiguous refusal in the same dialog, so the boolean bought nothing.
|
|
218
|
+
*
|
|
219
|
+
* FAIL-CLOSED IS UNCHANGED, and the boolean was never what provided it. A headless client with
|
|
220
|
+
* no human at a terminal returns `cancel` — measured, not assumed (HANDOFF.md) — and `cancel`
|
|
221
|
+
* is a deny. That is why an unattended run still cannot self-approve. It is also why an
|
|
222
|
+
* elicitation is never retried: a second ask cannot conjure a human, only wear one down.
|
|
223
|
+
*/
|
|
224
|
+
export function decide(result) {
|
|
225
|
+
if (result.action === "accept") {
|
|
226
|
+
// DEFENSIVE ONLY. We no longer request this field, so no client can be expected to send
|
|
227
|
+
// it — but one that volunteers an explicit `false` has said something, and honouring it
|
|
228
|
+
// costs nothing. Absence, which is the normal case, is consent.
|
|
229
|
+
if (result.content?.confirm === false) {
|
|
230
|
+
return { decision: "deny", reason: "declined" };
|
|
231
|
+
}
|
|
232
|
+
return { decision: "allow", reason: "" };
|
|
233
|
+
}
|
|
234
|
+
if (result.action === "decline")
|
|
235
|
+
return { decision: "deny", reason: "declined" };
|
|
236
|
+
// `cancel`, and anything a future client sends that we do not recognise: no explicit
|
|
237
|
+
// answer was given, which is not an answer we may read as yes.
|
|
238
|
+
return { decision: "deny", reason: "cancelled" };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* A one-line description of the SHAPE of what a client answered with — never its content.
|
|
242
|
+
*
|
|
243
|
+
* Which client sends what is currently guesswork: the elicitation bug in task 9 had to be
|
|
244
|
+
* fixed without being able to confirm what Claude Code actually submits, because its binary
|
|
245
|
+
* is compiled and its strings too fragmented to read. This line means the next person can
|
|
246
|
+
* look it up instead of inferring it.
|
|
247
|
+
*
|
|
248
|
+
* `action` and `confirm` are a fixed enum and a boolean; neither can carry a description, a
|
|
249
|
+
* credential or anything else a user typed.
|
|
250
|
+
*/
|
|
251
|
+
export function elicitationShape(result) {
|
|
252
|
+
const content = result.content;
|
|
253
|
+
const confirm = content?.confirm;
|
|
254
|
+
const seen = confirm === undefined
|
|
255
|
+
? "absent"
|
|
256
|
+
: typeof confirm === "boolean"
|
|
257
|
+
? String(confirm)
|
|
258
|
+
: "non-boolean";
|
|
259
|
+
return `action=${result.action} content=${content ? "present" : "absent"} confirm=${seen}`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Read Atlas's `applied_before_stop`. NEVER DERIVE IT.
|
|
263
|
+
*
|
|
264
|
+
* This side used to compute it as CONTRACT §5's literal "any allow preceded a deny", which
|
|
265
|
+
* could only ever be a guess: an approval whose request then failed counted as applied, so
|
|
266
|
+
* the field lied in the exact direction it exists to prevent (D2). Atlas now computes it
|
|
267
|
+
* from `applied`, which only Atlas can know, and sends it whenever a gate ran — including
|
|
268
|
+
* `false`, including with an empty trail.
|
|
269
|
+
*
|
|
270
|
+
* So a MISSING field is never "false". It is "nobody measured this": either no gate ran, or
|
|
271
|
+
* this Atlas predates the field. `null` says that out loud instead of asserting a fact.
|
|
272
|
+
*/
|
|
273
|
+
export function readAppliedBeforeStop(payload) {
|
|
274
|
+
const reported = payload.applied_before_stop;
|
|
275
|
+
return typeof reported === "boolean" ? reported : null;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Atlas's approval trail, when it sent one.
|
|
279
|
+
*
|
|
280
|
+
* Returns `null` — not `[]` — when the key is absent, because an empty trail Atlas DID send
|
|
281
|
+
* ("the relay ran and nothing was asked") is a different fact from no trail at all, and only
|
|
282
|
+
* the second is a reason to fall back to ours.
|
|
283
|
+
*
|
|
284
|
+
* Every entry is rebuilt rather than trusted: a `decision` that is not exactly `"allow"`
|
|
285
|
+
* becomes `"deny"`, so a garbled trail fails closed in the reporting the same way the wire
|
|
286
|
+
* does, and `applied` is carried only when it is genuinely a boolean.
|
|
287
|
+
*/
|
|
288
|
+
export function parseAtlasApprovals(payload) {
|
|
289
|
+
const raw = payload.approvals;
|
|
290
|
+
if (!Array.isArray(raw))
|
|
291
|
+
return null;
|
|
292
|
+
const trail = [];
|
|
293
|
+
for (const item of raw) {
|
|
294
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
295
|
+
continue;
|
|
296
|
+
const entry = item;
|
|
297
|
+
trail.push({
|
|
298
|
+
description: typeof entry.description === "string" ? entry.description : "",
|
|
299
|
+
decision: entry.decision === "allow" ? "allow" : "deny",
|
|
300
|
+
reason: typeof entry.reason === "string" ? entry.reason : "",
|
|
301
|
+
...(typeof entry.applied === "boolean" ? { applied: entry.applied } : {}),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return trail;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* One phrase per entry, because "approved, then it failed" and "refused" must not read
|
|
308
|
+
* alike — conflating them is the whole reason D2 mattered.
|
|
309
|
+
*
|
|
310
|
+
* An `allow` with no `applied` key is NOT rendered as a failure: nobody measured it, and
|
|
311
|
+
* saying otherwise would invent the very fact this is meant to report.
|
|
312
|
+
*/
|
|
313
|
+
/**
|
|
314
|
+
* Every ask was refused because no person was there — not because one said no.
|
|
315
|
+
*
|
|
316
|
+
* `cancelled` is what a client with nobody at the terminal returns (measured), and
|
|
317
|
+
* `no_human` is our own word for the same thing. An EMPTY trail is not this case: nothing
|
|
318
|
+
* was asked at all, which the existing sentences already describe correctly.
|
|
319
|
+
*/
|
|
320
|
+
export function nobodyAnswered(trail) {
|
|
321
|
+
return (trail.length > 0 &&
|
|
322
|
+
trail.every((e) => e.decision !== "allow" &&
|
|
323
|
+
(e.reason === "cancelled" || e.reason === "no_human")));
|
|
324
|
+
}
|
|
325
|
+
export function approvalOutcome(entry) {
|
|
326
|
+
if (entry.decision === "allow") {
|
|
327
|
+
if (entry.applied === true)
|
|
328
|
+
return "approved, and the change went through";
|
|
329
|
+
if (entry.applied === false) {
|
|
330
|
+
return ("APPROVED, BUT THE CHANGE DID NOT GO THROUGH — nobody refused it; the request " +
|
|
331
|
+
"failed after it was approved");
|
|
332
|
+
}
|
|
333
|
+
return "approved; whether the change went through was not reported";
|
|
334
|
+
}
|
|
335
|
+
switch (entry.reason) {
|
|
336
|
+
case "declined":
|
|
337
|
+
return "refused: a human said no";
|
|
338
|
+
case "cancelled":
|
|
339
|
+
return "refused: nobody was there to be asked";
|
|
340
|
+
case "timeout":
|
|
341
|
+
return "refused: nobody answered in time";
|
|
342
|
+
case "error":
|
|
343
|
+
return "refused: the approval channel broke before any answer arrived";
|
|
344
|
+
default:
|
|
345
|
+
return "refused";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function withOutcomes(trail) {
|
|
349
|
+
return trail.map((entry) => ({ ...entry, outcome: approvalOutcome(entry) }));
|
|
350
|
+
}
|
|
351
|
+
function asRecord(value) {
|
|
352
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
353
|
+
? value
|
|
354
|
+
: {};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Atlas's own status wins when it declares one; otherwise it is derived from what we can
|
|
358
|
+
* see. Deriving is a last resort, not an interpretation of the answer.
|
|
359
|
+
*/
|
|
360
|
+
export function deriveStatus(response, approvals, needsApproval) {
|
|
361
|
+
if (looksLikeDelegationResult(response.body)) {
|
|
362
|
+
// A run happened, so its own status is the answer — on a 502 and a 429 as much as on a
|
|
363
|
+
// 200. Reading the HTTP code first would overwrite what the run said about itself.
|
|
364
|
+
const declared = asRecord(response.body).status;
|
|
365
|
+
if (typeof declared === "string" &&
|
|
366
|
+
ASK_STATUSES.includes(declared)) {
|
|
367
|
+
return declared;
|
|
368
|
+
}
|
|
369
|
+
if (response.status === 429)
|
|
370
|
+
return "rate_limited";
|
|
371
|
+
if (response.status < 200 || response.status >= 300)
|
|
372
|
+
return "error";
|
|
373
|
+
const denied = approvals.some((entry) => entry.decision === "deny");
|
|
374
|
+
return denied || needsApproval.length > 0 ? "blocked" : "ok";
|
|
375
|
+
}
|
|
376
|
+
// No run to speak of. A 2xx lands here too when the body is not a result — which is what
|
|
377
|
+
// made that case report `ok: true` alongside an `error` (N4).
|
|
378
|
+
if (response.status === 429)
|
|
379
|
+
return "rate_limited";
|
|
380
|
+
return "error";
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Reconcile our view of the relay with Atlas's.
|
|
384
|
+
*
|
|
385
|
+
* ATLAS WINS WHEN IT SPOKE. It is the side that decided whether `RelayGate` actually ran, so
|
|
386
|
+
* its `used`/`reason` beat anything inferred here — that is the whole point of v1.1 §D, and
|
|
387
|
+
* `disabled` in particular is a fact only Atlas knows. The `detail` sentence stays ours.
|
|
388
|
+
*
|
|
389
|
+
* When we never offered a block, ours wins unconditionally: `no_human` describes a client
|
|
390
|
+
* that cannot be prompted, which Atlas is never told about and could not report.
|
|
391
|
+
*/
|
|
392
|
+
function relayVerdict(payload, mode, reachedAgent, notEntitled) {
|
|
393
|
+
// FIRST, ahead of `not_reached`. Both are true of a 403 — the request certainly did not
|
|
394
|
+
// reach the agent — but only one of them tells the reader what to do about it.
|
|
395
|
+
if (notEntitled) {
|
|
396
|
+
return {
|
|
397
|
+
used: false,
|
|
398
|
+
reason: "not_entitled",
|
|
399
|
+
detail: RELAY_OFF_DETAILS.not_entitled,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
// FIRST, because it outranks both of the cases below. If the request never reached the
|
|
403
|
+
// agent then no channel was exercised whether or not one was offered, and saying the run
|
|
404
|
+
// went read-only (`no_human`) would be just as wrong as saying it was used: nothing ran at
|
|
405
|
+
// all. The client's inability to be prompted is not the actionable fact here and will
|
|
406
|
+
// surface on the next run, once whatever `error` names is fixed.
|
|
407
|
+
if (!reachedAgent) {
|
|
408
|
+
return {
|
|
409
|
+
used: false,
|
|
410
|
+
reason: "not_reached",
|
|
411
|
+
detail: RELAY_OFF_DETAILS.not_reached,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
// BEFORE `no_human`, deliberately, when both are true. In the hosted deployment even a
|
|
415
|
+
// client that CAN be prompted is of no use, so the deployment is the binding constraint and
|
|
416
|
+
// the one the reader can act on; telling them to switch clients would waste their time.
|
|
417
|
+
if (mode === "remote_mode") {
|
|
418
|
+
return {
|
|
419
|
+
used: false,
|
|
420
|
+
reason: "remote_mode",
|
|
421
|
+
detail: RELAY_OFF_DETAILS.remote_mode,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (mode === "no_human") {
|
|
425
|
+
// CONTRACT §7's last row: no elicitation capability means the field was never sent.
|
|
426
|
+
return {
|
|
427
|
+
used: false,
|
|
428
|
+
reason: "no_human",
|
|
429
|
+
detail: RELAY_OFF_DETAILS.no_human,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const verdict = atlasRelayVerdict(payload);
|
|
433
|
+
if (verdict) {
|
|
434
|
+
return { ...verdict, detail: relayDetail(verdict.used, verdict.reason) };
|
|
435
|
+
}
|
|
436
|
+
// No verdict: an Atlas older than v1.1. We offered the channel and have no reason to
|
|
437
|
+
// believe it was refused, so the optimistic read is the honest one — and it is advisory
|
|
438
|
+
// either way, never deciding whether an action proceeded.
|
|
439
|
+
return { used: true, reason: "", detail: RELAY_ON_DETAIL };
|
|
440
|
+
}
|
|
441
|
+
/** Assemble CONTRACT §5's result. Atlas's payload is carried, never rewritten. */
|
|
442
|
+
export function buildResult(response, approvals, mode,
|
|
443
|
+
/** Named in the not-entitled sentence, because entitlement is per product. */
|
|
444
|
+
product = "") {
|
|
445
|
+
const payload = asRecord(response.body);
|
|
446
|
+
// ABSENT WHEN EMPTY, never `[]` (v1.1 §B): Atlas's `public()` omits the key entirely, as
|
|
447
|
+
// it does for `narration`, `artifacts`, `error`, `cost_breach` and `usage`. Missing is
|
|
448
|
+
// read as empty, not as a malformed response.
|
|
449
|
+
const needsApproval = Array.isArray(payload.needs_approval)
|
|
450
|
+
? payload.needs_approval
|
|
451
|
+
: [];
|
|
452
|
+
// ATLAS'S TRAIL WINS WHEN IT SENT ONE. It is the only side that can fill in `applied`, and
|
|
453
|
+
// it records what happened to the STEP: an ask answered without a prompt appearing is a
|
|
454
|
+
// denial there and nothing at all here. Ours is kept separately rather than discarded,
|
|
455
|
+
// because that difference is exactly how an answer this side never prompted for shows up.
|
|
456
|
+
const atlasTrail = parseAtlasApprovals(payload);
|
|
457
|
+
const trail = atlasTrail ?? approvals;
|
|
458
|
+
const status = deriveStatus(response, trail, needsApproval);
|
|
459
|
+
const reachedAgent = !neverReachedAgent(response);
|
|
460
|
+
const relay = relayVerdict(payload, mode, reachedAgent, isNotEntitled(response));
|
|
461
|
+
// Correct the sentence when the channel was fine but nobody was ever behind it. The
|
|
462
|
+
// verdict is computed from the MODE and Atlas's advisory field, neither of which can
|
|
463
|
+
// see what the elicitation actually returned — so only here, with the trail in hand,
|
|
464
|
+
// is "was a person really asked" knowable.
|
|
465
|
+
if (relay.used && relay.reason === "" && nobodyAnswered(trail)) {
|
|
466
|
+
relay.detail = RELAY_ON_NO_ANSWER_DETAIL;
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
ok: status === "ok",
|
|
470
|
+
status,
|
|
471
|
+
// The product's answer, as the product wrote it. Nothing here summarises or re-reads it.
|
|
472
|
+
answer: payload.answer ?? null,
|
|
473
|
+
approvals: withOutcomes(trail),
|
|
474
|
+
approvals_source: atlasTrail ? "atlas" : "mcp",
|
|
475
|
+
elicitations: withOutcomes(approvals),
|
|
476
|
+
needs_approval: needsApproval,
|
|
477
|
+
applied_before_stop: readAppliedBeforeStop(payload),
|
|
478
|
+
permission_relay: relay,
|
|
479
|
+
atlas_response: response.body ?? null,
|
|
480
|
+
...atlasError(response, payload, product),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* A rejected credential and a refused action are unrelated problems, and a result that lets
|
|
485
|
+
* them read alike sends someone hunting for a human who said no when the real answer is that
|
|
486
|
+
* this server never got through the door.
|
|
487
|
+
*
|
|
488
|
+
* Atlas answers a bad `Authorization` with `401 {"detail": "unauthorized"}` — no `error`
|
|
489
|
+
* string of its own — so without this the caller would see a bare "error" and nothing else.
|
|
490
|
+
* A denial, by contrast, is `status: "blocked"` with a populated `approvals` trail.
|
|
491
|
+
*
|
|
492
|
+
* The token itself is NOT named here, only the variable that should hold it.
|
|
493
|
+
*/
|
|
494
|
+
export const UNAUTHENTICATED_DETAIL = "Signing in with your BrowserStack credentials SUCCEEDED, but BrowserStack AI refused " +
|
|
495
|
+
"the resulting token (HTTP 401). YOUR CREDENTIALS ARE NOT THE PROBLEM — this is a " +
|
|
496
|
+
"server-side configuration one, most likely `delegation.required_scope` not matching the " +
|
|
497
|
+
"scope the token was minted with. NOBODY DECLINED ANYTHING: the request never reached " +
|
|
498
|
+
"the agent, so no permission was sought and nothing was refused.";
|
|
499
|
+
/**
|
|
500
|
+
* The `error` field, from whichever side has one.
|
|
501
|
+
*
|
|
502
|
+
* Ours when there was no response to speak for itself or the credentials were rejected;
|
|
503
|
+
* otherwise Atlas's own `error` string, which `public()` includes ONLY when non-empty
|
|
504
|
+
* (v1.1 §B) — and failing that, its bare `detail`.
|
|
505
|
+
*
|
|
506
|
+
* That last rung matters: Atlas's pre-run refusals (400 bad body, 503 delegation not
|
|
507
|
+
* enabled) carry `detail` and no `error`, so without it a caller got `status: "error"` and
|
|
508
|
+
* nothing whatsoever to act on.
|
|
509
|
+
*/
|
|
510
|
+
function atlasError(response, payload, product) {
|
|
511
|
+
if (response.status === 0 && response.error)
|
|
512
|
+
return { error: response.error };
|
|
513
|
+
// BEFORE Atlas's own string: for an entitlement refusal ours names the product and says
|
|
514
|
+
// what to do, where Atlas's is a bare `detail` a user cannot act on.
|
|
515
|
+
if (isNotEntitled(response))
|
|
516
|
+
return { error: NOT_ENTITLED_DETAIL(product) };
|
|
517
|
+
// Atlas's own error string wherever it sent one — on a 502 that carries a full result,
|
|
518
|
+
// this is the run explaining its own failure, and nothing here should talk over it.
|
|
519
|
+
const reported = payload.error;
|
|
520
|
+
if (typeof reported === "string" && reported)
|
|
521
|
+
return { error: reported };
|
|
522
|
+
if (response.status === 401)
|
|
523
|
+
return { error: UNAUTHENTICATED_DETAIL };
|
|
524
|
+
if (!neverReachedAgent(response)) {
|
|
525
|
+
// The delegation ran. `status`, `approvals` and `needs_approval` already say what
|
|
526
|
+
// happened; "refused before the agent started" would simply be false.
|
|
527
|
+
return {};
|
|
528
|
+
}
|
|
529
|
+
// Bounded: this came off the wire and ends up in front of a person.
|
|
530
|
+
const detail = payload.detail;
|
|
531
|
+
const quoted = typeof detail === "string" && detail
|
|
532
|
+
? `: ${JSON.stringify(detail.slice(0, 200))}`
|
|
533
|
+
: "";
|
|
534
|
+
if (response.status < 200 || response.status >= 300) {
|
|
535
|
+
return {
|
|
536
|
+
error: `BrowserStack AI refused this request before the agent started ` +
|
|
537
|
+
`(HTTP ${response.status})${quoted}.`,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// A 2xx carrying something that is not a delegation result at all. Real Atlas does not
|
|
541
|
+
// emit this; saying so plainly beats reporting a success with an error attached (N4).
|
|
542
|
+
return {
|
|
543
|
+
error: `BrowserStack AI answered HTTP ${response.status} with no delegation ` +
|
|
544
|
+
`result${quoted}.`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* A result for a call that never reached, or never got past, Atlas.
|
|
549
|
+
*
|
|
550
|
+
* It keeps §5's shape — including the approval trail — because a failure AFTER an approval
|
|
551
|
+
* was granted is exactly the case where a caller most needs to know something may already
|
|
552
|
+
* have been applied.
|
|
553
|
+
*/
|
|
554
|
+
export function errorResult(message, approvals) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
status: "error",
|
|
558
|
+
answer: null,
|
|
559
|
+
// Atlas never answered, so there is no authoritative trail to prefer. Ours is all there
|
|
560
|
+
// is, and `applied_before_stop` is null because nobody measured anything.
|
|
561
|
+
approvals: withOutcomes(approvals),
|
|
562
|
+
approvals_source: "mcp",
|
|
563
|
+
elicitations: withOutcomes(approvals),
|
|
564
|
+
needs_approval: [],
|
|
565
|
+
applied_before_stop: null,
|
|
566
|
+
// The request never left this process, so it certainly never reached the agent.
|
|
567
|
+
permission_relay: {
|
|
568
|
+
used: false,
|
|
569
|
+
reason: "not_reached",
|
|
570
|
+
detail: RELAY_OFF_DETAILS.not_reached,
|
|
571
|
+
},
|
|
572
|
+
atlas_response: null,
|
|
573
|
+
error: message,
|
|
574
|
+
};
|
|
575
|
+
}
|