githubTokenFromGhCli cached the outcome of `gh auth token` for the whole
process, including "none": a user who ran `gh auth login` after launching
the Desktop stayed anonymous (and rate-limited) until an app restart, since
only a 401 on a real token cleared the cache. Only a token is cached now; a
null answer (gh missing, logged out, hung) is re-asked by the next hourly
check, one bounded spawn per check at most.
Part of #112615
Closes the last atom of #112615. A Desktop app started from the Dock, Finder or a
desktop launcher inherits a minimal environment, so the GITHUB_TOKEN / GH_TOKEN rung
landed in #113190 never fires for exactly the users the issue is about (shared exit
IP, 60/hour anonymous budget exhausted by neighbours). The update check now walks the
same ladder as the Python GitHub client (tools/skills_hub_github.py::GitHubAuth):
1. GITHUB_TOKEN, then GH_TOKEN, from the launch env (unchanged).
2. `gh auth token` — execFile with an argv array (no shell), stdin closed, 3 s
timeout, windowsHide. `gh` is resolved on PATH plus the GUI-safe install
locations backend-env already appends for the backend (Homebrew, /usr/local,
~/.local/bin, nix profile, the Windows GitHub CLI installer dirs). The outcome
(token or none) is cached for the process lifetime so gh runs at most once.
3. Anonymous.
A rejected credential (401) still retries anonymously; the log line names the
source (env vars vs gh login) and never the token, once per source per process.
A rejected gh token also drops the cache so a re-login is picked up by the next
check. envTokenRejected is renamed githubTokenRejected since both rungs use it.
Live: with PATH=/usr/bin:/bin:/usr/sbin:/sbin and no env token, the module found
gh, resolved a credential in 135 ms and api.github.com reported a 5000/hour core
budget (anonymous: 60). A logged-out gh and a hanging gh both resolved to null
(anonymous) in 5 ms / 3001 ms.
Docs: the credential ladder is documented on the Desktop "Updating" page and in
the environment-variables reference.
Wiring GITHUB_TOKEN/GH_TOKEN into fetchGitHubApi made a stale or revoked
token fail the check closed ('api.github.com answered HTTP 401.') on
machines where the anonymous request had always worked. An authenticated
401 now logs once that the env token was rejected and repeats the request
without the Authorization header; every other status is surfaced as before.
envTokenRejected is the pure gate so the decision is unit-tested.
Trim the salvaged ladder to its env rung and make the 403 copy actionable.
- github-api-auth.ts keeps githubTokenFromEnv / githubApiHeaders; the
`gh auth token` rung (resolveGithubToken, readGhCliToken, the per-process
cache and the execFile import in main.ts) is dropped. Whether a GUI app may
silently spend the user's gh CLI login on a passive background check is a
product call, not a bug fix; the pure seam makes re-adding it a ~15-line
follow-up.
- fetchGitHubApi reads GITHUB_TOKEN / GH_TOKEN from process.env per request
(nothing stored) and attaches x-ratelimit-remaining / x-ratelimit-reset plus
whether the call was authenticated to the rejected error.
- describeUpdateCheckFailure moves out of the unimportable main.ts into
update-api-check.ts. A 403/429 with x-ratelimit-remaining: 0 now says the
anonymous budget is 60/hour per network address shared with everyone behind
the same connection, gives the real reset time and names GITHUB_TOKEN as the
remedy; a 403 without rate-limit headers is reported as a plain HTTP 403
instead of a rate limit the user did not cause.
- Docs: desktop.md "Updating" and the GITHUB_TOKEN row in
environment-variables.md.
- Tests trimmed to two invariants per module.
Why: behind a shared exit IP "try again in an hour" is advice the user cannot
act on, and the old copy hid the one thing that does help.
The passive update check reads the branch tip SHA from api.github.com. The
anonymous REST budget is 60 requests/hour keyed on the client IP, so one shared
exit (office NAT, VPN, a proxy node many users sit behind) exhausts it for
everyone on it and the check reports `GitHub API rate limit reached (HTTP 403)`
— which reads as "Hermes can't reach the update server", with a retry promise
that cannot help a saturated exit.
Resolve a credential the way the Python client already does
(tools/skills_hub_github.py::GitHubAuth): GITHUB_TOKEN / GH_TOKEN first, then
the `gh` CLI's keyring token, then anonymous. The `gh` rung is what makes this
work for a GUI-launched app, which inherits a minimal environment; the env rungs
alone would leave the affected users unchanged.
- electron/github-api-auth.ts: precedence ladder + header builder (pure; the gh
lookup is injected so the ladder is testable without Electron)
- electron/main.ts: resolve once per process, reuse resolveGhBinary, build the
fetchGitHubApi headers through the module. Anonymous headers are unchanged.
- electron/github-api-auth.test.ts: 6 contracts (env precedence, blank
fall-through, no Authorization key when anonymous, `token` scheme, gh only
consulted when env is empty, gh failure degrades instead of throwing)
Verified on macOS from an exhausted shared exit (remaining 0/60, HTTP 403 for
the tip SHA): the same code path answers 200 / limit 5000 once a token resolves.