Files
hermes-agent/apps
Robin Fernandes 90ff655c81 feat(free-tier): browser challenge primitive for the anonymous token exchange
NAS may answer POST /api/anonymous/token with 428 challenge_required and a
portal URL. Hermes implements the one `browser-v1` primitive: open the URL
(hidden where it can), poll /api/anonymous/challenge/status, exchange again.
Everything the page does (BotID, in-browser proof of work, an interactive
Turnstile fallback, its copy) belongs to the portal and changes without a
Hermes release. The mint stays authoritative throughout.

Python (hermes_cli/anon_challenge.py)
- the exchange reports surface (desktop / cli / gateway), version and the
  browser-v1 capability; a UA of the documented shape goes on every call
- the wait runs outside the auth-store and shared-store locks, under a
  per-profile work lock; only a caller someone is waiting on waits — the
  keepalive tick and a status paint announce the challenge and return
- 403 signin_required / access_denied and any 428 this version has no
  primitive for become a terminal sign-in error, scoped to the token
  exchange; sign-up and the promotion endpoints keep their previous verdicts
- status-poll 429 / 5xx / transport are blips; a 4xx is a verdict (the
  credential is gone); a second 428 starts a fail-fast cooldown so a boot
  burst of token readers does not each re-mint and reopen a window
- the pending challenge is published as the free_tier.challenge event and
  on free_tier.status for a client that connects later; the desktop reports
  how each window ended (free_tier.challenge_result) so the waiter re-mints
  promptly, and an attempt counter lets a fresh foreground attempt reopen a
  window the user closed without a status replay reopening it
- terminal: print the link, open a graphical browser once per ticket; a
  gateway process never opens one; control characters never reach the tty
- challenge URLs are only ever opened on the portal origin the exchange hit

Desktop (apps/desktop/electron/challenge-window.ts)
- the page loads in a hidden, sandboxed window of its own partition, pinned
  to the portal origin (will-navigate, will-redirect, off-origin commits end
  the run); a phase is read only from a main-frame portal URL fragment
- #interactive reveals the window (never for an optional challenge), #done
  and #failed close it; an HTTP error commit, a load failure and a crashed
  renderer are retryable, a closed window is not; timers are bounded
- the renderer relays the event and the status read to one window per
  attempt and reports the outcome once; a backend without the RPC is fine
2026-10-06 23:14:10 +05:30
..