fix(desktop): env-only GitHub token for the update check, honest rate-limit copy

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.
This commit is contained in:
teknium1 authored and Teknium committed 2026-09-16 17:22:39 -07:00
1 parent b1c91a1a97
commit c7d4d32800
7 files changed
+155 -174

No files matched your search

+9 -46
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import { githubApiHeaders, githubTokenFromEnv, resolveGithubToken } from './github-api-auth'
import { githubApiHeaders, githubTokenFromEnv } from './github-api-auth'
// The exact headers the anonymous update check sends today; the change must
// leave this shape byte-identical when no token is configured.
@@ -11,61 +11,24 @@ const UPDATE_CHECK_HEADERS = {
'User-Agent': 'hermes-desktop-update-check'
}
test('GITHUB_TOKEN wins over GH_TOKEN', () => {
test('GITHUB_TOKEN wins over GH_TOKEN, blanks fall through, values are trimmed', () => {
assert.equal(githubTokenFromEnv({ GITHUB_TOKEN: 'pat-a', GH_TOKEN: 'pat-b' }), 'pat-a')
})
test('a blank env token falls through to the next var, and the value is trimmed', () => {
assert.equal(githubTokenFromEnv({ GITHUB_TOKEN: ' ', GH_TOKEN: ' pat-b ' }), 'pat-b')
assert.equal(githubTokenFromEnv({ GITHUB_TOKEN: '', GH_TOKEN: '' }), null)
assert.equal(githubTokenFromEnv({}), null)
})
test('the anonymous path sends no Authorization key at all', () => {
const headers = githubApiHeaders(UPDATE_CHECK_HEADERS, null)
test('anonymous sends no Authorization key at all; a token adds the `token` scheme without touching the base', () => {
const anonymous = githubApiHeaders(UPDATE_CHECK_HEADERS, null)
assert.deepEqual(headers, UPDATE_CHECK_HEADERS)
assert.equal('Authorization' in headers, false)
})
assert.deepEqual(anonymous, UPDATE_CHECK_HEADERS)
assert.equal('Authorization' in anonymous, false)
test('a token adds the `token`-scheme Authorization header without disturbing the base headers', () => {
const headers = githubApiHeaders(UPDATE_CHECK_HEADERS, 'pat-a')
const authed = githubApiHeaders(UPDATE_CHECK_HEADERS, 'pat-a')
assert.equal(headers.Authorization, 'token pat-a')
assert.equal(headers['User-Agent'], 'hermes-desktop-update-check')
assert.equal(authed.Authorization, 'token pat-a')
assert.equal(authed['User-Agent'], 'hermes-desktop-update-check')
// The caller's base object is a constant; mutating it would leak the token
// into every later request that builds from it.
assert.equal('Authorization' in UPDATE_CHECK_HEADERS, false)
})
test('the gh CLI is consulted only when the env rung is empty', async () => {
let ghCalls = 0
const readGhCliToken = async () => {
ghCalls += 1
return 'gh-token'
}
assert.deepEqual(await resolveGithubToken({ env: { GH_TOKEN: 'pat-a' }, readGhCliToken }), {
token: 'pat-a',
source: 'env'
})
assert.equal(ghCalls, 0)
assert.deepEqual(await resolveGithubToken({ env: {}, readGhCliToken }), { token: 'gh-token', source: 'gh-cli' })
assert.equal(ghCalls, 1)
})
test('a missing, blank or failing gh degrades to the anonymous path instead of throwing', async () => {
const missing = async () => null
const blank = async () => ' '
const failing = async () => {
throw new Error('spawn gh ENOENT')
}
assert.deepEqual(await resolveGithubToken({ readGhCliToken: missing }), { token: null, source: 'none' })
assert.deepEqual(await resolveGithubToken({ readGhCliToken: blank }), { token: null, source: 'none' })
assert.deepEqual(await resolveGithubToken({ readGhCliToken: failing }), { token: null, source: 'none' })
})
+5 -43
View File
@@ -7,17 +7,11 @@
// reach the update server". Authenticating moves the caller onto the token's
// 5,000/hour budget.
//
// Precedence mirrors the Python client (tools/skills_hub_github.py::GitHubAuth):
// GITHUB_TOKEN / GH_TOKEN first, then the `gh` CLI's own keyring token. The
// ladder itself stays subprocess-free and pure; the `gh` lookup is injected by
// the caller (main.ts owns the binary resolution and the child process).
export type GithubTokenSource = 'env' | 'gh-cli' | 'none'
export interface GithubTokenResolution {
token: string | null
source: GithubTokenSource
}
// Only the env rung of the Python client's ladder
// (tools/skills_hub_github.py::GitHubAuth) is wired: GITHUB_TOKEN, then
// GH_TOKEN, read from the process env per request and never stored. Whether a
// GUI app may also spend the user's `gh` CLI login on a passive background
// check is a product call, kept out of this module.
/** Env vars consulted, in precedence order. */
export const GITHUB_TOKEN_ENV_VARS = ['GITHUB_TOKEN', 'GH_TOKEN'] as const
@@ -49,35 +43,3 @@ export function githubApiHeaders(base: Record<string, string>, token?: string |
return headers
}
/**
* Resolve a token for api.github.com. `gh` runs only when the env rung is
* empty, and a missing / unauthenticated / slow `gh` resolves to
* `{ token: null, source: 'none' }` rather than throwing: the anonymous
* 60/hour budget is a legitimate fallback rung, not an error.
*/
export async function resolveGithubToken({
env = {},
readGhCliToken
}: {
env?: Record<string, string | undefined>
readGhCliToken?: () => Promise<string | null>
} = {}): Promise<GithubTokenResolution> {
const fromEnv = githubTokenFromEnv(env)
if (fromEnv) {
return { token: fromEnv, source: 'env' }
}
if (!readGhCliToken) {
return { token: null, source: 'none' }
}
try {
const token = (await readGhCliToken())?.trim()
return token ? { token, source: 'gh-cli' } : { token: null, source: 'none' }
} catch {
return { token: null, source: 'none' }
}
}
+23 -84
View File
@@ -1,4 +1,4 @@
import { execFile, execFileSync, spawn } from 'node:child_process'
import { execFileSync, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import http from 'node:http'
@@ -217,7 +217,7 @@ import {
import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { registerGitIpc } from './git-ipc'
import { githubApiHeaders, resolveGithubToken } from './github-api-auth'
import { githubApiHeaders, githubTokenFromEnv } from './github-api-auth'
import { desktopBackendSpawnEnv, guestOnboardingEnabled, skipIntroEnabled } from './guest-onboarding'
import { readAndConsumeHandoffResult } from './handoff-result'
import {
@@ -411,7 +411,15 @@ import {
windowOpacityFor,
windowOpacityOptions
} from './translucency'
import { branchTipApiUrl, cacheIsFresh, compareApiUrl, githubRepoSlug, parseCompare } from './update-api-check'
import {
branchTipApiUrl,
cacheIsFresh,
compareApiUrl,
describeUpdateCheckFailure,
githubRepoSlug,
parseCompare,
rateLimitFromHeaders
} from './update-api-check'
import { waitForUpdateClearance } from './update-gate'
import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker'
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
@@ -2994,45 +3002,6 @@ function resolveGhBinary() {
return _ghBinaryCache
}
// Credentials for the update check's api.github.com calls: env first (an
// explicit GITHUB_TOKEN / GH_TOKEN wins), then the `gh` CLI's keyring token —
// the same ladder the Python client uses (tools/skills_hub_github.py). A shared
// exit IP (proxy/VPN/office NAT) exhausts the anonymous 60/hour budget for
// everyone behind it, which is what makes an update check report a rate limit.
//
// Resolved once per process: a token cannot change under a running app, and
// re-shelling `gh` on every check would be pure work. Anonymous stays a valid
// rung — a missing `gh` is not an error.
let _githubApiTokenCache = null
let _githubApiTokenResolved = false
function readGhCliToken(): Promise<string | null> {
return new Promise(resolve => {
execFile(
resolveGhBinary(),
['auth', 'token'],
{ env: process.env, windowsHide: true, timeout: 5_000 },
(err, stdout) => resolve(err ? null : String(stdout || ''))
)
})
}
async function resolveGithubApiToken() {
if (!_githubApiTokenResolved) {
const { token, source } = await resolveGithubToken({ env: process.env, readGhCliToken })
_githubApiTokenCache = token
_githubApiTokenResolved = true
if (token) {
// One line, so a rate-limit report can name the budget the check spent.
rememberLog(`[updates] api.github.com credentials: ${source}`)
}
}
return _githubApiTokenCache
}
function recentHermesLog() {
return hermesLog.slice(-20).join('\n')
}
@@ -3365,47 +3334,11 @@ async function checkUpdatesViaLsRemote({ updateRoot, branch, currentSha }) {
return { behind: null, updateAvailable: true, targetSha, commits: [] }
}
// One line a user can act on (or paste into a bug report) instead of the
// generic "couldn't reach the update server": which host, which failure.
// #105855 was a run of GitHub outages that read as a Hermes bug because the
// UI hid the cause.
function describeUpdateCheckFailure(error) {
const status = error?.statusCode
const code = error?.code
if (status === 403 || status === 429) {
return `GitHub API rate limit reached (HTTP ${status}) — try again in an hour.`
}
if (typeof status === 'number' && status >= 500) {
return `GitHub is having trouble (HTTP ${status} from api.github.com) — check githubstatus.com and try again later.`
}
if (typeof status === 'number') {
return `api.github.com answered HTTP ${status}.`
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
return 'DNS lookup for api.github.com failed — check your connection or proxy.'
}
if (code === 'ETIMEDOUT' || error?.message === 'timeout') {
return 'api.github.com did not answer within 10 seconds.'
}
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
return `Connection to api.github.com failed (${code}) — a firewall or proxy may be blocking it.`
}
if (typeof code === 'string' && /CERT|SSL|TLS/i.test(code)) {
return `TLS handshake with api.github.com failed (${code}) — a proxy may be intercepting HTTPS.`
}
return `api.github.com: ${error?.message || String(error)}`
}
async function fetchGitHubApi(url, accept = 'application/vnd.github+json') {
const token = await resolveGithubApiToken()
// GITHUB_TOKEN / GH_TOKEN from the environment, when present, moves the call
// from the anonymous 60/hour-per-IP budget to the token's 5,000/hour one; the
// header shape is otherwise unchanged. Read per request, never stored.
function fetchGitHubApi(url, accept = 'application/vnd.github+json') {
const token = githubTokenFromEnv(process.env)
return new Promise((resolve, reject) => {
const req = https.get(
@@ -3429,7 +3362,13 @@ async function fetchGitHubApi(url, accept = 'application/vnd.github+json') {
const body = Buffer.concat(chunks).toString('utf8')
if ((res.statusCode || 500) >= 400) {
reject(Object.assign(new Error(`HTTP ${res.statusCode}`), { statusCode: res.statusCode }))
reject(
Object.assign(new Error(`HTTP ${res.statusCode}`), {
statusCode: res.statusCode,
...rateLimitFromHeaders(res.headers),
authenticated: Boolean(token)
})
)
return
}
@@ -16,8 +16,10 @@ import { test } from 'vitest'
import {
branchTipApiUrl,
cacheIsFresh,
describeUpdateCheckFailure,
githubRepoSlug,
parseCompare,
rateLimitFromHeaders,
UPDATE_CHECK_FAILURE_TTL_MS,
UPDATE_CHECK_TTL_MS
} from './update-api-check'
@@ -79,3 +81,33 @@ test('compare payload maps to the behind count and a newest-first commit list; m
'https://api.github.com/repos/nousresearch/hermes-agent/commits/bb%2Fgui'
)
})
// #112615: behind a shared exit IP the anonymous 60/hour budget is spent by
// neighbours, so "try again in an hour" is advice the user cannot act on. A
// genuine rate limit (x-ratelimit-remaining: 0) must name the shared-address
// cause, the real reset time and the GITHUB_TOKEN remedy; any other 403 must
// not be reported as a rate limit.
test('a rate-limited 403 names the shared-IP cause, the reset time and GITHUB_TOKEN; a plain 403 is not a rate limit', () => {
const now = 1_700_000_000_000
const limited = {
statusCode: 403,
...rateLimitFromHeaders({ 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(now / 1000 + 25 * 60) }),
authenticated: false
}
const message = describeUpdateCheckFailure(limited, now)
assert.match(message, /60 per hour per network address/)
assert.match(message, /in about 25 minutes/)
assert.match(message, /GITHUB_TOKEN/)
assert.match(describeUpdateCheckFailure({ ...limited, authenticated: true }, now), /for your GITHUB_TOKEN/)
// Missing or non-zero rate-limit headers: an ordinary 403, reported as such.
assert.equal(describeUpdateCheckFailure({ statusCode: 403 }), 'api.github.com answered HTTP 403.')
assert.equal(
describeUpdateCheckFailure({ statusCode: 403, ...rateLimitFromHeaders({ 'x-ratelimit-remaining': '57' }) }),
'api.github.com answered HTTP 403.'
)
})
+83
View File
@@ -109,3 +109,86 @@ export function parseCompare(payload: unknown): { behind: number; commits: Compa
return { behind: ahead, commits }
}
/** GitHub's rate-limit headers off a failed response, when it carried them. */
export function rateLimitFromHeaders(headers: Record<string, string | string[] | undefined>): {
rateLimitRemaining: number | null
rateLimitReset: number | null
} {
const num = (name: string) => {
const raw = headers[name]
const value = Number.parseInt(Array.isArray(raw) ? raw[0] : String(raw ?? ''), 10)
return Number.isFinite(value) ? value : null
}
return { rateLimitRemaining: num('x-ratelimit-remaining'), rateLimitReset: num('x-ratelimit-reset') }
}
export interface UpdateCheckFailure {
statusCode?: number
code?: string
message?: string
/** `x-ratelimit-remaining` on the failed response; 0 marks a genuine rate limit. */
rateLimitRemaining?: number | null
/** `x-ratelimit-reset` (unix seconds) on the failed response. */
rateLimitReset?: number | null
/** Whether the failed request carried a GITHUB_TOKEN / GH_TOKEN credential. */
authenticated?: boolean
}
/**
* One line a user can act on (or paste into a bug report) instead of the
* generic "couldn't reach the update server": which host, which failure.
* #105855 was a run of GitHub outages that read as a Hermes bug because the
* UI hid the cause.
*
* A 403 with `x-ratelimit-remaining: 0` is the anonymous per-IP budget spent
* by every client behind one exit (office NAT, VPN, proxy) — waiting an hour
* does not help when the neighbours refill it, so the line names the fix the
* user actually has (a GITHUB_TOKEN in the environment) and the real reset
* time. Any other 403/429 is reported as what it is rather than as a rate
* limit the user did not cause.
*/
export function describeUpdateCheckFailure(error: UpdateCheckFailure | null | undefined, now = Date.now()): string {
const status = error?.statusCode
const code = error?.code
if ((status === 403 || status === 429) && error?.rateLimitRemaining === 0) {
const resetMs = typeof error.rateLimitReset === 'number' ? error.rateLimitReset * 1000 - now : NaN
const minutes = Number.isFinite(resetMs) && resetMs > 0 ? Math.ceil(resetMs / 60_000) : null
const when = minutes === null ? 'within an hour' : minutes === 1 ? 'in about a minute' : `in about ${minutes} minutes`
return error.authenticated
? `GitHub API rate limit reached for your GITHUB_TOKEN (HTTP ${status}) — it resets ${when}.`
: `GitHub API rate limit reached (HTTP ${status}): anonymous requests are limited to 60 per hour per network ` +
`address, shared with everyone behind the same connection. It resets ${when}; ` +
`setting GITHUB_TOKEN in the environment lifts the limit.`
}
if (typeof status === 'number' && status >= 500) {
return `GitHub is having trouble (HTTP ${status} from api.github.com) — check githubstatus.com and try again later.`
}
if (typeof status === 'number') {
return `api.github.com answered HTTP ${status}.`
}
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
return 'DNS lookup for api.github.com failed — check your connection or proxy.'
}
if (code === 'ETIMEDOUT' || error?.message === 'timeout') {
return 'api.github.com did not answer within 10 seconds.'
}
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') {
return `Connection to api.github.com failed (${code}) — a firewall or proxy may be blocking it.`
}
if (typeof code === 'string' && /CERT|SSL|TLS/i.test(code)) {
return `TLS handshake with api.github.com failed (${code}) — a proxy may be intercepting HTTPS.`
}
return `api.github.com: ${error?.message || String(error)}`
}
@@ -182,7 +182,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `GROQ_BASE_URL` | Override the Groq OpenAI-compatible STT endpoint |
| `STT_OPENAI_MODEL` | Override the OpenAI STT model (default: `whisper-1`) |
| `STT_OPENAI_BASE_URL` | Override the OpenAI-compatible STT endpoint |
| `GITHUB_TOKEN` | GitHub token for Skills Hub (higher API rate limits, skill publish) |
| `GITHUB_TOKEN` | GitHub token for Skills Hub (higher API rate limits, skill publish) and the desktop app's update check (`GH_TOKEN` also honoured) |
| `HONCHO_API_KEY` | Cross-session user modeling ([honcho.dev](https://honcho.dev/)) |
| `HONCHO_BASE_URL` | Base URL for self-hosted Honcho instances (default: Honcho cloud). No API key required for local instances |
| `HINDSIGHT_API_KEY` | Hindsight API key for graph-aware persistent memory ([hindsight.vectorize.io](https://hindsight.vectorize.io)) |
+2
View File
@@ -325,6 +325,8 @@ chats decide who replies: [Bot Mode: A Roster of Agents](./bot-mode.md).
The app checks for updates in the background and offers a one-click update when one is ready.
The background check asks the GitHub API for the branch tip. Anonymous GitHub requests are limited to 60 per hour **per network address**, so on a shared connection (office NAT, VPN, proxy) the check can report `GitHub API rate limit reached` even though this machine made almost none of them. If `GITHUB_TOKEN` (or `GH_TOKEN`) is set in the environment the app was launched from, the check spends that token's 5,000/hour budget instead; the token is read from the environment on each request and never stored. Applying an update uses `git`, not the API, and is unaffected.
During a local update, detailed build output streams into the active profile's
`logs/update.log`, including detached `--gateway` updates. It stays out of the
terminal but is available for troubleshooting before the build finishes. The