Skip to content

fix(login): reuse a valid session instead of always starting a device flow (#651) - #658

Merged
saadqbal merged 5 commits into
developfrom
fix/651-login-reuse-valid-session
Sep 11, 2026
Merged

fix(login): reuse a valid session instead of always starting a device flow (#651)#658
saadqbal merged 5 commits into
developfrom
fix/651-login-reuse-valid-session

Conversation

@saadqbal

@saadqbal saadqbal commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

tracebloc login always 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.

login now 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>", --force is 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

  • Feature
  • Bug fix
  • Tech-debt / refactor
  • Docs
  • Security / hardening
  • Breaking change

What changed

  • Short-circuit. A stored session for the target env is confirmed with a live WhoAmI — not trusted off disk, because a revoked token is still a token on disk — and on success login reports it and exits 0 without requesting a device code.
  • --force skips the short-circuit entirely, including the probe: switching accounts, or replacing a session you believe is stale. Same sense as delete --force (proceed despite the state that would otherwise stop you), not --yes (skip a confirmation).
  • Honest fall-through copy. The issue asks login to distinguish expired from rejected; it only claims what it can tell apart:
    • a local expires_at that has passed is named to the second, and the dead token is never presented to the backend;
    • a 401/403 is reported as rejected (the backend returns the same status for expired and revoked, so naming a cause there would be a guess);
    • anything else — DNS, a 5xx — is "couldn't check", which is not a verdict on the session. Same distinction auth status --check already draws.
    • a 426 surfaces the upgrade instruction and starts no flow: 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:

  1. the current session, when sessionEnv resolves it to the target — the same predicate auth status --check uses, so login and the installer's probe can't disagree about what "signed in to this env" means;
  2. failing that, that env's own profile. Profiles are per-env (R10), so a machine on prod can hold a live dev token; login --env dev adopts it and moves current_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/code was 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).

go test ./... -count=1                                    # all green
make vet lint lint-full fmt-check file-budget check-style deadcode

Output of the gates:

go vet ./...
==> fmt-check: 227 tracked Go file(s) clean
ok: internal/cli/data.go 250 <= 500 (auth.go is unbudgeted; 542 -> 672)
golangci-lint run -> 0 issues.
errcheck / ineffassign / misspell / staticcheck -> clean
==> deadcode: clean (allowlist: 6 entries)
== tracebloc style guard ==  ok: style + terminology clean

Goldens regenerated (TB_UPDATE_GOLDEN=1) for the new --force flag and copy: 07-login.golden, zz-all-strings.golden. command-surface.golden is 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: login does strictly less than before when a session is valid, and nothing changes for a signed-out machine. The installer probes with auth status --check before calling login, and that probe's predicate is the one the short-circuit reuses — so the two stay in agreement. No backend change; --force restores the exact prior behaviour.

Checklist

  • Tests added / updated and passing locally
  • Docs updated if behavior or config changed — login --help carries the new behaviour and the --force opt-out; no CLAUDE.md / BUGBOT.md / RFC statement is made false by this change
  • No secrets / credentials in the diff
  • For security-sensitive paths: appropriate reviewer requested
  • Cross-repo issues use Fixes tracebloc/<repo>#N — N/A, same-repo
  • If this depends on a change in another repo — N/A, CLI-only
  • go build ./..., go vet, and the Lint job's checks pass locally
  • Terminal output follows STYLE.md — new copy goes through Printer (Successf/Hintf/Detailf), no hardcoded colour or emoji; scripts/check-style.sh passes

🤖 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 --force paths are unchanged.

Overview
tracebloc login no longer always starts the OAuth device flow. When the machine already has a session for the target env, it probes the backend with WhoAmI and, if accepted, exits 0 with Already signed in—no browser step. --force skips 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 shared normalizeEnv so v1 "Dev" keys are not missed. Fall-through to device login explains local expiry, 401/403 rejection, unreachable backend, or 426 upgrade using shared classifyWhoAmIError (also used by auth status --check). Reuse updates current_env and saves under the original profile key.

Tests assert device-code requests are skipped when appropriate; help/golden strings document --force and new messages.

Reviewed by Cursor Bugbot for commit e27b36b. Bugbot is set up for automated code reviews on this repo. Configure here.

… 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>
@saadqbal saadqbal self-assigned this Sep 11, 2026
@saadqbal
saadqbal requested a review from LukasWodka September 11, 2026 08:20
…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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread internal/cli/auth.go
Comment thread internal/cli/auth.go
saadqbal and others added 2 commits September 11, 2026 15:11
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>
Comment thread internal/cli/auth.go Outdated
… (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>
@saadqbal

Copy link
Copy Markdown
Collaborator Author

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:

  • Ctrl-C during the probe — fixed in 413fcbf, ctx.Err() early-return ahead of the classification, same guard as pollForToken. Test asserts exit 130, silence, zero device codes.
  • Case-sensitive profile lookup — fixed in 413fcbf. You were right that the new tests only covered the current-env case; TestLogin_ReuseFindsARawKeyedProfileThatIsNotCurrent now covers a "Dev" profile that is not current, asserting no flow and no duplicate "dev" profile. Lookup folds the map's own keys, exact match first, sorted tie-break for determinism.
  • Shared classifiere27b36b, replied on that thread.

Thanks — the second one was a real hole in my own test, not just the code.

@LukasWodka LukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@saadqbal
saadqbal merged commit d75e265 into develop Sep 11, 2026
29 checks passed
@saadqbal
saadqbal deleted the fix/651-login-reuse-valid-session branch September 11, 2026 10:54
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.

tracebloc login always starts a new device flow, even when already signed in

2 participants