Skip to content

feat(ask): askBrowserstackAI — mid-run permission relay, with production defaults - #379

Open
sarve-shreyas wants to merge 32 commits into
mainfrom
askrelay/central-mcp
Open

feat(ask): askBrowserstackAI — mid-run permission relay, with production defaults#379
sarve-shreyas wants to merge 32 commits into
mainfrom
askrelay/central-mcp

Conversation

@sarve-shreyas

Copy link
Copy Markdown
Collaborator

What

Adds askBrowserstackAI — a tool that hands a plain-language task to BrowserStack's
agent (Atlas) and relays the agent's mid-run permission asks back to the calling
client
, so a write can be confirmed by the human sitting in front of it rather than
refused for want of anyone to ask.

The final commit points the compiled-in defaults at production:

constant was now
DEFAULT_ATLAS_URL https://ai-platform-service.bsstag.com https://workflows.browserstack.com
DEFAULT_AUTH_TOKEN_URL https://auth-preprod.bsstag.com/oauth2/v2/token https://auth.browserstack.com/oauth2/v2/token

Both hosts were verified rather than guessed: workflows.browserstack.com answers
/api/profiles with 401 {"detail":"authentication required"}, byte-identical to
staging Atlas.

⚠️ This removes a fail-safe — please read

Until now an install with no environment variables set talked to staging, where it
could not touch production data. It now reaches real customer data by default.

Every non-production deployment must set both explicitly:

ASK_BROWSERSTACK_ATLAS_URL      = https://ai-platform-service.bsstag.com
ASK_BROWSERSTACK_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token

Those staging hosts are recorded in the DEFAULT-PROD-HOSTS comment block for that
purpose. The resolved host is logged at info on first use, naming whether it came from
the env var or the compiled default, so a deployment pointed at the wrong Atlas shows up
in a log line rather than being inferred later from confusing data.

How the relay works

The ask rides the already-open POST /agent SSE response; each decision returns as a
fresh POST /agent/{run_id}/permission. Nothing dials back into the client — an earlier
design had Atlas POST to a caller-supplied callback_url, which cannot work when the MCP
server is on a laptop behind NAT. That transport was removed.

The privacy boundary is structural, not textual: what crosses to the caller is a
PermissionAsk of four named fields (perm_id, product, mode, description).
Tool inputs, outputs, paths and statuses live on a separate type that is never
serialized, so no regex is load-bearing for redaction.

Elicitations are routed onto the originating tool call's own stream via
relatedRequestId. Without that the SDK falls back to the standalone SSE stream, and a
host answering GET /mcp with 405 has none — the message is then silently dropped.

In hosted (REMOTE_MCP) mode the relay is off unless explicitly opted in with
ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY=true, because a paused tool call lives in one
process: with more than one replica the answer can land on a pod that never saw the
session.

Testing

  • Full suite green: 482 tests / 32 files, typecheck clean.
  • askBrowserstack*.test.ts add ~3,100 lines covering the stream transport, the
    elicitation path, timeout/deny handling, and the URL resolution (the constants are
    asserted as literals so a future repoint has to be deliberate).
  • Verified end-to-end against a live deployment: two mid-run approvals relayed over
    Streamable HTTP and accepted, with the write landing only after approval.

Reviewer notes

  • The prod repoint is the last commit (fa11a3a) and is reviewable on its own.
  • No changes to existing tool behaviour; the tool set grows by one.

sarve-shreyas and others added 30 commits August 19, 2026 15:58
Five tools driven by a prebuilt index artifact: listProducts, listEntities,
describeEntity, searchCapability, invokeEndpoint. 173 tm endpoints reachable from
one generic invoke tool instead of a hand-written tool per operation.

WHY AN INDEX AND NOT THE SPECS. The artifact (193KB, vs ~1.6MB for the three raw
harness bundles) is generated by the Python side and contains only data that has
already passed its outbound boundary. Had this half parsed openapi.yaml it would
BECOME the boundary, and every gate — route lint, the positive vocabulary rule, the
intent lint, the discovery denylist — would have to be reimplemented and re-tested
here, with a subtle mistake publishing what the other side withholds and no test to
catch it. An index cannot leak because the internal data is not in it: a test asserts
x-atlas-permission, parameter `target` names, body `pointer`s, raw key_facts, entity
`operations[]`, page/count params and strip_prefix are all absent. No hostname is
baked in either, so one artifact ships to every environment.

Endpoints are the handle, not capability names, and arguments arrive grouped into
path_params / query / body under the spec's OWN names — so a caller passes search
output straight back with no remapping. Grouping is not cosmetic: four tm operations
declare one name in two places (bulk-move has folder_id as both a path parameter and
a body field), which a flat map cannot express. Body fields carry json_path when the
nesting differs from the field name, because that nesting is unguessable and fails
silently — tm's folder create wants {folder: {name}} while the flat {name} a reader
would assume returns 200 and discards the field.

ONE invoke tool means one set of MCP annotations, so they describe the whole surface
honestly: readOnlyHint false (it can write), destructiveHint false (it can never
delete, because destructive endpoints are refused before binding). Write consent
therefore rests on user_permission enforced here rather than on a client hint.
Parameters are validated BEFORE permission is demanded, so a typo cannot send someone
to ask a human about a call that was never going to run.

Auth forwards the caller's own credentials as `Api-Token: <username>:<access_key>`,
which every /api/v1 route validates against IAAM OAuth2 v2. HTTP Basic is NOT usable
there — authenticate_with_authorization_header never reaches the Basic path — so
Api-Token is the scheme that covers the whole surface, with no token to mint, nothing
to refresh mid-pagination, and no auth-host/environment coupling.

Ported faithfully from the Python resolver, including the rules each learned from a
live failure: rows found by SHAPE (tm uses 30 distinct row-key names; a hardcoded
list of 13 missed 24 of them), a single wrapped record counted as one row (otherwise
every stats/summary read returned ok:true count:0), an empty array kept
distinguishable from a shape with no rows, paging at the declared ceiling (880
projects walked 30 at a time was the 17 Aug incident), search penalties that reorder
without excluding (conflating them once dropped 40 valid matches), and discovery mode
publishing scalars only minus a sensitive-name denylist.

35 new tests; the full suite is 311 across 31 files. Not yet wired into
server-factory.ts — registration is opt-in until the overlap with the 17 hand-written
testmanagement.ts tools is settled.
Registered alongside the existing tool adders, conforming to their contract —
(server, config) -> Record<string, RegisteredTool>, with trackMCP instrumentation in
the house style. Credentials come from BrowserStackConfig and are read PER CALL, not
captured, because the remote server rebuilds config per session and a captured
credential would outlive its session.

Fails soft on purpose: a missing or unreadable index registers nothing and logs why,
rather than throwing. A packaging problem must not take every other product's tools
down with it, and a test asserts the other adders still register when the artifact is
absent. CAPABILITY_REGISTRY_DISABLED=true is the kill switch;
CAPABILITY_REGISTRY_BASE_URL_<PRODUCT> overrides the host per environment, since no
hostname is baked into the artifact.

Fixes a real regression the end-to-end test caught. The port had been reading the
page-size ceiling off `max_items`, but that is a total-items cap on the Python side;
the ceiling lives on Operation.max_count, which `project()` deliberately withholds
because publishing page controls invites a caller to drive paging itself — the 17 Aug
failure. Nothing carried it into the artifact, so the resolver was about to page at
the product's default: exactly the incident, reintroduced by the port. Paging controls
now travel in a sibling `paging` map keyed "METHOD /path" (44 endpoints, 14 with a
declared ceiling). The resolver reads it; the search tool never echoes it, so the
published surface is unchanged and a test asserts no page control appears in a search
hit.

End-to-end coverage through the factory, with fetch stubbed: the five tools register
beside the hand-written ones; a real search returns a usable endpoint; an invoke
forwards `Api-Token: <username>:<access_key>` plus the attribution header, sends
count=300 rather than the product's default, and projects rows to the declared returns
so an undeclared field never reaches the caller; a destructive endpoint is refused
with no egress at all; and a write is refused until confirmed, with a typo'd parameter
surfacing as a parameter error rather than sending someone to ask a human about a call
that was never going to run.

capability-index.json ships at the package root and is listed in package.json `files`,
because tsc compiles TS and does not copy JSON into dist.

Full suite: 319 tests across 32 files, lint and tsc clean.
Precedence: config override, then the harness-declared host from the artifact, then
product-specific discovery, then refuse.

FIXES A BUG I WOULD HAVE SHIPPED. The first version hardcoded
test-management.browserstack.com for tm and invented hosts for a11y and tra from a
naming pattern. But the package already resolves TM's host per ACCOUNT: getTMBaseURL
probes test-management{,-eu,-in}.browserstack.com with the caller's credentials,
caching per process in stdio mode and deliberately never in REMOTE_MCP mode so one
tenant's region is not served to another. A fixed host would have failed every EU and
IN account on every call — and that is the live path behind the "Unable to connect to
Test Management" error users already see.

So tm now defers to getTMBaseURL, and a11y/tra are NOT guessed: an unknown host is
refused by name. A guessed host fails as a DNS error or a 404 that reads like the
caller's problem, when it is our missing configuration. Add a product here only once
its host is known rather than inferred.

The sharp edge is documented where it bites: a harness-declared host is one fixed
origin, so declaring one for a region-sharded product would send EU and IN accounts to
the wrong region. tm declares none for exactly that reason and falls through to
discovery.

Auth stays hardcoded to `Api-Token: <username>:<access_key>` by decision — it is a
BrowserStack-wide convention rather than a per-product quirk, and the artifact
therefore carries no auth information at all. The harness PR that declares the scheme
is consequently NOT a dependency of this half; it matters for the Python registry,
whose auth resolution is declaration-driven, and for the spec telling the truth.

Tests mock the resolver module (the probe is axios-based, so a fetch stub cannot reach
it) and assert each rung of the precedence, including that a request lands on the EU
host when that is the account's region.

Full suite: 325 tests across 33 files, lint and tsc clean.
Atlas resolves a product's host in three config rungs — an explicit per-session
override, then the host for the session's environment
(harness.extra_environments[env][product]), then the profile's own base_url — and all
three live in config, not in the harness bundle. This half had only the first. It now
has the same ladder:

  1. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>             explicit, environment-agnostic
  2. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV>       this environment's host
  3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}}  the same as one map, the closest
                                                        analogue of extra_environments
  4. the harness-declared host from the artifact
  5. product-specific discovery (tm is region-sharded)
  6. refuse, by name

ENVIRONMENT AND REGION ARE DIFFERENT THINGS, and the code says so where it matters. An
environment is a property of the DEPLOYMENT (this instance talks to preprod), so it is
read once from the process. A region is a property of the ACCOUNT (this user's data is
in EU), which is why region discovery runs per request and is never cached under
REMOTE_MCP. Conflating them would either send everyone to one region or re-probe on
every call.

Two refusals rather than fallbacks, both because the silent alternative is worse than
an error: a named environment with no host defined does NOT fall through to the harness
default (that would point a preprod deployment at production), and a malformed
CAPABILITY_REGISTRY_BASE_URLS is rejected rather than read as "no override" (same
outcome, arrived at by typo).

Full suite: 331 tests across 33 files, lint and tsc clean.
`invokeEndpoint` now answers {ok, completed, http_response:{status, body}} and
nothing else. Removed: row extraction by shape, the `returns` allowlist, the
scalars-only filter for undeclared schemas, item counting, ordering, trimming,
internal paging, and the guards that reported an empty projection as a
registration defect. envelope.ts is deleted outright.

WHY, from a live failure. Every one of those was a place we could be wrong ABOUT a
correct answer, and when we were, the caller saw a confident empty result rather
than an error. `GET .../test-case/priority` returned
`capability_shape_empty: the product answered with rows, but every field on them
was an object, an array, or a name withheld as sensitive` — while in truth the
extractor had looked one level too shallow (the response is keyed by the field
asked for, `{priority: {values: […]}}`) and the scalars-only rule then dropped the
only field left. A complete answer, reported as "this endpoint can report nothing".
The same call now returns 11 option ids, which are what every priority filter and
write needs.

`ok` mirrors the HTTP status; nothing else decides it. A non-2xx returns the
product's OWN body, which usually says more than we could ("Drill-down is only
available for User Workload Reports") — inventing a message discarded it. `error`
is set only when status is 0, i.e. there was no response to speak for itself.
`completed` reads one declared field (`info.next`) so a caller knows another page
exists; it is a peek, not a reshaping.

ONE REQUEST PER CALL, so paging is the caller's. That required publishing `p`, the
page-size parameter and `max_page_size` with each paginated endpoint — they were
hidden only while this side walked the pages itself, and withholding them would
leave a 913-row read stuck on page one at the product's default of 30.

Response headers are NOT returned. Measured on preprod: 26 of them, ~20 varnish
and timing noise, and `set-cookie` present — which matters because tm's own auth
scheme IS a cookie, so that header is credential-shaped rather than metadata.

323 tests, lint and tsc clean, live-verified against preprod.
Approach 1 shipped read-only because there was no way to ask a human mid-run:
the old surface refused every write and listed what it would have needed. MCP
elicitation is that missing piece, so this adds one tool that hands a
plain-language task to BrowserStack's agent and asks the person sitting in
front of the calling client whenever the agent reaches a step that changes data.

Transport is a caller-supplied callback URL, which matters more than it looks.
Because the agent makes the OUTBOUND request, the decision comes back on the
same connection to the same pod, so the affinity problem that would otherwise
force either a single replica or a Redis nudge does not arise here at all.

The listener is per tool call, on loopback, on a port the OS picks, behind a
fresh 256-bit bearer compared in constant time and checked before the body is
even read. The threat model is local: every other process on the developer's
machine can reach that port, and a human trained to approve prompts is the
exploit, so a caller that cannot present the run's token gets a 401 and no
prompt appears.

Everything ambiguous denies. Only accept plus confirm: true is an allow. A
cancel is a deny with reason "cancelled" rather than "declined" because a
headless client with nobody at the terminal returns exactly that — which is
what stops an unattended run from approving its own writes, and why an
elicitation is never retried.

A client that cannot elicit is not a failure case. permission_relay is omitted
entirely rather than sent empty, its absence selects the read-only gate, and
the result says why the write was refused instead of leaving the caller with an
unexplained failure. Nothing depends on sampling, which Claude Code does not
declare.

The result carries the approval trail and applied_before_stop, so a caller can
tell "nothing happened" from "some steps applied, then stopped" and does not
retry a half-applied task.
CONTRACT v1.1 replaced four inferences with facts, and two of them were traps.
`needs_approval` is absent when empty rather than `[]` — Atlas's `public()`
omits the key entirely, as it does for narration, artifacts, error,
cost_breach and usage. And the emitted status vocabulary is ok | error |
blocked | rate_limited; `interrupted` appears in a dataclass comment but never
on this path. Both were already read defensively, so this pins them down with
tests and comments rather than changing behaviour.

Atlas now reports whether the relay it was offered actually ran, which is a
fact only it knows, so its verdict is preferred over anything inferred here.
The reason worth the most care is `disabled`: the knob is off server-side, so
every write was refused for a configuration reason and nobody declined
anything. A caller who cannot tell that from a human saying no retries forever,
so each reason gets its own sentence and `disabled` says outright that nobody
declined. An unrecognised reason degrades to a sentence too — a newer Atlas
must not be able to throw inside a result — and is length-bounded, because it
arrives off the wire and ends up in front of a person.

The prompt is now framed with the product, which v1.1 approves: a bare sentence
with no attribution is a worse prompt than a framed one. The product is all the
callback carries, and the description still goes through verbatim, because the
human must approve what the model actually said, not a paraphrase.

Two judgment calls beyond the brief, both flagged for veto in the result file.
Atlas's own `error` string is lifted to the top level instead of being left for
the caller to dig out of `atlas_response`. And `isError` now marks a call that
failed rather than one that was refused: a blocked run is the feature working,
and rendering a correct refusal in red invites the retry loop these distinct
reasons exist to prevent.
Atlas's delegation route accepts exactly two credentials, both in the
Authorization header: the shared delegation token, or a BrowserStack central
JWT. There is no Api-Token path. That header is right for the product APIs and
was carried over here by analogy, so as built every call would have returned
401 before the gate, the relay or the model were reached.

So /agent now sends Bearer <shared delegation token>, from its own env var
resolved per call with the same precedence as the host — a preprod deployment
must not be able to fall back to a token meant for somewhere else. It refuses
by name when unset, naming the variable and never a value, and the token
appears in no log line, no error message and no result.

Api-Token is removed rather than left as harmless clutter. Atlas never reads it
on this route, and it carries the user's access key, so sending it pushed a
secret across a trust boundary to an endpoint with no use for it and into every
request log on the way. The access key now does not leave this process on this
route at all; a call no longer needs one to succeed, and the header key set is
asserted exactly so it cannot creep back.

The shared token authenticates the caller but not the principal, so Atlas reads
the acting user from the body. user_id carries the configured username, omitted
entirely rather than sent empty. A caller can claim any user_id on this path;
that is Atlas's documented design for the shared-token route, not a hole to
plug here.

A rejected credential and a refused action are unrelated problems, and Atlas
answers a bad bearer with a bare {"detail": "unauthorized"} and no error string
of its own. A 401 therefore gets its own sentence, leading with the fact that
nobody declined anything, so nobody goes hunting for a human who said no when
the real answer is that this server never got through the door.
Atlas omits its permission_relay verdict on every refusal that dies before the
delegation layer — 401, 400 and 503 all answer with a bare {"detail": …} — and
an unreachable Atlas has no body at all. The verdict reader treated a missing
block as "an Atlas older than v1.1" and fell back to the optimistic reading, so
a 401 came back asserting that BrowserStack asked before each change and the
answers were in `approvals`, with `approvals` empty, alongside an `error` field
saying nothing had ever been asked. Three fields in one payload contradicting
each other, and zero prompts had appeared.

That is the confusion the `disabled` sentence was written to prevent, one layer
earlier: a reader who cannot tell "nobody was asked" from "somebody said no"
retries forever. So a request that never reached the agent now says so in its
own words, and says it outranks both of the other readings — including the
no-elicitation one, because a client that cannot be prompted did not "run
read-only" either when nothing ran at all.

Pre-run refusals also carry `detail` and never `error`, so a 400 or a 503 used
to arrive as status "error" with nothing whatsoever to act on. Atlas's detail is
now surfaced with the status that carried it, bounded, since it comes off the
wire and ends up in front of a person.

The withheld-placeholder fixture asserted against a string Atlas does not emit.
Runtime was never affected — the description passes through verbatim — but it
is the one string here a test can assert on and be confidently wrong about, so
it now uses the bytes collector.py actually builds.
Only Atlas can know whether an approved step's request actually landed: the
gate returns before anything is sent, which was the whole of D2. So this side
stops deriving applied_before_stop from "any allow preceded a deny" — a rule
that counted an approval whose egress then failed as applied, and so lied in
the exact direction the field exists to prevent. The derivation is deleted
rather than left unused, because code that still computes a fact we no longer
trust is an invitation to rewire it.

A missing applied_before_stop is now null, not false. Atlas sends the field
whenever a gate ran, including false and including an empty trail, so absence
means either that no gate ran or that this Atlas predates the field. Reporting
either as a measured false would assert something nobody checked, in the
direction that makes a caller retry a task that already half-applied.

Atlas's approvals trail wins whenever it sent one, and an empty trail it did
send counts as one — "the relay ran and nothing was asked" is a fact, not a gap
to fill from our own records. Each entry is rebuilt on arrival rather than
trusted: a decision that is not exactly "allow" reports as a refusal, so a
garbled trail fails closed in the reporting the same way the wire does.

Our own trail is kept beside it rather than folded in, because where the two
disagree the disagreement is the signal. A callback answered with no prompt
appearing is a denial to Atlas and nothing at all here, and that pair is what a
probe of the loopback port looks like; merging the trails would destroy the only
evidence it happened.

Every entry now carries a sentence, because "approved, then the request failed"
is a genuinely different thing to tell a person than "somebody said no", and an
approval nobody measured is neither — it is not rendered as a failure.
Atlas answers HTTP 502 with a complete result body when a delegation ran and a
step then failed, and 429 carries a full body too. The not-reached rule read
"any non-2xx" and ran ahead of Atlas's own verdict, so an approved write whose
egress failed came back saying nothing had been asked and nothing refused —
while approvals in the same payload showed the prompt shown, approved, and not
applied. That is the lie the rule was added to prevent, now told on the one run
where it costs the most: the reader is talked out of checking for a partial
application at exactly the moment one is possible.

The status code describes the outcome; the body describes whether there was a
run. Only the second question decides this, so a body carrying ok, status,
answer or approvals means the delegation ran, whatever code carried it, and
not-reached is left to a transport failure or a body that is not a result at
all. Status derivation and the error string follow the same rule: a run's own
status outranks its transport code, and "refused before the agent started" is
no longer said over a run that plainly started.

A 2xx carrying no result used to report ok: true with an error attached. Real
Atlas never emits that, but a shape that contradicts itself is not a shape to
leave lying around, so it now reads as the error it is.

The tests assert the whole permission_relay object beside approvals rather than
picking at single fields, because this ordering has now bitten twice and the
failure mode both times was two fields in one payload disagreeing.
The shared delegation token is gone from Atlas, so the only way in is a
BrowserStack central JWT minted from the caller's own username and access key.
That is a better door than the one it replaces, not just a different one:
validate_delegation_token refuses any token without user claims, so the minted
one is user-attested. Atlas sets principal_verified, takes the acting user from
signed claims rather than from our request body, and re-uses the same JWT for
product egress — so the write a human approves runs as that human rather than
as a shared service account. The user_id we send stops being a claim anyone
could forge, and is kept only because dropping a field from a frozen wire
format is not a change one side gets to make alone.

Both halves of the scope are load-bearing and neither can be tidied away.
central_ai_s2s is what Atlas matches on, but it is a client_id+secret scope and
asking for it alone with a username and access key is refused outright; it
becomes obtainable only paired with oauth_user_profile. The constant says so,
with the citation, because it reads like redundancy and is not.

Tokens are cached rather than minted per call, and the staleness margin is the
whole /agent budget plus a minute rather than the usual small skew. Atlas holds
this token for the life of the run and re-uses it for egress, so handing out one
with sixty seconds left would mean a human approves a write and the request that
follows dies on an expired credential — the precise failure the cache exists to
avoid. The key is hashed rather than stored, so rotating a credential mints
immediately without leaving the secret in a map for the life of the process.

The token endpoint's error body can echo the credential straight back, so only
its status ever crosses. Nothing here logs the access key or the minted token,
and neither appears in a result or an error.

Three failures that look alike and are not: the endpoint refusing the
credential, the endpoint being unreachable, and Atlas refusing a token we minted
successfully. The third is what a misconfigured deployment actually hits, and it
now says so — the credentials were fine and required_scope is the likely cause —
rather than telling someone their password is wrong. None of the three is a
permission denial, and all three still report as never having reached the agent.
The requested scope becomes oauth_user_profile ai_agent_notify, moving with
Atlas's required_scope. Only the second half changes; the first stays for the
reason it was always there, as what makes the pair obtainable through the
username and access key flow at all.

The comment on the constant now records why this is a narrower door than the one
it replaces, from the merged railsApp change: ai_agent_notify is documented as
client_id/secret auth, the username+access_key allow list is still only
user_management and oauth_user_profile, it carries a new application
registration gate, and railsApp defines it as the product-to-agent direction
while we use it as an agent-to-Atlas inbound credential. The scope it replaces
was deliberately excluded from that gate. Those are the facts that decide
whether this can be minted at all, so they belong next to the string rather than
in a report nobody reads at three in the morning.

Because it may simply not be issuable, the refusal is now told apart from a
rejected credential. The two need entirely different fixes — provisioning versus
a password — and one message for both sends someone to the wrong place. The
refusal names the scope, says outright that the credentials are not the problem,
and says that this server will not quietly retry with a weaker one. It does not:
a silent downgrade to a different authorization is precisely the thing nobody
notices until it matters, and every refusal shape is asserted to make exactly one
attempt carrying exactly the chosen scope.

Classifying the refusal means reading the failure body, which the credential
rule forbids surfacing. So only the OAuth2 error code is consulted, only when it
is one of the fixed spec tokens, which cannot carry a credential the way the
free-text description demonstrably can — and it is discarded after classifying.
Nothing from the body reaches a message; where no usable code exists the status
decides, since a bad request is a 400 and a bad caller is a 401.
A user approved a prompt against preprod and was told they had refused it. The
schema was at fault, not the client: confirm was a required boolean defaulting
to false, so a client renders one unchecked checkbox, pressing approve submits
accept with confirm false, and the mapping turned that into deny/declined. The
approve path was unreachable unless the user also toggled a field they had no
reason to think was load-bearing. "form" is the only elicitation mode the SDK
has, so there was no confirm mode to move to.

For a yes/no approval the action already is the answer — accept, decline and
cancel carry exactly the three outcomes needed — and a boolean inside the form
duplicated that signal while contradicting it. So confirm becomes optional, with
no default, and an accept that carries no confirm field is an approval.

This looks like a loosening and is not. The guard against an unattended run was
never that boolean; it is that a headless client returns cancel, which was
measured and is unchanged, and an accept means the protocol itself says a human
accepted. What the old shape actually bought was a false denial, indistinguishable
in the result from a human saying no — the same confusion that two earlier fixes
existed to remove, arriving this time through the front door. The comment that
claimed the protection is replaced by that reasoning rather than left standing
over code that no longer relies on it.

An explicit false is still a refusal, because a client that does render the
checkbox and a user who unticks it have said no. Only absence is consent, and
nothing is coerced: a string "true" or a 1 is a malformed answer, not an
approval. The emitted schema is asserted to carry neither a required array nor a
default, so this cannot come back quietly.
A boolean inside a form whose accept action already means approval can only
agree with that action or contradict it, and when it contradicts we cannot tell
which the human meant. Accept with confirm false is either "I approved and never
saw the checkbox" — the false denial a user hit on preprod — or "I unticked it
deliberately". Guessing either way is wrong for the other case, and it could not
be settled by inspecting what the client sends, because that binary is compiled
and its strings too fragmented to read. So the question is removed rather than
answered: requestedSchema now asks for nothing, and decline already offers an
unambiguous refusal in the same dialog.

Fail-closed is untouched and never rested on the boolean. A headless client with
nobody at the terminal returns cancel, which is measured and is a deny, and that
is the whole of what stops an unattended run approving itself.

A volunteered confirm false is still honoured, documented as defensive only,
since no client can be expected to send a field nobody asked for.

The answer's shape is now logged once per ask — the action, whether content came
back, and whether confirm was among it. Only a fixed enum and a boolean, so no
description, credential or typed text can ride along, which is asserted rather
than assumed. Next time this misbehaves the client's answer can be read instead
of inferred.
A test harness is running the documented sample queries to measure which tool
Claude picks, but the credentials available are preprod-only and the classic
tools probe production, so they 401. That does not corrupt the selection itself,
which happens before any call, but it corrupts what the selection means: a 401
can send the model off to retry with a different tool, so "chose ours because it
fit" and "chose ours because the other one was broken" become the same
observation.

So the probe list gets an env override, named and parsed after the capability
registry's, and it REPLACES the built-in list rather than extending it —
appending would leave the production hosts probed first and the 401s would come
straight back. With the variable unset the list is exactly the three production
regions in exactly the order they were already in, which is asserted rather than
assumed, because this is a harness affordance and must not be able to change
what ships.

An override that parses to nothing falls back to the built-in list rather than
leaving an empty probe loop, which would surface as "unable to connect" with no
detail. It warns when it does: quietly talking to production when someone asked
for preprod is the failure this exists to prevent, so it must be visible in the
log rather than inferred from a 401 much later.

The module-level cache is now keyed on the list it was discovered under. Skipping
it entirely under an override would have worked too, but keying it also covers
the reverse — a value minted against preprod being handed to a run that has since
gone back to production — where the override would appear to work while silently
returning the wrong environment's host.

Accessibility and observability are deliberately not covered. Their hosts are
hardcoded inline at nine call sites across five files with no host-resolution
module to hook, so the same shape does not drop in; giving them one means
rewiring egress across the codebase for a test affordance, which is not a trade
worth making here.
This package publishes as @browserstack/mcp-server and remote-mcp-server
consumes it, so a version bump hands the hosted multi-tenant server a tool built
entirely for stdio. Three things break there. The callback listener binds an
ephemeral loopback port per tool call, which in a shared process is one listener
per concurrent call with a per-run bearer as the only thing keeping tenants
apart. Atlas could not reach it regardless, because a 127.0.0.1 callback names
the Atlas pod's own loopback and its SSRF allowlist refuses it. And elicitation
is a server-initiated message, while the remote transport is stateless by
deliberate design — 841c6358 removed sessions because they broke behind two
replicas, and justified it on the grounds that nothing used server-initiated
messages. This feature is the exception that commit did not have to consider.

So in remote mode the listener is never bound — not bound and left to fail on a
callback that cannot arrive — and permission_relay is omitted, which selects the
read-only gate that already works and is already tested. The reasoning sits at
the guard rather than in a report, including what re-enabling it would actually
take, because the next person to try will otherwise spend a day rediscovering
the session problem.

The result says which of three things stopped the relay, since they need
different responses: nobody could be asked, the request never arrived, or this
deployment cannot receive a callback. The last one is new, and it deliberately
outranks the first when both hold: in the hosted mode even a client that can be
prompted is no use, so telling someone to switch clients would waste their time.
It still loses to a request that never reached the agent, which is the more
immediate fact. What was a boolean is now a three-valued mode, because it was
never really a boolean.
This was the only tool here requiring install-time host configuration. Every
other one carries its production host in the code and treats env vars as an
override, and the capability registry's resolveBaseUrl is the richer form of the
same idea; this is that, for Atlas. Both existing overrides stay ahead of the
map, so nothing already working stops working.

Each host was established rather than assumed, and the comment says how: prod
answers the same 401 as staging Atlas and 404s on /agent only because it runs an
image without the delegation route yet; stag and preprod are the two ingress
hosts serving one backend. Staging pointing at preprod's auth server looks like a
copy-paste and is not — preprod is configured there as an extra environment and
accepted configs include extras, so a preprod-minted token validates against
staging, which was confirmed live rather than reasoned about.

An unset environment refuses instead of defaulting to production. The registry
already refuses when an environment is named but has no host, on the grounds
that falling back would silently send a preprod deployment at production, and
that argument only gets stronger for a tool that writes. The cost of refusing is
one environment name at install time, reported by name the first time the tool
runs; the cost of defaulting is an unconfigured install quietly changing
production data. A selector that is wrong refuses by name, where a URL that is
wrong talks to the wrong place in silence — which is the whole reason to prefer
naming an environment over pasting a host.
The three-environment map and its selector go away. The decision is to ship a
single hardcoded staging host for now and repoint at production later, so that
is what this does: one default, one override, no map and no ASK_BROWSERSTACK_ENV.
That also brings this tool into line with every other one here, which carries its
host in the code and treats the env var as an escape hatch.

A hardcoded default removes the refusal that task 13 added, and the argument for
that refusal was about production: an unconfigured install quietly writing to
real data. Defaulting to staging inverts it — an unconfigured install cannot
touch production — but it is still wrong for a production deployment, which would
read and write the wrong environment's data without any sign that it was doing
so. So the resolved host is announced at info on first use, naming whether it
came from the environment or from the constant, once rather than per call so it
is not something to scroll past. A deployment pointing at the wrong Atlas should
cost one log line to notice, not a confusing afternoon.

The constant carries the rest: that this is an interim placeholder, that
production is workflows.browserstack.com with its own auth endpoint and how that
host was verified, that publishing to npm means an install with no configuration
talks to staging, and that both the constants and the tests asserting them must
change before production users get the tool. The tests assert the literals for
exactly that reason — repointing should be a deliberate edit rather than
something that passes quietly.

The module docblock claimed no host was compiled in, which is now the opposite of
what the code does, so it says what the code does instead.
Atlas is adding a per-product entitlement gate to the delegation route, the one
the WebSocket path already had, and it answers 403 when an account is not on the
product's agent flag. That gate fails open on its side — Redis down, a flag never
seeded, an unknown product all allow the request — so a 403 is a deliberate "this
account is not enabled" and never an outage, which is what makes it worth a
sentence of its own.

Until now it would have arrived as a generic pre-run refusal quoting Atlas's bare
detail: accurate and useless. It is now its own outcome, kept apart from the four
authentication failures because the fix is different from all of them. It is not
a rejected credential, and saying so explicitly matters — the credentials
authenticated fine, so anyone reading a vaguer message would go and rotate a
working access key. It is not a permission denial either: nobody declined
anything and the relay is irrelevant, so it takes precedence over both the
never-reached and remote-mode readings, which are technically true of a 403 and
tell the reader nothing they can act on.

The message names the product, because the flags are per product and an account
entitled for one is not necessarily entitled for another. A bare "not enabled"
sends someone to their admin asking about the wrong thing.

Classification keys on the status, not on the sentence in the body, and says so
where someone might be tempted otherwise. Matching the prose would break silently
the first time anyone rewords it, and silently means falling back to the generic
error this change exists to replace. Atlas may add a structural code to that
body; the place to prefer it is marked, with the status left as the fallback for
an older Atlas.
searchCapability, invokeEndpoint, listProducts, listEntities and describeEntity
are a different piece of work — Approach 2, driven by a prebuilt index — that
this branch inherited by being cut from feat/capability-registry. They are not
part of what is shipping here, and a release carrying five extra tools nobody
asked for is a release nobody can reason about, so they come out.

Nothing is lost. Those five commits are untouched on feat/capability-registry,
locally and on ctoi, so this removes them from one branch rather than from the
world; restoring them is a merge, not an archaeology exercise.

The coupling turned out to be a single import in the server factory: the
relay stopped importing the registry's auth header when /agent moved to a
central JWT, and nothing else ever reached across. Also dropped: the 205KB
capability-index.json artifact and its entry in the published files list, since
neither has a consumer here any more, and the citation in tm-base-url that
pointed at a naming precedent no longer in this tree — a comment referring to
code that is not there is worse than no comment.
The tool sat among forty-four hand-written ones without saying when it should be
preferred to any of them, and a description is the only thing steering that
choice — the client picks from these words alone, before a single call is made.
So it now leads with the case it is actually for: no other tool here fits, or the
ones tried did not get there.

The ordering is the point rather than the wording. When to reach for it comes
first, because that is the question being asked at selection time; what it does
and what consent looks like follow, because those only matter once it has been
chosen. It also says plainly to prefer a specific tool when one fits, since a
dedicated endpoint is faster and more predictable than an agent working out its
own API calls.

The assertion on it is anchored to the start of the string, so a later edit
cannot quietly demote the fallback framing into a trailing sentence nobody reads.
CONTRACT v2. `callback.ts` binds 127.0.0.1:<ephemeral> and hands Atlas the URL,
which works only when Atlas shares that loopback. For a real user it cannot work
at all: this server runs on their machine, and a laptop behind NAT is not
addressable from a pod in BrowserStack's cluster. No configuration creates that
route. Every successful relay run to date used a locally-run Atlas — the shape v1
was written for, not the shape a user is in.

A1 makes both connections outbound from here: the ask arrives on the open
`POST /agent` SSE response, and each decision goes back as a fresh short
`POST /agent/{run_id}/permission`.

Adds `stream.ts` — `AgentStreamTransport` and `DecisionTransport` seams,
`splitFrames`, `parseFrame`, and fetch-based implementations of both.

This file deliberately decides NOTHING. Whether a human approved, how an
elicitation outcome maps to allow/deny, what the result looks like — all of that
stays in `relay.ts`, untouched and shared with the callback transport. Keeping the
judgement out of the transport is why swapping A2 for A1 does not put the
fail-closed behaviour at risk. `callback.ts` is untouched too: it stays the working
path for a local Atlas until the Atlas half ships (v2 §7.5), because deleting it
now would leave the feature with no working transport at all.

Chunk boundaries get most of the test attention on purpose. A frame split
mid-JSON, two frames in one read, a trailing frame with no blank line, an
unparseable frame, the heartbeat — a dropped frame is an ask that never reaches
the human, i.e. a write that silently never gets approved and never says why, and
it is the failure a hand-rolled SSE parser actually produces. An unparseable frame
is dropped rather than guessed at: the worst case is that Atlas's gate denies on
its own expiry, never that something is approved. A non-stream response throws
instead of iterating empty, because an empty iteration is indistinguishable from
"the run finished and said nothing". A decision that never left reports 0 rather
than a refusal, so a lost request is never reported as a human saying no.

Timeouts follow v2 §4: the whole-run guard is 1800s because the stream now spans a
run that may hold several 300s approvals in series, so the old 330s outer rung
meant nothing. The per-ask rung (270s elicitation inside Atlas's 300s gate) is
unchanged and still enforced where it belongs.

16 new tests, 480 passing overall. Typecheck and lint clean.
`askBrowserstackAI` now drives CONTRACT v2. The relay block it sends is
`{ mode: "stream" }` — no URL and no per-run bearer, because nothing dials in.
The ask arrives on the open `POST /agent` response and each decision goes back on
its own `POST /agent/{run_id}/permission`.

`relay.ts` is untouched, as v2 §7.3 predicted: `relayOneAsk`, `decide`,
`buildResult`, the trail precedence and every `permission_relay` reason are shared
with the callback transport. The new `runStreamed` is a loop that reads events,
elicits, posts the decision and hands the result to `buildResult`. It decides
nothing.

TWO REAL BUGS the existing tests caught, both in that loop:

1. **The HTTP status was being dropped.** I built the AgentResponse with a
   hardcoded 200, which lost the 401/403 that `relay.ts` needs to tell a rejected
   credential from an account without the feature from an ordinary failure — three
   different sentences for the user, all collapsing into a generic error. The
   transport now carries the status on a result synthesised from a non-stream
   reply; a real stream is 200 by definition.
2. **A failed elicitation abandoned the run.** `relayOneAsk` RETHROWS on an
   unexpected failure, which under A2 was load-bearing: the throw made the inbound
   callback answer 500 and Atlas read that as a deny. Under A1 there is no inbound
   request to fail, so the throw escaped and left Atlas waiting out its full 300s
   gate — a client hiccup becoming a five-minute stall. Now caught and converted
   into the explicit deny the throw used to imply.

Also: a fetch failure maps to "BrowserStack AI could not be reached" rather than
leaking "connection reset"/"fetch failed", matching the request/response transport
— the upstream detail names our plumbing, not anything the reader can act on.

An Atlas that predates v2 answers `POST /agent` with plain JSON. The stream
transport yields that as a single `result`, so the tool degrades to a correct
read-only answer with `permission_relay.reason: "disabled"`. No version flag and
no negotiation, which is what makes it safe to ship this before every Atlas serves
A1.

Test migration: repointing the one `atlas()` stub from A2 to A1 fixed 12 of the 30
failures on its own — which is the evidence that these assertions were about
`relay.ts` rather than the transport. The stub now streams `run`, holds each
`permission` frame open until the tool answers it on the decision endpoint, then
sends `result`. Four tests had premises A1 makes impossible and were rewritten to
assert the same invariant in the new shape rather than deleted:

* the stray-process-with-the-wrong-bearer hazard is gone (no port, no bearer), so
  that test now covers what remains: a refused decision (409/404) must not be
  re-sent, because a retry could land an approval on a step the run has moved past.
* listener teardown is gone (nothing is bound), so that test now pins that a
  transport failure still surfaces as a clean result rather than an escaping throw.
* D4's loopback probe becomes "Atlas denies without us prompting" — same
  invariant: the two trails stay separate and ours being empty is the only record
  that no human was asked.
* N1's 502-carrying-a-result cannot occur under A1 (an ask needs an open 200
  stream), so the failure now arrives in the `result` event of a stream that did
  prompt and was approved. Same invariant: a run that asked and got a yes must not
  report "nothing was asked".

480 tests passing, typecheck and lint clean.
Observed live against a real Atlas. A headless client returned `cancel`, and the
one result carried both of these:

    permission_relay.detail: "This client can prompt you, so BrowserStack asked
                              before each change and the answers are in `approvals`."
    approvals[0].outcome:    "refused: nobody was there to be asked"

Two sentences in the same payload contradicting each other, and the reader pays
for it — exactly the confusion the `disabled` vs `no_human` sentences exist to
prevent, one layer up.

Cause: `relayVerdict` picks the sentence from the negotiated MODE and Atlas's
advisory field. Neither can see what the elicitation actually returned, so a client
that DECLARES elicitation capability but answers `cancel` gets a sentence claiming
a human engaged. "The channel is usable" and "a person actually answered" are
different facts, and only the second licenses the word "answers".

Fixed in `buildResult`, the one place holding both the verdict and the trail: when
the channel worked and every ask came back `cancelled`/`no_human`, the detail says
nobody was present and names what to do about it. Guarded three ways so it cannot
overreach — it does not fire when a person genuinely declined (`declined` is a
human saying no, which needs a different sentence), nor on an empty trail (nothing
was asked at all, already described correctly), nor when any ask was allowed.

484 tests, typecheck and lint clean.
Observed live. Preprod auth was down, returned 503, and the tool answered
"Your BrowserStack credentials were rejected … Check BROWSERSTACK_USERNAME
and BROWSERSTACK_ACCESS_KEY" — sending someone to audit env vars that had
worked minutes earlier. The reader's own guess ("is preprod down?") was
right and the message argued against it.

`mintOnce` had two branches: unreachable (status 0) and "not 200", and the
second one read the body to tell a scope problem from a credential one.
A 5xx has neither, so it fell through to the credential sentence. Now a
`status >= 500` check sits between them, BEFORE the refusal branch, with
its own detail that says the service is failing and states plainly that
the credentials are not the problem. The status alone settles it: OAuth2
puts a bad client at 401/403 and a bad request at 400, so nothing in the
5xx range is ever a statement about the caller.

The 401 path is asserted alongside it, because the split is only worth
having if a genuine rejection still reads as one — the two need opposite
actions from whoever reads them.

Formatting churn in this file is `npm run build`'s own prettier step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Atlas removed it, so nothing on this side has anything to talk to. A2
opened a loopback listener per tool call and handed Atlas its URL; that
only ever worked for a caller on the same host as the Atlas process, which
is not how anyone runs this — an MCP server runs on a laptop and Atlas runs
in a cluster. A1 (the ask arrives on the open `POST /agent` stream, the
decision goes back as a fresh POST) is the shipped path and is verified
end to end against a live Atlas.

Deleted: `callback.ts` in full — the listener, its bearer, its 401/400/404
handling — plus `AgentTransport`/`fetchAgentTransport` in `egress.ts` (the
one-request-one-response shape has nothing left to describe now that
`/agent` is read as a stream), and `AskDeps.transport`/`.startListener`.
`PermissionRelay` narrows to `{ mode }`; `mode` stays optional because the
field's ABSENCE is what selects Atlas's read-only gate and that has to
remain expressible.

`parseAsk` MOVED rather than went with it, into `stream.ts`, and is now
actually wired in: `runStreamed` was doing `event.data as PermissionAsk` —
a bare cast, no validation. So a frame with a blank description would have
produced a prompt asking a human to approve nothing, and one with an id
off Atlas's `perm-<32 hex>` shape would have produced a prompt whose
answer could never be routed back. Every reason that function existed was
a property of the ASK, not of the direction it arrived from.

Two tests were A2's alone. The remote-mode pair keeps its meaning and
loses its wording: "binds no port" is true by construction now, and what it
always guarded — the hosted deployment offers no relay, because a stateless
replica cannot raise a server-initiated elicitation (v2 §5) — is what it
now says. "Tears the listener down once the call ends" is gone: its subject
does not exist, and it had already gone vacuous, reading
`permission_relay.callback_url` off a body that carries only `{mode}` and
passing on the throw from probing `undefined`.

`host_not_allowed` stays mapped in `relay.ts` but is rewritten: only an
Atlas that predates A1 can send it, and such a deployment still deserves a
sentence rather than a raw enum. 479 tests pass; build and lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It doesn't any more. Atlas removed the guard that replaced a route-shaped
`description` with "(approval request withheld: …)" — it asked a human to
approve a sentence they could not read — and CONTRACT v2 §3 was amended
to match.

No behaviour to change on this side: the description was always passed
through verbatim, deliberately, because paraphrasing it would mean the
human approves something other than what the model said. Only three
comments were wrong, in `register.ts` and `relay.ts`, each asserting the
string had already been checked upstream.

The placeholder-framing test stays and is retitled: an Atlas predating
the amendment still sends one, and it must not read as a bug once framed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the relay work up to 1.3.0, which is what the hosted Remote MCP
server installs. No conflicts: main's 10 commits are the per-tool MCP
annotations, a docs note and the version bump, none of which touch
src/tools/ask-browserstack.

Done BEFORE vendoring this package into remote-mcp-server for a staging
test. Without it we would have shipped 1.2.36 into a service that expects
1.3.0 — downgrading every other tool there by 10 commits and confounding
any session test with unrelated regressions on a shared environment.

479 tests pass, tsc clean, and the relay is intact
(`addAskBrowserstackAITool` still wired into server-factory, all seven
ask-browserstack modules present).

Local only. This branch is still deliberately unpushed: the repo is public
and the relay is unreleased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`relayMode` refused unconditionally in REMOTE_MCP. That refusal has become
factually wrong, and its own message said so: "this deployment has no way
to put an approval prompt in front of you". It now does.

    if (appConfig.REMOTE_MCP && !allowRemoteRelay()) return "remote_mode";

MEASURED, not assumed. Against the hosted Streamable HTTP server with
per-session servers (browserstack/remote-mcp-server#96): initialize issued
an Mcp-Session-Id, the following tools/call was served by the SAME instance,
and the run completed over HTTP. The thing that used to make this
impossible was the host discarding its server after each POST, so an
elicitation answer — which arrives on a SEPARATE POST — reached an instance
that had never asked anything while the real one sat suspended.

OFF BY DEFAULT, and that is not caution. It depends on a property of the
HOST that this package cannot observe:

  * the host must keep one McpServer alive per session, and
  * it must pin a session to a pod. Sessions are per-process, so without
    affinity the answer POST can land on a replica that has never seen the
    session.

Both were demonstrated the hard way: the first hosted attempt 404'd because
it ran during a rollout, when two pods were briefly serving and the
follow-up POST hit the one without the session. That failure is
intermittent and reads like a client bug, which is exactly why it must not
be a default.

The flag only lifts the blanket refusal. `relayMode` still asks whether
THIS client declared `elicitation`, so a client that did not still gets a
read-only run — there is a test for that, because a flag that quietly
forced asks onto clients unable to show them would be worse than the
refusal it replaced.

Also rewrote the rationale above `relayMode`. It asserted the relay was
"a STDIO-ONLY FEATURE ... A config flag will not do it", which was correct
when written and is now the opposite of true. It records what actually
blocked it (a paused call cannot be moved between processes), why
841c6358 was right at the time, and what a hosted operator must have in
place before turning this on.

481 tests pass, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sarve-shreyas and others added 2 commits August 27, 2026 20:45
Over Streamable HTTP a server->client message is written to the stream of
the request it RELATES to. `elicitInput` was called with no
`relatedRequestId`, so the SDK fell back to the standalone SSE stream — and
a host that answers `GET /mcp` with 405 has none. The message was then
dropped in silence:

    const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId);
    if (standaloneSse === undefined) {
        // Stream is disconnected - event is stored for replay, nothing more to do
        return;
    }

The tool then waited out its 270s and Atlas's gate expired, so the run came
back `decision: "deny", reason: "timeout"` — "refused: nobody answered in
time". The person is blamed for not answering a question that was never
put in front of them, which is the worst possible shape for a
consent mechanism to fail in.

MEASURED against the hosted Remote MCP server: the relay was offered
(`permission_relay.used: true`) and the ask reached Atlas, but the client
handled ZERO elicitations and the approval timed out.

Fixed by threading the tool call's own request id through: the SDK hands it
to the tool as `extra.requestId`, so the callback now takes `extra` and
passes it to `runStreamed` -> `relayOneAsk` -> `elicitInput`.

INVISIBLE ON STDIO, which is why this shipped: one pipe, nothing to route,
so every local and stdio test passed while the hosted run timed out. Note
the shared `call()` test helper passes `{}` as `extra` and therefore could
never have caught it — the new test supplies a request id the way the SDK
does, and it FAILS against the version without `relatedRequestId`
(verified).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEFAULT_ATLAS_URL      -> https://workflows.browserstack.com
DEFAULT_AUTH_TOKEN_URL -> https://auth.browserstack.com/oauth2/v2/token

These were deliberate staging placeholders ("for now lets hardcode the base_url
to staging only then we will point this to prod url later"); this is that step.
Both hosts were verified rather than guessed - workflows.browserstack.com answers
/api/profiles with 401 {"detail":"authentication required"}, byte-identical to
staging Atlas.

Note what this removes: an install with no env vars used to fail SAFE onto
staging, where it could not touch production data. It now reaches real customer
data by default, so every non-production deployment must set
ASK_BROWSERSTACK_ATLAS_URL / ASK_BROWSERSTACK_AUTH_TOKEN_URL explicitly. The
staging hosts are recorded in the comment block for exactly that purpose, and the
resolved host is still logged at info on first use naming env-vs-default.

Tests assert the literals so a future repoint stays deliberate; updated with the
constants. Full suite green (482 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant