diff --git a/.cursor-mcp.json b/.cursor-mcp.json index eed3690..f7d28a1 100644 --- a/.cursor-mcp.json +++ b/.cursor-mcp.json @@ -1,13 +1,8 @@ { "mcpServers": { "bstack": { - "command": "npx", - "args": ["-y", "@browserstack/mcp-server@1.2.27-beta.1"], - "env": { - "BROWSERSTACK_USERNAME": "${BROWSERSTACK_USERNAME}", - "BROWSERSTACK_ACCESS_KEY": "${BROWSERSTACK_ACCESS_KEY}", - "O11Y_TFA_RCA_BASE_URL": "${O11Y_TFA_RCA_BASE_URL}" - } + "type": "http", + "url": "https://mcp.browserstack.com/mcp?isTfaPlugin=true" } } } diff --git a/.env.example b/.env.example deleted file mode 100644 index d86819e..0000000 --- a/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# BrowserStack credentials — used by the bundled bstack MCP server for -# listTestIds + tfaRcaTurn. Per-user; never commit real values. -BROWSERSTACK_USERNAME= -BROWSERSTACK_ACCESS_KEY= - -# Observability base URL the TFA RCA chat runs against. Optional — -# the bstack MCP server defaults to its rengg-tfa staging URL when unset. -# O11Y_TFA_RCA_BASE_URL=https://api-observability-rengg-tfa.bsstag.com diff --git a/.mcp.json b/.mcp.json index 7fca468..f7d28a1 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,14 +1,8 @@ { "mcpServers": { "bstack": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@browserstack/mcp-server@1.2.27-beta.1"], - "env": { - "BROWSERSTACK_USERNAME": "${BROWSERSTACK_USERNAME}", - "BROWSERSTACK_ACCESS_KEY": "${BROWSERSTACK_ACCESS_KEY}", - "O11Y_TFA_RCA_BASE_URL": "${O11Y_TFA_RCA_BASE_URL}" - } + "type": "http", + "url": "https://mcp.browserstack.com/mcp?isTfaPlugin=true" } } } diff --git a/.npmrc b/.npmrc index 33911e3..62e4127 100644 --- a/.npmrc +++ b/.npmrc @@ -5,3 +5,7 @@ engine-strict=true legacy-peer-deps=false audit-level=high access=public +# Wait 7 days before resolving a newly published version: a package compromised at +# publish time is usually caught and unpublished inside that window. Requires npm +# >= 11.10; older npm ignores the key rather than failing. +min-release-age=7 diff --git a/INTEGRATION.md b/INTEGRATION.md index 7e68e96..14eb478 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -30,11 +30,13 @@ orchestration. On every host the run finishes the same way: glimpse table → ## Claude Code ```bash -cp .env.example .env # BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY claude --plugin-dir ./ -/rca-build +/tfa-rca:rca-build ``` +No credentials to set: `.mcp.json` points at the hosted server and Claude Code runs +the OAuth flow on first connect. `/mcp` shows the connection and re-triggers sign-in. + `.claude-plugin/plugin.json` + root `.mcp.json` + `skills/` + `agents/` are auto-discovered. (No `commands/rca-build.md` on purpose — a command and skill with the same name collide and the skill body fails to load.) @@ -43,7 +45,7 @@ with the same name collide and the skill body fails to load.) The repo ships Cursor parity files mirroring `slack-mcp-plugin`: `.cursor-plugin/plugin.json` (points at `../.cursor-mcp.json` and `./skills/`) -and `.cursor-mcp.json` (the stdio `bstack` server). +and `.cursor-mcp.json` (the hosted `bstack` server over HTTP). **Wire the MCP server** — either: - copy `.cursor-mcp.json`'s `bstack` entry into your project `.cursor/mcp.json` @@ -52,8 +54,8 @@ and `.cursor-mcp.json` (the stdio `bstack` server). - use an **Add to Cursor** deeplink: `cursor://anysphere.cursor-deeplink/mcp/install?name=bstack&config=` -Set `BROWSERSTACK_USERNAME` / `BROWSERSTACK_ACCESS_KEY` / `O11Y_TFA_RCA_BASE_URL` -in your environment (or replace the `${…}` placeholders with literals). +Nothing else to set — the entry carries only `type` and `url`, and Cursor performs the +OAuth sign-in itself. There are no environment variables and no placeholders to fill. **Skill + agent discovery** — Cursor reads `.cursor/skills/` and `.cursor/agents/` (and also `.claude/agents/`). The simplest no-duplication setup is to symlink the @@ -75,12 +77,14 @@ Codex reads the global `~/.codex/config.toml` (no per-project MCP file). into `~/.codex/config.toml`, or: ```bash -codex mcp add bstack \ - --env BROWSERSTACK_USERNAME=… --env BROWSERSTACK_ACCESS_KEY=… \ - --env O11Y_TFA_RCA_BASE_URL=https://api-observability-rengg-tfa.bsstag.com \ - -- npx -y @browserstack/mcp-server@1.2.27-beta.1 +codex mcp add bstack --url "https://mcp.browserstack.com/mcp?isTfaPlugin=true" ``` +Codex's own key for a streamable-HTTP server is `url`, and `auth = "oauth"` is its +documented fallback when no bearer token or static header is configured — which is this +case. No experimental flag is needed; `experimental_environment = "remote"` gates remote +*stdio executors*, a different feature. + **Skill + agent discovery** — Codex reads `.agents/skills/` (skills) and `.codex/agents/` (subagents). Symlink the shared trees: @@ -94,9 +98,13 @@ Then run the `rca-build` skill; the coordinator + `tfaRcaTurn` loop are identica ## Notes -- The `bstack` server is **stdio** (`npx @browserstack/mcp-server@1.2.27-beta.1`), not a remote - OAuth server — so the configs use `command`/`args`/`env`, unlike Slack's - `url`+`oauth`/`auth` shape. +- The `bstack` server is **remote HTTP with OAuth** — `type`/`url` in Claude Code and + Cursor, `url`/`auth` in Codex. No `command`, no `args`, no `env`, and no credential in + any config file. +- **`?isTfaPlugin=true` is load-bearing.** The server registers the TFA RCA + collaboration tools per-request only when that query parameter is present. Drop it and + the plugin loads with nothing to call, which looks like a broken install rather than a + missing flag. Query parameters in the `url` are supported by all three clients. - Env-var interpolation (`${VAR}`) is honored by Claude Code's `.mcp.json`; on Cursor/Codex, replace the placeholders with literals if your client doesn't expand them. diff --git a/README.md b/README.md index ae96f3b..674e6d6 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,15 @@ Works in **Claude Code**, **Cursor** and **Codex**. | You need | Why | |---|---| | A BrowserStack account with Test Observability | The build, its test logs, and the dashboard the report lands on | -| `BROWSERSTACK_USERNAME` + `BROWSERSTACK_ACCESS_KEY` | From your [account settings](https://www.browserstack.com/accounts/profile/details) | | **GitHub access** — the `gh` CLI signed in, or a GitHub MCP server | Required. Without your code and its merged PRs there is no culprit PR to name, and that is the point of the run | | Anything else you use — logs, metrics, a cluster, CI | Optional. Each one you skip is recorded and shown in the report as evidence that was not available | GitHub is the only hard requirement. Everything else is offered, never forced. +**There are no BrowserStack credentials to configure.** The plugin talks to +BrowserStack's hosted MCP server and your client signs you in on first connect. Nothing +is stored in this repo, and there is no `.env` to fill in. + ## Install In Claude Code: @@ -34,15 +37,9 @@ In Claude Code: /plugin install tfa-rca@browserstack-ai-tfa ``` -Then set your credentials — the plugin needs them before it can read anything: - -```bash -export BROWSERSTACK_USERNAME=... # from your account settings -export BROWSERSTACK_ACCESS_KEY=... -``` - -Ask Claude to **run the plugin's setup** and it will check both of these, confirm your -GitHub route, and tell you exactly what is missing if anything is. +The first time it connects, your client walks you through signing in to BrowserStack. +That is the whole credential step. Ask Claude to **run the plugin's setup** if you want +that and your GitHub route checked before you start.
Installing from a clone instead (for development) @@ -50,7 +47,6 @@ GitHub route, and tell you exactly what is missing if anything is. ```bash git clone https://github.com/browserstack/ai-tfa-plugins.git cd ai-tfa-plugins -cp .env.example .env # add your BrowserStack username + access key claude --plugin-dir ./ ``` diff --git a/SETUP.md b/SETUP.md index bcec5ca..d4961aa 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,69 +1,68 @@ --- name: setup -description: One-time setup for the tfa-rca plugin — proves the bundled BrowserStack MCP server can authenticate and that a GitHub route exists, then hands off to /tfa-rca:rca-build. Run this on install, or whenever the plugin reports that it cannot reach BrowserStack. Does NOT configure repos, logs, metrics or CI — the rca-build interview owns those. +description: One-time setup for the tfa-rca plugin — confirms the hosted BrowserStack MCP server is connected and that a GitHub route exists, then hands off to /tfa-rca:rca-build. Run this on install, or whenever the plugin reports that it cannot reach BrowserStack. Does NOT configure repos, logs, metrics or CI — the rca-build interview owns those. --- # Setting up tfa-rca -**Scope.** This gets the two things the plugin cannot start without: the bundled -`bstack` MCP server authenticating, and a GitHub route existing. Everything else — +**Scope.** This gets the two things the plugin cannot start without: the hosted +`bstack` MCP server connected, and a GitHub route existing. Everything else — which repos, which branches, where the logs are, what runs the services — is settled by `/tfa-rca:rca-build`'s own first-contact interview, which writes `.rca-context.json` in the user's project. **Do not ask about any of that here.** Asking twice reads as not having listened the first time. +**Step 1 gates everything.** If the `bstack` tools are not in this session the user is +not signed in, and nothing further can be checked or run — stop there and ask them to +authorise, rather than working down the list reporting failures that all have one cause. + Work through the steps in order and report each outcome in one line. Nothing here writes a file. -## 1. Credentials for the bundled MCP server - -The `bstack` server needs `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`. -Check whether they are already present in the environment. +## 1. Connect to BrowserStack -If either is missing, tell the user exactly this and stop — do not proceed to step 2: +The `bstack` server is BrowserStack's hosted MCP endpoint, and it authenticates by +**OAuth** — the client runs the sign-in flow. There are no credentials to set, no +`.env` to fill in, and nothing for you to record. -> Add your BrowserStack credentials, then reload the plugin. Copy `.env.example` to -> `.env` and fill in `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` — both are -> on your [account settings](https://www.browserstack.com/accounts/profile/details) -> page. Exporting them in your shell works too. +Check whether the `bstack` tools are available in this session — `fetchBuildInsights`, +`listTestIds`, `tfaRcaTurn`. -**Never write a credential value anywhere, and never echo one back.** If the user -pastes a key into the conversation, say that the value is now in the transcript and -should be revoked and reissued, then ask them to put the new one in `.env` and tell -you only that it is set. You record that a variable is set; you never record what is -in it. +- **They are there** — the connection is up. Move to step 2. +- **They are not** — the user is not signed in, and this is where you stop. Ask for it + directly and do not continue to step 2: -`O11Y_TFA_RCA_BASE_URL` is optional and almost always unset — only a customer on a -non-default tenant needs it. Do not ask for it. + > I can't reach BrowserStack yet. Authorise the `bstack` MCP server — run `/mcp` in + > Claude Code and approve the sign-in, or accept the prompt your client shows — then + > ask me again. -## 2. Confirm the server loaded — and say what is still unproven + If the server is not listed **at all** (as opposed to listed and unauthorised), that + is wiring rather than sign-in: **[INTEGRATION.md](INTEGRATION.md)** has the fix per + client. Say which of the two it is; they have different fixes and guessing wastes a + round trip. -Two different things can be wrong, and they have different fixes, so separate them. +**Never ask for a username, an access key, or any token.** This route does not use +them, so a request for one is both useless and a prompt to paste a secret into a +transcript. If the user offers one anyway, say it is not needed and that they should +treat any value they pasted as disclosed — revoke and reissue it. -**Did the server load?** Check whether the `bstack` tools — `fetchBuildInsights`, -`listTestIds`, `tfaRcaTurn` — are available in this session. If they are not, the MCP -server did not start, which is client wiring rather than credentials: -**[INTEGRATION.md](INTEGRATION.md)** has the fix per client. Point at the section for -the client actually in use. +## 2. Confirm it can actually read — and say what is still unproven -**Do the credentials work?** Every Observability read on this server needs a build to -read, so there is no free call that proves authentication on its own. Handle it -honestly, in one of two ways: +Being connected is not the same as being able to read this account's builds. Every +Observability read needs a build to read, so there is no free call that proves access +on its own. Handle it honestly, in one of two ways: - **The user has a build id to hand** — use it, and you have real proof: `fetchBuildInsights(buildId=)`. Returning the build's name and status means - credentials, entitlement and connectivity all work. An auth error means the values - are being read and rejected, so rotating or re-copying the access key is the fix, - not re-exporting them. -- **They do not** — say plainly that the variables are set and that the first real - authenticated read happens on their first `/tfa-rca:rca-build` run, which fails - loudly and immediately if the credentials are wrong. Do not call an unrelated tool - to manufacture a probe: a Test Management read succeeding or failing says nothing - reliable about Observability access, and reporting it as proof would be worse than - reporting nothing. - -**Never say "verified" for a check you did not run.** "Variables are set, not yet -exercised" is the accurate sentence when no build was read, and it is the one to use. + the sign-in, the entitlement and the connection all work. +- **They do not** — say plainly that the connection is up and that the first real read + happens on their first `/tfa-rca:rca-build` run, which fails loudly and immediately + if authorisation is wrong. Do not call an unrelated tool to manufacture a probe: a + Test Management read succeeding or failing says nothing reliable about Observability + access, and reporting it as proof would be worse than reporting nothing. + +**Never say "verified" for a check you did not run.** "Connected, not yet exercised" +is the accurate sentence when no build was read, and it is the one to use. ## 3. A GitHub route — the one hard requirement diff --git a/codex-mcp.example.toml b/codex-mcp.example.toml index e3dda80..536f19d 100644 --- a/codex-mcp.example.toml +++ b/codex-mcp.example.toml @@ -1,11 +1,20 @@ # Codex MCP wiring for the bstack server. -# Codex reads ~/.codex/config.toml (no per-project MCP file), so copy this block -# into your global config — or use the `codex mcp add` one-liner in INTEGRATION.md. -# Replace the env values with your BrowserStack credentials. +# Codex reads ~/.codex/config.toml (no per-project MCP file), so copy this block into +# your global config — or add it in one line: +# +# codex mcp add bstack --url "https://mcp.browserstack.com/mcp?isTfaPlugin=true" +# +# Same hosted server the Claude Code and Cursor configs use (.mcp.json / +# .cursor-mcp.json). No credentials live here: `auth = "oauth"` makes Codex run the +# sign-in flow on first connect, and it is the documented fallback when no bearer token +# or static header is configured. +# +# `isTfaPlugin=true` is required, not decorative — the server registers the TFA RCA +# collaboration tools per-request only when it is present. Without it the plugin loads +# and has nothing to call. [mcp_servers.bstack] -command = "npx" -args = ["-y", "@browserstack/mcp-server@1.2.27-beta.1"] -env = { "BROWSERSTACK_USERNAME" = "your-username", "BROWSERSTACK_ACCESS_KEY" = "your-access-key", "O11Y_TFA_RCA_BASE_URL" = "" # optional: set only to target a staging tenant (default is production) } +url = "https://mcp.browserstack.com/mcp?isTfaPlugin=true" +auth = "oauth" startup_timeout_sec = 15 tool_timeout_sec = 120 diff --git a/skills/rca-build/SKILL.md b/skills/rca-build/SKILL.md index bd71970..9bb8d19 100644 --- a/skills/rca-build/SKILL.md +++ b/skills/rca-build/SKILL.md @@ -1,10 +1,19 @@ --- name: rca-build -description: Autonomous batch RCA over every failed test of a BrowserStack build via tfaRcaTurn. First contact interviews you once and writes .rca-context.json; every run after that is one gate (context validation + resolved intake) then fully autonomous — clusters failures, routes evidence, triggers the dashboard report. Args: build id, optional PR URLs / repo hints. +description: Autonomous batch RCA over every failed test of a BrowserStack build via tfaRcaTurn. Needs the hosted bstack MCP server authenticated (OAuth) — if its tools are absent, ask the user to sign in before anything else. First contact interviews you once and writes .rca-context.json; every run after that is one gate (context validation + resolved intake) then fully autonomous — clusters failures, routes evidence, triggers the dashboard report. Args: build id, optional PR URLs / repo hints. --- # rca-build — single-gate autonomous RCA over a build +> **FIRST, before anything else: if the `bstack` MCP tools are not in this session, stop +> and ask the user to sign in.** The server is BrowserStack's hosted endpoint and it +> authenticates by OAuth, so an unauthenticated session has no `tfaRcaTurn`, +> `listTestIds` or `fetchBuildInsights` — every step below is dead and the failure looks +> like a broken plugin rather than a missing sign-in. Say plainly: *"I can't reach +> BrowserStack yet — authorise the `bstack` MCP server (`/mcp`) and re-run."* Do not +> start the interview, do not read the context file, and never ask for a username or +> access key: this route does not use them. + Drives the `tfaRcaTurn` collaborative loop over **every failed test** of a build and lands a per-test RCA in the TRA (Test Observability) dashboard. **TFA owns logs; the client agent owns everything else** (product code, infra/runtime, logs, diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 3ed03c3..df34e6a 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -619,7 +619,14 @@ test("the gate prints what selection matched on, not only what it chose", () => const flat = readFileSync(join(ROOT, "skills/rca-build/templates/gate-summary.md"), "utf8") .replace(/^\s*>\s?/gmu, "").replace(/\s+/gu, " "); - assert.match(flat, /matchedBy/u, "the gate screen must show HOW the profile was chosen"); + // Anchored to the GATE screen's own block. The whole-file form passed with the gate + // screen's `matchedBy` deleted, because the review screen further down still had one — + // two screens, one assertion, and only one of them actually pinned. + const raw = readFileSync(join(ROOT, "skills/rca-build/templates/gate-summary.md"), "utf8"); + const gate = (raw.match(/```\nGATE CLOSED[\s\S]*?```/u) ?? [""])[0]; + assert.ok(gate.length > 200, "the gate screen block must exist and be findable"); + assert.match(gate, /^\s*profile:.*matchedBy/mu, + "the GATE screen's profile row must show HOW the profile was chosen"); assert.match(flat, /projectUnchecked/u, "and must say when a declared project constraint could not be evaluated — a " + "silently unapplied constraint is indistinguishable from one that agreed"); @@ -758,13 +765,30 @@ test("the gate reviews the persisted setup and can change it", () => { assert.match(skill, /A change to scope invalidates what was verified against the old scope/iu, "a just-corrected branch has never been proved reachable"); - // The review is only real if the values are on screen. - for (const field of ["matchedBy", "others on file", "subpaths", "knowledge"]) { + // The review is only real if the values are on THE SCREEN — so assert against the + // fenced screen block, not the whole file. A mutation proved the loose form vacuous: + // deleting the `subpaths:` row still passed, because "subpaths" also appears twice in + // the surrounding prose. Same weakness as the `/writes nothing/` assertion above. + const screen = (readFileSync(join(ROOT, "skills/rca-build/templates/gate-summary.md"), "utf8") + .match(/```\nSETUP ON FILE[\s\S]*?```/u) ?? [""])[0]; + assert.ok(screen.length > 200, "the review's screen block must exist and be findable"); + + // Matched as ROW LABELS — `^ :` — not as substrings of the block. Two + // mutations were needed to get here: the whole-file form passed with the `subpaths:` + // row deleted (the word also appears in the prose), and so did the block-anchored form + // (it appears again inside the block, in the warnings example). A row is what "the + // screen shows this" actually means. + for (const label of ["others on file", "subpaths", "knowledge"]) { assert.match( - template, new RegExp(field.replace(/ /gu, " "), "iu"), - `the review screen must show ${field} — a value not on screen cannot be corrected`, + screen, new RegExp(`^\\s*${label}:`, "mu"), + `the review screen needs a '${label}:' ROW — a value not on screen cannot be corrected`, ); } + // matchedBy is not its own row; it qualifies the profile row, which is the point of it. + assert.match( + screen, /^\s*profile:.*matchedBy/mu, + "the profile row must carry matchedBy — 'default-profile' there means nothing matched", + ); }); // ---- the gate's stated budget and its never-ask prose must agree -------------- @@ -969,7 +993,7 @@ test("the suspect packet carries every field prDetails requires", () => { // every reader, which is what `repo` was before this. const packet = readFileSync(join(ROOT, "skills/rca-build/templates/suspect-packet.md"), "utf8"); for (const field of ["repo:", "pr:", "author:", "tag:", "link:"]) { - assert.match(packet, new RegExp(`^\\s*${field.replace(":", ":")}`, "mu"), + assert.match(packet, new RegExp(`^\\s*${field}`, "mu"), `the packet must carry ${field} — prDetails requires it and cannot be filled without it`); } assert.match(packet, /identity is\s+`?repo \+ number`?|repo \+ number/u, @@ -1079,3 +1103,53 @@ test("an explicit invocation value outranks build metadata", () => { assert.match(tags, /`given` \| \*\*the customer said so\*\*/u, "given is the customer speaking"); assert.match(tags, /build metadata included/u, "and metadata is detected"); }); + +// ---- an unauthenticated session is a sign-in problem, not a broken plugin ---- +// +// The hosted MCP server authenticates by OAuth, so a session that has not signed in has +// no `tfaRcaTurn`, `listTestIds` or `fetchBuildInsights` at all. Every step of the skill +// then fails for one cause, and the symptom — tools missing, nothing works — reads as a +// broken install. The instruction to ask for sign-in has to be the FIRST thing in the +// skill, before the interview and before the context load, or it is found last. +test("the skill asks for OAuth sign-in before doing anything else", () => { + // MUTATION: move the notice below Step 0, or drop it -> fails. + const src = readFileSync(join(ROOT, "skills/rca-build/SKILL.md"), "utf8"); + // Blockquote markers stripped BEFORE collapsing whitespace: the notice is a `>` block, + // and `\s+ -> " "` alone leaves the wrapped marker mid-sentence ("Do not > start the + // interview"), so the match silently never fires. Third time this exact bug has bitten + // in this file — it is why the other guards here normalise the same way. + const flat = src.replace(/^\s*>\s?/gmu, "").replace(/\s+/gu, " "); + + const notice = src.indexOf("if the `bstack` MCP tools are not in this session"); + const firstStep = src.search(/^## /mu); + assert.ok(notice > 0, "the skill must tell the agent to check for the bstack tools"); + assert.ok( + notice < firstStep, + "and it must come BEFORE the first section — an agent that reaches Step 0 has " + + "already started work that cannot succeed", + ); + assert.match(flat, /Do not start the interview, do not read the context file/u, + "and it must say what NOT to do, or the agent proceeds and reports seven failures with one cause"); + + // The credential trap: on this route asking for a username or key is both useless and + // an invitation to paste a secret into a transcript. + assert.match(flat, /never ask for a username or access key/iu, + "OAuth means there is no username or key to ask for"); + + // The frontmatter description is what a client shows when choosing the skill. + const fm = src.split("---")[1] ?? ""; + assert.match(fm.replace(/\s+/gu, " "), /authenticated \(OAuth\)/u, + "the description must say it needs an authenticated server"); +}); + +test("SETUP.md gates its remaining steps on the sign-in", () => { + // MUTATION: drop the gate sentence -> fails. Without it the skill walks all four + // steps and reports each as broken, when one sign-in fixes every one of them. + const flat = readFileSync(join(ROOT, "SETUP.md"), "utf8").replace(/\s+/gu, " "); + assert.match(flat, /Step 1 gates everything/u, + "a failed sign-in must stop the list, not produce four failures with one cause"); + assert.match(flat, /this is where you stop/u, "and step 1 itself must say so"); + // Listed-but-unauthorised and not-listed-at-all have different fixes. + assert.match(flat, /wiring rather than sign-in/u, + "the two failure modes must be distinguished — guessing wastes a round trip"); +});