fix(login): reuse a valid session instead of always starting a device flow (#651) - #658
Conversation
… flow (cli#651) `tracebloc login` went straight to a device code even when the machine already held a valid session for the target env. On a headless host that is a dead end rather than an inconvenience: the credentials are on disk, but the command insists on a browser approval it has no way to complete — turning "re-run login to be safe" into a hard stop for any script or runbook. login now checks the session it already has before asking for one: - A session for the target env that the backend accepts ends the command at exit 0 with "Already signed in as <account>". The session is confirmed with a live WhoAmI rather than trusted off disk — a revoked token is still a token on disk. - `--force` is the opt-out (switching accounts, replacing a session believed stale) and skips the short-circuit entirely, including the probe. Same sense as `delete --force`: proceed despite the state that would otherwise stop you. - Every fall-through says WHY first, and only claims what it can tell apart: a LOCAL expires_at that has passed is named to the second (and is not presented to the backend), a 401/403 is reported as rejected, and anything else — DNS, a 5xx — is "couldn't check", not a verdict on the session. A 426 surfaces the upgrade instruction and starts no flow, since a fresh flow would hit the same version floor. "A session for this env" is resolved two ways, so credentials already on disk aren't stranded: the current session when sessionEnv resolves it to the target (the same predicate `auth status --check` uses, so login and the installer's probe cannot disagree), else that env's own profile — a machine on prod can hold a live dev token, and `login --env dev` adopts it and switches current_env. The profile is written back under the key it was found under, so a v1-migrated `"Dev"` config doesn't gain a second, lower-cased profile beside the real one. Tests cover each arm by asserting whether /device/code was requested at all — the browser demand is the behaviour that matters. Goldens regenerated for the new --force flag and copy. Closes #651 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cli#651) version-bump-gate failed on the previous head: VERSION still read 0.10.25, v0.10.25 is already released, and this PR changes a published file (internal/cli/auth.go, matching `internal/*`). The release train cuts the tag from this file and never bumps it, so leaving it stale doesn't fail here — it fails the next prod hop, days later, on somebody else (backend#1561). 0.10.26 is free: v0.10.25 is the highest released final version and no v0.10.26 tag exists. Patch, not minor — this ships one bug fix, and the new `login --force` flag is additive with no change to any existing invocation. (Open PR #657 also touches VERSION, but bumps 0.10.24 -> 0.10.25, which is already released — its gate is red for the same reason and it needs a rebase. It does not claim 0.10.26.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2dfd636. Configure here.
Picks up #657 (TRACEBLOC_ENV alias + TRACEBLOC_ prefix), which landed on develop after this branch opened. Two conflicts, both from #657 rewording the same `login` surface this branch extends, and both resolved by taking BOTH sides: - internal/cli/auth.go — `--env`'s help string gains #657's "$TRACEBLOC_ENV, then legacy $CLIENT_ENV" wording; this branch's `--force` registration is kept beside it. - 07-login.golden — regenerated rather than hand-merged, so it reflects the real `--help` output of the merged command. VERSION did not conflict: #657 bumped 0.10.24 -> 0.10.25, this branch 0.10.25 -> 0.10.26, so the merged file keeps 0.10.26 — still above every released tag (v0.10.25 is the highest), which is what version-bump-gate requires. No behaviour change beyond the two sides combined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e (cli#651) Two Bugbot findings on PR #658, both real. 1. storedSessionFor's second arm indexed cfg.Profiles with the ALREADY NORMALISED target env, so a live token written under a raw key such as `"Dev"` (config.migrateV1 stores a v1 `env` verbatim) went unseen the moment that profile stopped being the current one. `login --env dev` then ran a device flow and saved a SECOND profile under `"dev"`, stranding a perfectly good session beside it — on exactly the headless host this issue is about. The existing raw-key test could not see it: it keeps `"Dev"` CURRENT, which arm 1 catches before the map lookup is reached. The gap only opens after a `login --env` elsewhere has moved current_env. Lookup now folds the map's own KEYS (new profileKeyed). Exact match wins; the fold is a tie-break scanned in sorted order, so a config holding both `"Dev"` and `"dev"` cannot answer differently run to run on Go's randomised map iteration. The trim+lower-case is extracted from sessionEnv as normalizeEnv and shared, rather than hand-rolled a second time — a second copy is how the keys stop matching in the first place. 2. A cancelled context surfaces on the WhoAmI call as a plain error, so Ctrl-C during the new probe landed in the "couldn't check" arm: it printed "signing in again" and then failed RequestDeviceCode with exit 1, where every other interrupt in login exits 130 silently. Guarded on ctx.Err() before the classification — the same guard, for the same reason, as pollForToken's. Tests: the not-current raw-key case (asserts no flow, no duplicate profile, current_env set to the FOUND key), a determinism test running profileKeyed 50x over a config with three case variants, and a cancelled probe asserting exit 130, silence, and zero device codes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (cli#651) Review on PR #658: reuseStoredSession's 426 / 401-403 / everything-else classification of a WhoAmI failure duplicated the identical block in runAuthCheck a few dozen lines down. The duplication is one this PR introduced, so it is fixed here rather than left for a follow-up — "fix the class, not the instance". classifyWhoAmIError returns a named whoAmIVerdict (whoAmIUnverified / whoAmIRejected / whoAmIUpgradeRequired) plus the *api.UpgradeRequiredError, so the caller surfaces the server's own version floor rather than a paraphrase. The three arms are deliberately not collapsible: only whoAmIRejected is a statement about the credential. A 5xx folded into it would tell someone to re-authenticate during an outage, and a 426 folded into it would send them to a browser step that cannot lift a version floor. The COPY stays at the call sites. The two commands answer different questions — "should I start a device flow?" vs "what is this exit code?" — and say so in different words; only the classification is shared. No user-facing string changed, and the goldens confirm it (regenerated, no diff). Test: a table over 401/403/426/500/404/429, a transport error, a cancelled context, and wrapped 401/426 — wrapped because both call sites receive the error through the api client's own fmt.Errorf wrapping, so matching the concrete type alone would silently demote every real verdict to "unverified". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Lukas — your three review points all landed, but two of the threads have since disappeared from the PR (the comment IDs 404), so noting the outcome here:
Thanks — the second one was a real hole in my own test, not just the code. |
LukasWodka
left a comment
There was a problem hiding this comment.
Reviewed through the last few pushes (head now e27b36b). Verified locally with a scratch checkout: go build ./..., go vet ./..., and the full go test ./... suite (18 packages) are clean.
Confirmed the core mechanism: the short-circuit is a genuine backend round-trip via WhoAmI, not a file-presence check — a revoked-but-present token correctly falls through to the device flow. --force bypasses the check entirely. --env/current-session/cross-env profile lookup and the fall-through messaging (expired vs rejected vs unreachable vs version-floor) are each distinct and accurate.
Both of Bugbot's findings (raw-keyed profile lookup missing a non-current session; Ctrl-C during the probe starting a device flow instead of exiting quietly) are real — I independently reproduced the raw-key one with a standalone test before seeing Bugbot's comment, and it now passes against the fix. The DRY duplication between this probe's error classification and auth status --check's has also been cleaned up into a shared helper.
No remaining code issues from my side. Approving.

Summary
tracebloc loginalways started a new device flow, even when the machine already held a valid session for the target env — so on a headless host, "re-run login to be safe" was a hard stop rather than a no-op: the credentials were on disk, but the command insisted on a browser approval it had no way to complete.loginnow checks the session it already has before asking for one: a session the backend accepts ends the command at exit 0 with "Already signed in as <account>",--forceis the opt-out for re-authenticating anyway, and every fall-through to the device flow first says why — naming only what it can actually tell apart.Related
Closes #651
Type of change
What changed
WhoAmI— not trusted off disk, because a revoked token is still a token on disk — and on successloginreports it and exits 0 without requesting a device code.--forceskips the short-circuit entirely, including the probe: switching accounts, or replacing a session you believe is stale. Same sense asdelete --force(proceed despite the state that would otherwise stop you), not--yes(skip a confirmation).expires_atthat has passed is named to the second, and the dead token is never presented to the backend;auth status --checkalready draws."A session for this env" is resolved two ways, so credentials already on disk aren't stranded:
sessionEnvresolves it to the target — the same predicateauth status --checkuses, sologinand the installer's probe can't disagree about what "signed in to this env" means;login --env devadopts it and movescurrent_env, since answering "already signed in" without moving the pointer would leave every following command on prod.The profile is written back under the key it was found under, so a v1-migrated
"Dev"config doesn't gain a second, lower-cased profile beside the real one.Test plan
Nine new tests in
internal/cli/auth_test.go, each asserting whether/device/codewas requested at all — the browser demand is the behaviour that matters, the copy is secondary. Coverage: the issue's reproduction,--force, rejected (401), unverifiable (500, must not read as rejected), locally expired (and not presented to the backend), unexpired-but-still-probed, 426, cross-env profile adoption, raw-key preservation, and the signed-out path (unchanged: one flow, one post-flow confirmation, no extra probe).Output of the gates:
Goldens regenerated (
TB_UPDATE_GOLDEN=1) for the new--forceflag and copy:07-login.golden,zz-all-strings.golden.command-surface.goldenis unchanged — the surface pins class/shape, and no command was added or re-parented.Deployment notes
Behaviour change on a happy path, but only in the safe direction:
logindoes strictly less than before when a session is valid, and nothing changes for a signed-out machine. The installer probes withauth status --checkbefore callinglogin, and that probe's predicate is the one the short-circuit reuses — so the two stay in agreement. No backend change;--forcerestores the exact prior behaviour.Checklist
login --helpcarries the new behaviour and the--forceopt-out; no CLAUDE.md / BUGBOT.md / RFC statement is made false by this changeFixes tracebloc/<repo>#N— N/A, same-repogo build ./...,go vet, and the Lint job's checks pass locallyPrinter(Successf/Hintf/Detailf), no hardcoded colour or emoji;scripts/check-style.shpasses🤖 Generated with Claude Code
Note
Medium Risk
Changes authentication entry behavior and token/session handling on disk, but only short-circuits when the backend confirms the session; signed-out and
--forcepaths are unchanged.Overview
tracebloc loginno longer always starts the OAuth device flow. When the machine already has a session for the target env, it probes the backend withWhoAmIand, if accepted, exits 0 with Already signed in—no browser step.--forceskips that path for account switches or stale sessions.Session lookup covers the current env (aligned with
auth status --check) and per-env profiles, with case-insensitive profile keys via sharednormalizeEnvso v1"Dev"keys are not missed. Fall-through to device login explains local expiry, 401/403 rejection, unreachable backend, or 426 upgrade using sharedclassifyWhoAmIError(also used byauth status --check). Reuse updatescurrent_envand saves under the original profile key.Tests assert device-code requests are skipped when appropriate; help/golden strings document
--forceand new messages.Reviewed by Cursor Bugbot for commit e27b36b. Bugbot is set up for automated code reviews on this repo. Configure here.