Files
hermes-agent/hermes_cli/github_api.py
ethernet 6ba45b300c fix(update-check): authenticate api.github.com calls and name the branch-tip failure
The passive source check moved into hermes_cli/source_check.py during the
pm/ rewrite, and with it the GitHub calls left the desktop — but the four
main fixes that lived on the desktop side (gh-CLI token fallback
c61a11474b, anonymous retry e79ba2d7f2, rate-limit headers
c7d4d32800, failure copy 66a13703b3) did not follow. `_request` sent no
Authorization, so every client behind one NAT/VPN exit shared the anonymous
60/hour budget, and `_branch_tip` collapsed every failure into "Could not
resolve the remote branch tip." — a 403 rate limit read as a Hermes bug.

hermes_cli/github_api.py owns the credential ladder (GITHUB_TOKEN, GH_TOKEN,
`gh auth token` cached per process, anonymous; a 401 on a token retries
anonymously) and the failure copy (rate limit with reset time and the
GITHUB_TOKEN remedy, 5xx as GitHub trouble, else the status/transport
error). `_branch_tip` returns that reason and check_for_updates puts it in
the message.

apps/desktop/electron/github-api-auth.ts had no caller left (the desktop no
longer talks to api.github.com) — removed with its test; the behaviour now
lives where the request is made.
2026-09-21 18:56:56 -04:00

108 lines
4.7 KiB
Python

"""Credentials and failure copy for the update check's api.github.com calls.
The passive source check asks the REST API for a branch tip. Unauthenticated
that budget is 60 requests/hour keyed on the *client IP*, so a shared exit
(office NAT, VPN, proxy) exhausts it for everyone behind it and the check
reports a 403 that read as "Hermes can't reach the update server".
Authenticating moves the caller onto the token's 5,000/hour budget.
Credential ladder: GITHUB_TOKEN, then GH_TOKEN, then ``gh auth token`` (the
gh CLI's own login — the only rung most desktop users have, since a
GUI-launched app inherits a minimal environment; its answer is cached per
process so the check never spawns gh more than once), then anonymous. A
token GitHub rejects (401) drops that request to anonymous. The token itself
never reaches a log line or an error string.
"""
from __future__ import annotations
import logging
import os
import subprocess
import time
import urllib.error
from typing import Optional
logger = logging.getLogger(__name__)
GITHUB_TOKEN_ENV_VARS = ("GITHUB_TOKEN", "GH_TOKEN")
_GH_CLI_TIMEOUT_SECONDS = 3
_gh_cli_cache: Optional[str] = None
_gh_cli_probed = False
def github_token_from_env(env=os.environ) -> Optional[str]:
"""First non-blank env token, trimmed. A blank value falls through to the next."""
for name in GITHUB_TOKEN_ENV_VARS:
value = (env.get(name) or "").strip()
if value:
return value
return None
def _gh_cli_token() -> Optional[str]:
global _gh_cli_cache, _gh_cli_probed
if _gh_cli_probed:
return _gh_cli_cache
_gh_cli_probed = True
from hermes_cli._subprocess_compat import windows_hide_flags
try:
result = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=_GH_CLI_TIMEOUT_SECONDS, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(),
)
except (OSError, subprocess.SubprocessError) as exc:
logger.debug("gh CLI token lookup unavailable: %s", exc)
return None
token = result.stdout.strip() if result.returncode == 0 else ""
_gh_cli_cache = token or None
return _gh_cli_cache
def github_token() -> Optional[str]:
"""The credential for this request, or None for anonymous."""
return github_token_from_env() or _gh_cli_token()
def describe_github_failure(exc: BaseException, authenticated: bool, now: Optional[float] = None) -> str:
"""One line a user can act on instead of a generic "could not resolve" (#105855).
A 403/429 with ``x-ratelimit-remaining: 0`` is the anonymous per-IP budget spent
by every client behind one exit — the line names the fix the user actually has
(a GITHUB_TOKEN) and the real reset time. Any other status is reported as what it is.
"""
if isinstance(exc, urllib.error.HTTPError):
status = exc.code
headers = exc.headers or {}
remaining = _header_int(headers, "x-ratelimit-remaining")
if status in (403, 429) and remaining == 0:
reset = _header_int(headers, "x-ratelimit-reset")
when = "within an hour"
if reset is not None:
minutes = int((reset - (time.time() if now is None else now) + 59) // 60)
if minutes >= 1:
when = "in about a minute" if minutes == 1 else f"in about {minutes} minutes"
if authenticated:
return f"GitHub API rate limit reached for your GITHUB_TOKEN (HTTP {status}) — it resets {when}."
return (f"GitHub API rate limit reached (HTTP {status}): anonymous requests are limited to 60 per hour "
f"per network address, shared with everyone behind the same connection. It resets {when}; "
"setting GITHUB_TOKEN in the environment lifts the limit.")
if status >= 500:
return (f"GitHub is having trouble (HTTP {status} from api.github.com) — check githubstatus.com "
"and try again later.")
return f"api.github.com answered HTTP {status}."
if isinstance(exc, urllib.error.URLError):
reason = exc.reason
if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower():
return "api.github.com did not answer within 10 seconds."
return f"Connection to api.github.com failed ({reason}) — check your connection, firewall or proxy."
if isinstance(exc, TimeoutError):
return "api.github.com did not answer within 10 seconds."
return f"api.github.com: {exc}"
def _header_int(headers, name: str) -> Optional[int]:
try:
return int(str(headers.get(name, "")).strip())
except (TypeError, ValueError):
return None