Skip to content

Login through the browser (session flow) (C3) - #53

Open
thedogwiththedataonit wants to merge 2 commits into
claude/project-foundations-mcp-installfrom
claude/browser-login
Open

thedogwiththedataonit wants to merge 2 commits into
claude/project-foundations-mcp-installfrom
claude/browser-login

Conversation

@thedogwiththedataonit

@thedogwiththedataonit thedogwiththedataonit commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Update 2026-10-21: restacked on #44 after CLI main 2ce7d42 (release concurrency, #66). The sign-in routes now ship in brew-v2 #2169 (the DX stack collapsed into one PR, spec @ 6f8ef50544); that is the deploy this waits on. No code change; tsc, lint, test (882), docs/skills freshness, build, signal smoke ✓.

Update 2026-10-16: restacked on #44 (now on main 0.16.0) and checked against brew-v2#2060 367851e422 (its later b2ae25d1d8 only adds observability to the approval page; the wire contract is unchanged).

  • The platform's sealForCli round-trips with this CLI's unseal (ECDH P-256 → HKDF-SHA256 brew-cli-login-v1 → AES-256-GCM, AAD = session id; 200 runs). Create/token request and response fields, authorization_pending / slow_down / access_denied / expired_token / invalid_grant handling and exit codes, approver.email, and --no-wait --json → {verificationUrl, userCode, resumeCommand} exit 3 all match.
  • Fixes (new commit): the brand id a browser sign-in stores no longer rides along with a key from --api-key/BREW_API_KEY (it caused 403 BRAND_SCOPE_MISMATCH), and logout/a pasted key drop it; a non-default --api-url is stored with the key; "Signed in as …" shows under --quiet; client details trimmed by code point; unknown key kinds skipped; 408/429 without a body are retryable API errors; the pending file is written atomically (0600 temp + rename).
  • Checks (SDK at main's pin): tsc ✓ · lint ✓ · test 58 files / 783 tests ✓ · docs + skills fresh ✓.

Stacked on #44. Needs brew-v2 #2169 (which carries #2060's /api/cli/sessions) deployed before it works against brew.new; until then login on a terminal exits 3 CLI_LOGIN_UNAVAILABLE with a --paste hint.

What changes

brew-cli login on an interactive terminal signs in through the browser instead of asking for a pasted key:

Sign in to Brew in your browser. The page is on brew.new:
  https://brew.new/cli/authorize?session=…
Check the code in your browser matches: WDJB-MJHT
Opened it in your browser. Waiting for approval… (Ctrl-C to cancel)
Signed in as ada@acme.com to Acme. Key stored in ~/.config/brew-cli/config.json (0600).
  • Protocol (brew-v2 docs/runbooks/cli-login.md): one P-256 key pair and a 32-byte poll secret per sign-in. POST /api/cli/sessions sends the public JWK, base64url(SHA-256(pollSecret)), keys: ["cli"] and display-only client facts (hostname, OS, CLI version). Polls POST /api/cli/sessions/{id}/token every interval; slow_down adds 5 s, a 429 honours Retry-After, 5xx and dropped attempts retry (10 in a row at most), and polling stops at expiresAt + 1 min.
  • Unseal: ECDH P-256, HKDF-SHA256 (empty salt, info brew-cli-login-v1), AES-256-GCM with the session id as AAD. node:crypto webcrypto only, no new dependencies. Tests seal with the server's own algorithm, so a drift in either side fails.
  • Storage: the CLI key at 0600 as before, plus non-secret userEmail and brandName. brandId is set to the key's brand, because a stale one would turn every request into 403 BRAND_SCOPE_MISMATCH. A pasted key clears the identity fields. whoami shows them only for the stored key. logout clears them and any pending sign-in.
  • Agents: login --no-wait --json exits 3 at once with { status: "pending", verificationUrl, userCode, expiresAt, resumeCommand } and keeps the pending sign-in (session id, poll secret, private JWK) in pending-auth.json at 0600. login --resume collects it; --resume --no-wait checks once; --wait <duration> bounds the wait and returns the pending result when it runs out. resumeCommand uses the invocation prefix (npx -y brew.new@… login --resume --json). The skill tells agents to show the link and code, then resume.
  • Remote environments: Codespaces, Replit, Gitpod, WebContainers, SSH without a display, and CI never try a browser. They print the link for any device and poll the same way.
  • Failures: access_denied, expired_token and invalid_grant exit 3 with CLI_LOGIN_DENIED, CLI_LOGIN_EXPIRED and CLI_LOGIN_INVALID_GRANT, and delete the pending file. A seal that will not open exits 1 CLI_LOGIN_SEAL_INVALID and says how to revoke the orphan key. Interrupts and connection errors on the sign-in routes say to run the command again, not to check for a half-applied write.
  • Flags (additive): --browser, --paste (the old prompt), --no-open, --wait <duration>, --no-wait, --resume. Contradictory combinations exit 2 (--paste or --api-key with any browser flag, --wait with --no-wait). --api-key, a piped key and BREW_API_KEY behave as before, and login off a terminal with no key still exits 2, with a pointer to --no-wait --json.
  • Opener: the platform opener now runs detached (xdg-open can stay in the foreground as long as the browser does) and uses wslview under WSL. Windows keeps Project foundations + mcp install (C1) #44's rundll32 url.dll,FileProtocolHandler, which takes the URL as one argument. cmd /c start would split it at &.
  • Seams: CliIo.clock (now, sleep) so tests run a ten-minute poll instantly. runCli now defaults openUrl to a failing stub, so no test can launch the host browser.

Not in this PR

  • The loopback redirectPort wake-up. It is optional in the protocol and the poll carries the keys either way.
  • init reuse: startBrowserLogin / resumeBrowserLogin already accept keys: ['cli','app'] and return the app key in memory only, for C4.

Checks

  • bun run check: tsc, biome, vitest (54 files, 754 tests), docs:commands:check, generate:skills:check. All green.
  • New: tests/commands/login-browser.test.ts (33 MSW cases) covers the happy path with a real server-side seal; slow_down, 429 and 500 backoff; denied, expired (server and local deadline) and invalid_grant; a seal bound to another session; Ctrl-C (130); a remote env skipping the opener; --no-open; --api-url site; the unavailable server; --no-wait + --resume; --wait running out; flag conflicts; whoami and logout; and a secret canary over every stdout/stderr. tests/lib/auth-flow.test.ts covers remote-env detection, opener choice, site URL, and seal round trips (including through the persisted JWK).
  • Manual: the built dist/bin.js completed a real sign-in against a local stand-in server that implements the routes and seal (real fetch, clock and webcrypto); config written at 0600.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

RetriggerView in GreptileConfidence Score: 2/5

The PR needs fixes before merging: requested wait limits, replacement credentials, and manually saved brands still break in the previously reported cases.

Fix All in Claude CodeFindings

  1. P1 Sign-in exceeds the requested wait ▶
  2. P1 Old sign-in replaces newer credentials ▶
  3. P1 Saved brand stops working ▶
  4. P2 Failed save hides key cleanup ▶
  5. P2 Resume forgets polling delays ▶
Fix with agent prompt
### Issue 1
src/lib/auth-flow/poll.ts:92-104
With `login --browser --wait 5s`, the loop can start a token request at the five-second deadline and wait another 30 seconds for it. It also shortens server delays: a seven-second `Retry-After` received at two seconds becomes a three-second sleep, so the CLI polls before the server allows it.

Preserve the full polling delay, stop before starting a poll that cannot fit, and bound requests by the remaining wait.

### Issue 2
src/lib/auth-flow/login.ts:126-128
A successful fresh login leaves an older browser sign-in in `pending-auth.json`. Start session A with `--no-wait`, then successfully sign in through session B or paste another key. Approving and resuming session A still overwrites the newer key and brand.

Clear the superseded pending sign-in when a replacement login succeeds, including the key-login path.

### Issue 3
src/lib/client.ts:65-68
After browser sign-in, running `config set brandId bd_other` leaves the old `brandName` in place. When the user supplies an organization key through `BREW_API_KEY`, this check drops their manually saved brand, so brand-scoped commands fail despite that setting. The same check makes `logout` and key login delete the manually changed brand.

Track the browser-bound brand separately, or clear that association when `config set brandId` runs.

### Issue 4
src/commands/login.ts:179-189
If a full disk or unwritable config prevents saving the approved key, the user gets only a generic filesystem error. The token response is single-use, and resume has already deleted the pending file before `updateConfig` runs. This leaves an active key in Brew that the CLI cannot recover, without explaining how to revoke it.

Catch storage failures after approval and give the same revoke-and-restart advice used for a broken seal.

### Issue 5
src/lib/auth-flow/login.ts:187-190
After a poll returns `slow_down` or a 429, `waitForApproval` keeps the longer delay only in memory. When the command returns pending, the file still has the original interval, and the next `--resume` polls immediately. Agents making repeated short checks can therefore poll before the server allows it and keep getting rate-limited.

Save the updated interval and earliest next-poll time, and honor both when resuming.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds browser sign-in with encrypted key collection and a resumable flow for agents.

  • Interactive login signs in through the browser and can resume later.
  • The saved browser key keeps its brand and approver details together.

Diagram

sequenceDiagram
  participant CLI
  participant Brew
  participant Browser
  participant Config
  CLI->>Brew: Create session with public key and poll-secret hash
  Brew-->>CLI: Approval link, code, expiry, interval
  CLI->>Browser: Show or open approval link
  Browser->>Brew: Approve sign-in
  CLI->>Brew: Poll with session ID and poll secret
  Brew-->>CLI: Encrypted keys and approval details
  CLI->>CLI: Decrypt keys
  CLI->>Config: Save CLI key and brand
Loading

Reviews (6) · Last reviewed commit: "fix(login): browser brand stays with its..." · Reviewed by Greptile

Comment thread src/lib/auth-flow/poll.ts
Comment on lines +92 to +104
await clock.sleep(
Math.min(delayMs, remainingWait, sessionDeadline - now),
ctx.signal
)
}
const result = await pollToken(
ctx,
{
siteUrl: login.siteUrl,
sessionId: login.sessionId,
pollSecret: login.pollSecret,
},
clock.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sign-in exceeds the requested wait

With login --browser --wait 5s, the loop can start a token request at the five-second deadline and wait another 30 seconds for it. It also shortens server delays: a seven-second Retry-After received at two seconds becomes a three-second sleep, so the CLI polls before the server allows it.

Preserve the full polling delay, stop before starting a poll that cannot fit, and bound requests by the remaining wait.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/auth-flow/poll.ts
Line: 92-104

Comment:
**Sign-in exceeds the requested wait**

With `login --browser --wait 5s`, the loop can start a token request at the five-second deadline and wait another 30 seconds for it. It also shortens server delays: a seven-second `Retry-After` received at two seconds becomes a three-second sleep, so the CLI polls before the server allows it.

Preserve the full polling delay, stop before starting a poll that cannot fit, and bound requests by the remaining wait.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +126 to +128
if (outcome.status === 'approved') {
return { status: 'approved', approved: outcome.approved, apiUrl }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Old sign-in replaces newer credentials

A successful fresh login leaves an older browser sign-in in pending-auth.json. Start session A with --no-wait, then successfully sign in through session B or paste another key. Approving and resuming session A still overwrites the newer key and brand.

Clear the superseded pending sign-in when a replacement login succeeds, including the key-login path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/auth-flow/login.ts
Line: 126-128

Comment:
**Old sign-in replaces newer credentials**

A successful fresh login leaves an older browser sign-in in `pending-auth.json`. Start session A with `--no-wait`, then successfully sign in through session B or paste another key. Approving and resuming session A still overwrites the newer key and brand.

Clear the superseded pending sign-in when a replacement login succeeds, including the key-login path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/lib/auth-flow/pending.ts Outdated
Comment thread src/commands/login.ts
Comment on lines +178 to +185
const path = updateConfig(ctx.io.env, {
apiKey: cliKey.key,
// The key is bound to this brand; a stale brand id would make every
// request a 403 BRAND_SCOPE_MISMATCH.
brandId: approved.brand.brandId,
userEmail: approved.approver.email,
brandName: approved.brand.name,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed save hides key cleanup

If a full disk or unwritable config prevents saving the approved key, the user gets only a generic filesystem error. The token response is single-use, and resume has already deleted the pending file before updateConfig runs. This leaves an active key in Brew that the CLI cannot recover, without explaining how to revoke it.

Catch storage failures after approval and give the same revoke-and-restart advice used for a broken seal.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/login.ts
Line: 178-185

Comment:
**Failed save hides key cleanup**

If a full disk or unwritable config prevents saving the approved key, the user gets only a generic filesystem error. The token response is single-use, and resume has already deleted the pending file before `updateConfig` runs. This leaves an active key in Brew that the CLI cannot recover, without explaining how to revoke it.

Catch storage failures after approval and give the same revoke-and-restart advice used for a broken seal.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment on lines +187 to +190
if (outcome.status === 'pending') {
return {
status: 'pending',
pending: pendingView(pending, options.resumeCommand),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Resume forgets polling delays

After a poll returns slow_down or a 429, waitForApproval keeps the longer delay only in memory. When the command returns pending, the file still has the original interval, and the next --resume polls immediately. Agents making repeated short checks can therefore poll before the server allows it and keep getting rate-limited.

Save the updated interval and earliest next-poll time, and honor both when resuming.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/auth-flow/login.ts
Line: 187-190

Comment:
**Resume forgets polling delays**

After a poll returns `slow_down` or a 429, `waitForApproval` keeps the longer delay only in memory. When the command returns pending, the file still has the original interval, and the next `--resume` polls immediately. Agents making repeated short checks can therefore poll before the server allows it and keep getting rate-limited.

Save the updated interval and earliest next-poll time, and honor both when resuming.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

Comment thread src/lib/client.ts
Comment on lines +65 to +68
const storedBrandId =
stored.brandName !== undefined && apiKey !== keyFromConfig
? undefined
: stored.brandId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Saved brand stops working

After browser sign-in, running config set brandId bd_other leaves the old brandName in place. When the user supplies an organization key through BREW_API_KEY, this check drops their manually saved brand, so brand-scoped commands fail despite that setting. The same check makes logout and key login delete the manually changed brand.

Track the browser-bound brand separately, or clear that association when config set brandId runs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/client.ts
Line: 65-68

Comment:
**Saved brand stops working**

After browser sign-in, running `config set brandId bd_other` leaves the old `brandName` in place. When the user supplies an organization key through `BREW_API_KEY`, this check drops their manually saved brand, so brand-scoped commands fail despite that setting. The same check makes `logout` and key login delete the manually changed brand.

Track the browser-bound brand separately, or clear that association when `config set brandId` runs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Cursor

thedogwiththedataonit and others added 2 commits October 21, 2026 13:00
`brew-cli login` on an interactive terminal now starts a sign-in on
brew.new (POST /api/cli/sessions), prints the link and code, opens the
page, and polls /api/cli/sessions/{id}/token until the person approves.
The keys arrive sealed to a one-time P-256 key pair (ECDH, HKDF-SHA256
"brew-cli-login-v1", AES-256-GCM with the session id as AAD, mirroring
brew-v2 unsealForCli, node:crypto only); the CLI key is stored at 0600
with the approver's email and brand name, and the stored brand id is set
to the key's brand.

- Flags (additive): --browser, --paste (old prompt), --no-open,
  --wait <duration>, --no-wait (exit 3 with { status: "pending",
  verificationUrl, userCode, expiresAt, resumeCommand }, pending sign-in
  kept in pending-auth.json at 0600), --resume. Conflicting flags exit 2.
- slow_down adds 5 s; 429 honours Retry-After; 5xx and dropped attempts
  retry; the poll stops at expiresAt + 1 min. access_denied, expired and
  invalid_grant exit 3 (CLI_LOGIN_DENIED / _EXPIRED / _INVALID_GRANT); a
  server without the routes exits 3 CLI_LOGIN_UNAVAILABLE with a --paste
  hint.
- Codespaces, Replit, Gitpod, WebContainers, SSH without a display and CI
  never open a browser. The platform opener runs detached and gains
  wslview under WSL.
- whoami shows userEmail/brandName for a stored browser key; logout
  clears them and any pending sign-in.
- CliIo gains a clock seam; tests seal with the server's algorithm over
  MSW, including a secret canary across stdout/stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked against brew-v2#2060 (367851e422): the platform's sealForCli opens
with this unseal, and every request and response field matches. Fixes:

- The brand id a browser sign-in stores is bound to that key. A key from
  --api-key or BREW_API_KEY no longer sends it (every brand route would
  403 BRAND_SCOPE_MISMATCH), and logout or a pasted key drops it.
- A sign-in against a non-default --api-url stores that URL with the key.
- "Signed in as <email> to <brand>" shows under --quiet too: it is the
  check against an approval someone else phished.
- Client details are trimmed by code point, so a cut never leaves half an
  emoji the server's printable check refuses.
- A key kind this CLI does not know is skipped rather than failing a
  sign-in whose session is already consumed.
- A 408 or 429 without a JSON body is a retryable API error, not
  "this server has no browser sign-in".
- The pending sign-in file is written to a fresh 0600 file and renamed
  into place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thedogwiththedataonit
thedogwiththedataonit force-pushed the claude/project-foundations-mcp-install branch from 37325ff to 8b352aa Compare October 21, 2026 04:12
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