* feat(auth): every Nous sign-in is the plain device-code login
Every Nous sign-in (desktop, CLI and TUI /login, gateway /login,
`hermes auth upgrade`) now runs one plain device-code login,
`anon_sign_in.run_sign_in`: device code, a cancel-aware token poll,
save. The free tier the account replaces hands its apps over in the
existing guest handoff step after the save, and every surface prints
what moved and what to reconnect right after "Signed in as ...".
The promotion intent/status calls, the consent page link and the
`cancel_wins_after_promotion` rule are gone, with the Declined,
Superseded, Retired and Unavailable states. A cancel is now one
`Cancelled` state and always wins: nothing is saved.
- The token poll takes a `cancelled` hook and checks it on a <= 1 s
tick, so a desktop cancel or a newer /login stops it at once.
- An expired code maps to `TimedOut` (`reason: timeout` on the desktop).
- The account email is read from the access token's `email` claim.
- TLS goes through `_resolve_verify`, so the CA bundle settings apply.
- /login works with no free tier on disk.
- The desktop start route always uses the flow (`replace_account=True`
keeps "connect another account" working) and the poll route returns
the handoff lines.
- `hermes model` signs a connector-only free tier in instead of
refusing; `_login_nous` prints the handoff result too.
- Every sign-in that saves an account settles the config itself once
its locks are released (`run_sign_in`, `_login_nous`, `hermes auth add
nous`, the billing step-up, the tool-setup login); the handoff runner
only moves apps.
* fix(auth): CI round 1 on the plain sign-in
- The docs page wrote "<= 1 s", which MDX parses as a JSX tag; say
"at least once a second" instead.
- The shared Retry-After parser test still imported the promotion
status poller's helper, which this PR deletes; drop that leg.
* fix(auth): Greptile round 1 on the plain sign-in
- Read the Portal URL inside the profile scope: with no Nous state on
disk, a multi-profile backend read the override after the scope had
exited and signed in against the default Portal.
- An expired_token answer from the token endpoint is TimedOut (the
desktop's timeout screen), the same as running out the clock.
* fix(auth): Greptile round 2 on the plain sign-in
`hermes auth add nous` and the tool-setup Nous login now capture the
free tier's handoff around their save and print what moved and what to
reconnect, the same as the terminal login does.
* fix(auth): Greptile round 3 on the plain sign-in
- The Portal is resolved through the same rule every other Nous call
uses: the operator's override wins over a URL saved in the auth store,
so a staging override no longer signs a desktop "connect another
account" into production.
- Gateway /login holds its registry lock across the final cancel check
and the save, so a replacing /login cannot land between them and the
replaced attempt never saves.
* fix(auth): Greptile round 4 on the plain sign-in
Gateway /login no longer holds its registry lock across the save. The
final cancel check claims the save under the lock, then the save runs
outside it: the event loop takes that lock, and a save can wait on the
auth store. A replacing /login while the save runs is told a sign-in is
finishing instead of cancelling it.
Routing _default_verify through resolve_httpx_verify() made the darwin branch
return httpx's bare True on a healthy install, which drops the certifi pin the
function exists for (Homebrew Python's OpenSSL cannot find the system store).
tests/hermes_cli/test_auth_nous_provider.py::TestResolveVerifyFallback went red
on macOS (pass on origin/main).
Take the bundle from certifi_bundle_path() instead: pin it on darwin, hand over
the platform context when nothing re-derives, keep True elsewhere.
The Nous credential exchange builds its TLS context in
hermes_cli/auth_device_flow._default_verify, which on macOS pinned
certifi.where() directly. That made it a second trust stand outside
agent/ssl_verify, so an install-environment rotation could hand it the
cacert.pem of a deleted environment and every credential refresh failed
until the gateway restarted (#137255, seen in the wild on a launchd
gateway).
_default_verify now asks agent.ssl_verify for the bundle, which
re-derives a path the rotation invalidated. The same one-line move
covers the other three direct certifi.where() consumers — the Weixin
and WeCom aiohttp connectors and the Actual relay's ssl_ca_cert — so
no pin in the tree bypasses the re-derivation.
Review fixes for the abandoned-sign-in change:
- A device-code poll that ran out while the token endpoint kept answering
408/429/5xx/WAF chains a ConnectionError cause, so it records failed/network
instead of abandoned; a code left on authorization_pending is still abandoned.
- xAI keeps the wire OAuth error code: access_denied and expired_token are
abandoned, invalid_client stays failed/auth.
- Codex 401/403 at user-code fetch or token exchange raise AuthError (auth), and
the start route's HTTPException carries the worker's recorded class.
hermes.provider_setup.count recorded every user cancel as event=failed
failure_class=cancelled, on every surface: Esc/Ctrl-C/Back in the CLI pickers,
Cancel on a Desktop/dashboard OAuth sign-in (DELETE /sessions), a cancel that
landed before the start response, and consent declined on the provider page.
A device code left to run out was also failed/other on the Nous, xAI and
Codex dashboard pollers (only the free-tier `timeout` reason and Codex
`expired` status were abandoned). Every provider's failure rate was inflated by
walk-aways (openai-codex desktop: 64 of 75 failed rows were `cancelled`).
- provider_setup_fields: failed + cancelled/expired is recorded abandoned, so
every emitter (CLI, web routers, pollers) gets the same answer.
- setup_failure_class: a lapsed device code (DeviceCodeExpired on the Nous poll,
AuthError code device_code_timeout/timeout, RFC 8628 expired_token) is
`expired`; access_denied is `cancelled`; an error whose __cause__ is a network
error is `network`.
- Dashboard/Desktop pollers keep the exception's closed class on the session
(note_oauth_failure) so `status=error` no longer collapses to `other`;
free-tier anon_unreachable is network, a start-route 504 is network.
- DELETE /sessions settles the metric immediately: a Nous/xAI poll blocks for
the code's lifetime, so the cancel row used to land minutes later or never.
Live (real dashboard server, Desktop-owned, isolated HOME, real providers):
base: nous/openai-codex cancel -> failed/cancelled (xai cancel never
settled); nous code left to lapse -> failed/other
fix: nous/xai-oauth/openai-codex cancel -> abandoned/none, each at cancel
Live (real `hermes model` in tmux, Ctrl-C at the OpenRouter key prompt):
base failed/cancelled, fix abandoned/none.
The edge/WAF backoff in the shared Nous+xAI device-code poll loop had three
gaps found in review:
- Retry-After was parsed with a bare int() and never capped, so a
`Retry-After: 3600` slept an hour past a 5-15 minute device code. Parse it
with the shared agent.retry_utils.parse_retry_after_seconds and bound every
sleep by min(60, time left before the device-code deadline).
- The backoff was written into current_interval, so after a block normal
authorization_pending polls kept the inflated interval and slow_down grew
from it. Keep it in its own edge_backoff, reset on any OAuth JSON response.
- Any non-JSON 403 was treated as transient, disagreeing with the refresh
classifier from the previous commit. Only a 403 carrying
x-vercel-mitigated is the edge speaking; a header-less non-JSON 403 raises
as before. 408/429/5xx stay transient.
Tests reduced to the two invariants: recovery after edge blocks (each sleep
<= 60, back to the server interval afterwards) and a persistent block ends at
the deadline without oversleeping.
The generic RFC 8628 device-token poll loop (shared by the Nous Portal and
xAI flows) aborted the whole login when the token endpoint returned a
non-JSON error body. Vercel fronts the Nous Portal and answers rate-limited
clients with a text/plain 403 (x-vercel-mitigated: deny) or 429 — no JSON
body, so such a response can never carry authorization_pending/slow_down.
One mitigation response mid-approval killed a device login the user may
still be approving in the browser.
Treat non-JSON 403/408/429/5xx as transient: back off (doubling from the
current interval, floor 5s, cap 60s; Retry-After honored when present) and
keep polling until the device code expires. Statuses outside that set keep
the existing abort behavior and JSON OAuth errors keep each caller's exact
error contract.
Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 13ff660c5ebcbade203faad5e63c12de0603af5f)
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
Move the S256 verifier/challenge pair, the loopback callback handler, the bind-first
listener and the serve-until-redirect loop out of auth_spotify into auth_device_flow so a
second loopback PKCE provider does not copy 80 lines of HTTP-server plumbing. Spotify's
behaviour and error codes are unchanged; only its private copies are deleted.
* refactor(auth): one sign-in flow behind SignInState, rendered by the CLI and the desktop
* feat(gateway): /signin signs the free tier into a Nous account from a DM
* feat(cli): chat surfaces name /signin as the sign-in verb
* fix(auth): review follow-ups for the shared sign-in flow and /signin
* fix(i18n): carry the /status free-tier line in every locale catalog
* refactor(cli): the chat sign-in command is /login
* fix(auth): durable override cleanup in the /login sweep, and the sign-in flow in its own modules
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.