Repository navigation
Login through the browser (session flow) (C3) - #53
thedogwiththedataonit wants to merge 2 commits into
Conversation
| 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() |
There was a problem hiding this 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.
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.| if (outcome.status === 'approved') { | ||
| return { status: 'approved', approved: outcome.approved, apiUrl } | ||
| } |
There was a problem hiding this 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.
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.| 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, | ||
| }) |
There was a problem hiding this comment.
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.df53432 to
1620c51
Compare
dcf2763 to
cf16a99
Compare
| if (outcome.status === 'pending') { | ||
| return { | ||
| status: 'pending', | ||
| pending: pendingView(pending, options.resumeCommand), |
There was a problem hiding this comment.
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.cf16a99 to
b962031
Compare
cb49869 to
d599c0d
Compare
b962031 to
d23d42d
Compare
d599c0d to
37325ff
Compare
d23d42d to
fc46dbd
Compare
| const storedBrandId = | ||
| stored.brandName !== undefined && apiKey !== keyFromConfig | ||
| ? undefined | ||
| : stored.brandId |
There was a problem hiding this comment.
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.`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>
37325ff to
8b352aa
Compare
fc46dbd to
7fcc8af
Compare
Stacked on #44. Needs brew-v2 #2169 (which carries #2060's
/api/cli/sessions) deployed before it works against brew.new; until thenloginon a terminal exits 3CLI_LOGIN_UNAVAILABLEwith a--pastehint.What changes
brew-cli loginon an interactive terminal signs in through the browser instead of asking for a pasted key:docs/runbooks/cli-login.md): one P-256 key pair and a 32-byte poll secret per sign-in.POST /api/cli/sessionssends the public JWK,base64url(SHA-256(pollSecret)),keys: ["cli"]and display-only client facts (hostname, OS, CLI version). PollsPOST /api/cli/sessions/{id}/tokeneveryinterval;slow_downadds 5 s, a 429 honoursRetry-After, 5xx and dropped attempts retry (10 in a row at most), and polling stops atexpiresAt+ 1 min.brew-cli-login-v1), AES-256-GCM with the session id as AAD.node:cryptowebcrypto only, no new dependencies. Tests seal with the server's own algorithm, so a drift in either side fails.userEmailandbrandName.brandIdis set to the key's brand, because a stale one would turn every request into403 BRAND_SCOPE_MISMATCH. A pasted key clears the identity fields.whoamishows them only for the stored key.logoutclears them and any pending sign-in.login --no-wait --jsonexits 3 at once with{ status: "pending", verificationUrl, userCode, expiresAt, resumeCommand }and keeps the pending sign-in (session id, poll secret, private JWK) inpending-auth.jsonat 0600.login --resumecollects it;--resume --no-waitchecks once;--wait <duration>bounds the wait and returns the pending result when it runs out.resumeCommanduses the invocation prefix (npx -y brew.new@… login --resume --json). The skill tells agents to show the link and code, then resume.access_denied,expired_tokenandinvalid_grantexit 3 withCLI_LOGIN_DENIED,CLI_LOGIN_EXPIREDandCLI_LOGIN_INVALID_GRANT, and delete the pending file. A seal that will not open exits 1CLI_LOGIN_SEAL_INVALIDand 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.--browser,--paste(the old prompt),--no-open,--wait <duration>,--no-wait,--resume. Contradictory combinations exit 2 (--pasteor--api-keywith any browser flag,--waitwith--no-wait).--api-key, a piped key andBREW_API_KEYbehave as before, andloginoff a terminal with no key still exits 2, with a pointer to--no-wait --json.xdg-opencan stay in the foreground as long as the browser does) and useswslviewunder WSL. Windows keeps Project foundations +mcp install(C1) #44'srundll32 url.dll,FileProtocolHandler, which takes the URL as one argument.cmd /c startwould split it at&.CliIo.clock(now,sleep) so tests run a ten-minute poll instantly.runClinow defaultsopenUrlto a failing stub, so no test can launch the host browser.Not in this PR
redirectPortwake-up. It is optional in the protocol and the poll carries the keys either way.initreuse:startBrowserLogin/resumeBrowserLoginalready acceptkeys: ['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.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-urlsite; the unavailable server;--no-wait+--resume;--waitrunning out; flag conflicts; whoami and logout; and a secret canary over every stdout/stderr.tests/lib/auth-flow.test.tscovers remote-env detection, opener choice, site URL, and seal round trips (including through the persisted JWK).dist/bin.jscompleted 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
Need help on this PR? Tag
@codesmith-botwith what you need.The PR needs fixes before merging: requested wait limits, replacement credentials, and manually saved brands still break in the previously reported cases.
Fix with agent prompt
Summary
Adds browser sign-in with encrypted key collection and a resumable flow for agents.
Diagram
Reviews (6) · Last reviewed commit: "fix(login): browser brand stays with its..." · Reviewed by Greptile