fix(desktop): update check falls back to the gh CLI login before going anonymous

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.
This commit is contained in:
teknium1 authored and Teknium committed 2026-09-17 08:54:13 -07:00
1 parent a1238adfae
commit c61a11474b
5 files changed
+311 -35

No files matched your search

+115 -10
View File
@@ -1,8 +1,17 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { beforeEach, test } from 'vitest'
import { envTokenRejected, githubApiHeaders, githubTokenFromEnv } from './github-api-auth'
import {
describeGitHubCredentialSource,
findGhCli,
forgetGhCliToken,
GH_CLI_TIMEOUT_MS,
githubApiHeaders,
githubTokenFromEnv,
githubTokenRejected,
resolveGitHubCredential
} 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,6 +20,31 @@ const UPDATE_CHECK_HEADERS = {
'User-Agent': 'hermes-desktop-update-check'
}
beforeEach(() => {
forgetGhCliToken()
})
/** execFile stand-in that records every spawn and answers with a scripted result. */
function fakeExecFile(result: { error?: Error | null; stdout?: string }) {
const calls: Array<{ file: string; args: string[]; options: Record<string, unknown> }> = []
let stdinEnded = 0
const execFileFn = ((file, args, options, callback) => {
calls.push({ file, args, options })
queueMicrotask(() => callback(result.error ?? null, result.stdout ?? '', ''))
return {
stdin: {
end() {
stdinEnded += 1
}
}
}
}) as any
return { calls, execFileFn, stdinEnded: () => stdinEnded }
}
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')
assert.equal(githubTokenFromEnv({ GITHUB_TOKEN: ' ', GH_TOKEN: ' pat-b ' }), 'pat-b')
@@ -33,12 +67,83 @@ test('anonymous sends no Authorization key at all; a token adds the `token` sche
assert.equal('Authorization' in UPDATE_CHECK_HEADERS, false)
})
test('only an authenticated 401 counts as the env token being rejected', () => {
// The anonymous-retry gate: a stale GITHUB_TOKEN must fall back to the
// anonymous request that worked before the token was wired in.
assert.equal(envTokenRejected({ statusCode: 401, authenticated: true }), true)
assert.equal(envTokenRejected({ statusCode: 401, authenticated: false }), false)
assert.equal(envTokenRejected({ statusCode: 403, authenticated: true }), false)
assert.equal(envTokenRejected({ statusCode: 429, authenticated: true }), false)
assert.equal(envTokenRejected(null), false)
test('only an authenticated 401 counts as the credential being rejected', () => {
// The anonymous-retry gate: a stale GITHUB_TOKEN or gh login must fall back
// to the anonymous request that worked before credentials were wired in.
assert.equal(githubTokenRejected({ statusCode: 401, authenticated: true }), true)
assert.equal(githubTokenRejected({ statusCode: 401, authenticated: false }), false)
assert.equal(githubTokenRejected({ statusCode: 403, authenticated: true }), false)
assert.equal(githubTokenRejected({ statusCode: 429, authenticated: true }), false)
assert.equal(githubTokenRejected(null), false)
})
// #112615: a Finder/launcher-started app inherits /usr/bin:/bin:/usr/sbin:/sbin,
// so `gh` from Homebrew (macOS) or the GitHub CLI installer (Windows) is
// invisible on PATH — the rung must look where those installers put it.
test('gh is found in the GUI-safe install locations a minimal launch PATH omits', () => {
const brewGh = '/opt/homebrew/bin/gh'
const macos = findGhCli({ PATH: '/usr/bin:/bin:/usr/sbin:/sbin' }, 'darwin', p => p === brewGh)
assert.equal(macos, brewGh)
const winGh = 'C:\\Program Files\\GitHub CLI\\gh.exe'
const windows = findGhCli({ Path: 'C:\\Windows\\System32', ProgramFiles: 'C:\\Program Files' }, 'win32', p => p === winGh)
assert.equal(windows, winGh)
// PATH still wins over the fallbacks when both have one.
const pathGh = '/usr/local/bin/gh'
const both = findGhCli({ PATH: '/usr/local/bin' }, 'darwin', p => p === pathGh || p === brewGh)
assert.equal(both, pathGh)
assert.equal(findGhCli({ PATH: '/usr/bin' }, 'linux', () => false), null)
})
test('gh rung: argv-only spawn, stdin closed, bounded, cached for the process, and after the env rung', async () => {
const exists = (p: string) => p === '/opt/homebrew/bin/gh'
const env = { PATH: '/usr/bin:/bin' }
const logged = fakeExecFile({ stdout: 'gho_from_gh\n' })
const first = await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: logged.execFileFn })
assert.deepEqual(first, { token: 'gho_from_gh', source: 'gh-cli' })
assert.equal(logged.calls.length, 1)
assert.equal(logged.calls[0].file, '/opt/homebrew/bin/gh')
assert.deepEqual(logged.calls[0].args, ['auth', 'token'])
assert.equal(logged.calls[0].options.shell, undefined)
assert.equal(logged.calls[0].options.windowsHide, true)
assert.equal(logged.calls[0].options.timeout, GH_CLI_TIMEOUT_MS)
assert.equal(logged.stdinEnded(), 1)
// Process-lifetime cache: the second check does not spawn gh again.
await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: logged.execFileFn })
assert.equal(logged.calls.length, 1)
// Env token outranks gh and never spawns it.
forgetGhCliToken()
const fromEnv = await resolveGitHubCredential({
env: { ...env, GH_TOKEN: 'pat-env' },
platform: 'darwin',
exists,
execFileFn: logged.execFileFn
})
assert.deepEqual(fromEnv, { token: 'pat-env', source: 'env' })
assert.equal(logged.calls.length, 1)
// Logged-out gh (exit 1, empty stdout) and a missing gh both mean anonymous.
forgetGhCliToken()
const loggedOut = fakeExecFile({ error: Object.assign(new Error('exit 1'), { code: 1 }), stdout: '' })
assert.equal(await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: loggedOut.execFileFn }), null)
forgetGhCliToken()
assert.equal(await resolveGitHubCredential({ env, platform: 'darwin', exists: () => false, execFileFn: logged.execFileFn }), null)
assert.equal(logged.calls.length, 1)
})
test('the rejected-credential log line names the source, never the token', () => {
assert.match(describeGitHubCredentialSource('env'), /GITHUB_TOKEN \/ GH_TOKEN/)
assert.match(describeGitHubCredentialSource('gh-cli'), /gh auth token/)
})
+164 -12
View File
@@ -1,3 +1,9 @@
import { execFile as nodeExecFile } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { delimiterForPlatform, pathEnvKey, POSIX_SANE_PATH_ENTRIES } from './backend-env'
// Credentials for the self-update check's api.github.com calls.
//
// The passive check asks the REST API for the branch tip SHA. Unauthenticated
@@ -7,17 +13,41 @@
// reach the update server". Authenticating moves the caller onto the token's
// 5,000/hour budget.
//
// 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.
// Credential ladder, mirroring the Python client (tools/skills_hub_github.py::GitHubAuth):
// 1. GITHUB_TOKEN, then GH_TOKEN, read from the process env per request and never stored.
// 2. `gh auth token` — the gh CLI's own login. A GUI-launched app inherits a
// minimal environment, so this is the only rung that helps most desktop
// users; the answer (token or "none") is cached for the process lifetime
// so the check never spawns gh more than once.
// 3. Anonymous.
// A token GitHub rejects (401) drops the caller back to rung 3 for that request;
// the token itself never reaches a log line or an error string.
/** Env vars consulted, in precedence order. */
export const GITHUB_TOKEN_ENV_VARS = ['GITHUB_TOKEN', 'GH_TOKEN'] as const
/** `gh auth token` must answer within this; a wedged keyring prompt must not stall the check. */
export const GH_CLI_TIMEOUT_MS = 3000
export type GitHubTokenSource = 'env' | 'gh-cli'
export interface GitHubCredential {
token: string
source: GitHubTokenSource
}
type Env = Record<string, string | undefined>
interface GhCliOptions {
env?: Env
platform?: NodeJS.Platform
execFileFn?: typeof nodeExecFile
exists?: (filePath: string) => boolean
timeoutMs?: number
}
/** First non-blank env token, trimmed. A blank value falls through to the next. */
export function githubTokenFromEnv(env: Record<string, string | undefined> = {}): string | null {
export function githubTokenFromEnv(env: Env = {}): string | null {
for (const name of GITHUB_TOKEN_ENV_VARS) {
const value = env[name]
@@ -29,6 +59,123 @@ export function githubTokenFromEnv(env: Record<string, string | undefined> = {})
return null
}
/**
* Directories searched for the gh CLI: PATH first, then the install locations
* a GUI launch's minimal PATH omits — the same sane POSIX entries backend-env
* appends for the backend, plus the GitHub CLI installer's Windows targets.
*/
export function ghCliSearchDirs(env: Env = process.env, platform: NodeJS.Platform = process.platform): string[] {
const dirs = String(env[pathEnvKey(env, platform)] || '')
.split(delimiterForPlatform(platform))
.filter(Boolean)
if (platform === 'win32') {
const programFiles = env.ProgramFiles || 'C:\\Program Files'
const localAppData = env.LOCALAPPDATA
dirs.push(path.win32.join(programFiles, 'GitHub CLI'))
if (localAppData) {
dirs.push(path.win32.join(localAppData, 'Programs', 'GitHub CLI'))
}
} else {
dirs.push(...POSIX_SANE_PATH_ENTRIES, '/home/linuxbrew/.linuxbrew/bin')
if (env.HOME) {
dirs.push(path.posix.join(env.HOME, '.local', 'bin'), path.posix.join(env.HOME, '.nix-profile', 'bin'))
}
}
return [...new Set(dirs)]
}
/** Absolute path of the gh CLI, or null when no candidate directory has one. */
export function findGhCli(
env: Env = process.env,
platform: NodeJS.Platform = process.platform,
exists: (filePath: string) => boolean = fs.existsSync
): string | null {
const pathModule = platform === 'win32' ? path.win32 : path.posix
const executable = platform === 'win32' ? 'gh.exe' : 'gh'
for (const dir of ghCliSearchDirs(env, platform)) {
const candidate = pathModule.join(dir, executable)
if (exists(candidate)) {
return candidate
}
}
return null
}
/**
* Run `gh auth token` once. argv only (no shell), stdin closed immediately,
* bounded by GH_CLI_TIMEOUT_MS. Missing gh, a logged-out gh (exit 1), a
* timeout, or a spawn failure all resolve to null — the caller goes anonymous.
*/
export function readGhCliToken({
env = process.env,
platform = process.platform,
execFileFn = nodeExecFile,
exists = fs.existsSync,
timeoutMs = GH_CLI_TIMEOUT_MS
}: GhCliOptions = {}): Promise<string | null> {
const gh = findGhCli(env, platform, exists)
if (!gh) {
return Promise.resolve(null)
}
return new Promise(resolve => {
try {
const child = execFileFn(
gh,
['auth', 'token'],
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true, env: env as NodeJS.ProcessEnv },
(error, stdout) => {
const token = typeof stdout === 'string' ? stdout.trim() : ''
resolve(!error && token ? token : null)
}
)
// gh may prompt on a TTY-less stdin when its keyring is locked; never wait on it.
child?.stdin?.end?.()
} catch {
resolve(null)
}
})
}
let ghCliTokenPromise: Promise<string | null> | null = null
/** `gh auth token`, asked at most once per process; the outcome (including "none") is cached. */
export function githubTokenFromGhCli(options: GhCliOptions = {}): Promise<string | null> {
ghCliTokenPromise ??= readGhCliToken(options)
return ghCliTokenPromise
}
/** Drop the cached gh answer so the next request asks gh again (rejected token, tests). */
export function forgetGhCliToken(): void {
ghCliTokenPromise = null
}
/** Walk the ladder: env token, then the cached gh CLI login, else null (anonymous). */
export async function resolveGitHubCredential(options: GhCliOptions = {}): Promise<GitHubCredential | null> {
const env = options.env ?? process.env
const fromEnv = githubTokenFromEnv(env)
if (fromEnv) {
return { token: fromEnv, source: 'env' }
}
const fromGh = await githubTokenFromGhCli({ ...options, env })
return fromGh ? { token: fromGh, source: 'gh-cli' } : null
}
/**
* Headers for api.github.com. The `token` scheme (not `Bearer`) matches the
* Python client. Anonymous callers get the base headers untouched: an empty
@@ -45,12 +192,17 @@ export function githubApiHeaders(base: Record<string, string>, token?: string |
}
/**
* True when api.github.com rejected the env token itself (HTTP 401 on an
* authenticated call). A stale or revoked GITHUB_TOKEN must not fail the
* update check closed — the caller retries anonymously, which is exactly what
* worked before the token was wired in. Anonymous 401s and every other status
* are not the token's fault and are surfaced as-is.
* True when api.github.com rejected the credential itself (HTTP 401 on an
* authenticated call). A stale or revoked token must not fail the update
* check closed — the caller retries anonymously, which is exactly what worked
* before credentials were wired in. Anonymous 401s and every other status are
* not the token's fault and are surfaced as-is.
*/
export function envTokenRejected(error: { statusCode?: number; authenticated?: boolean } | null | undefined): boolean {
export function githubTokenRejected(error: { statusCode?: number; authenticated?: boolean } | null | undefined): boolean {
return error?.statusCode === 401 && error?.authenticated === true
}
/** Log-safe name of a rejected credential's source; never includes the token. */
export function describeGitHubCredentialSource(source: GitHubTokenSource): string {
return source === 'env' ? 'the GITHUB_TOKEN / GH_TOKEN from the environment' : 'the gh CLI login (`gh auth token`)'
}
+24 -11
View File
@@ -217,7 +217,13 @@ import {
import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { registerGitIpc } from './git-ipc'
import { envTokenRejected, githubApiHeaders, githubTokenFromEnv } from './github-api-auth'
import {
describeGitHubCredentialSource,
forgetGhCliToken,
githubApiHeaders,
githubTokenRejected,
resolveGitHubCredential
} from './github-api-auth'
import { desktopBackendSpawnEnv, guestOnboardingEnabled, skipIntroEnabled } from './guest-onboarding'
import { readAndConsumeHandoffResult } from './handoff-result'
import {
@@ -3346,25 +3352,32 @@ async function checkUpdatesViaLsRemote({ updateRoot, branch, currentSha }) {
// 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.
//
// A token GitHub rejects (401: expired, revoked, malformed) must not turn a
// check that worked anonymously into a hard failure, so the call is retried
// once without it; the rejection is logged once per process.
let warnedRejectedGitHubToken = false
// Credential ladder (github-api-auth.ts): GITHUB_TOKEN / GH_TOKEN from the
// launch env, then the gh CLI's login, then anonymous. A token GitHub rejects
// (401: expired, revoked, malformed) must not turn a check that worked
// anonymously into a hard failure, so the call is retried once without it; the
// rejection is logged once per source per process, never with the token.
const warnedRejectedGitHubTokenSources = new Set()
async function fetchGitHubApi(url, accept = 'application/vnd.github+json') {
const token = githubTokenFromEnv(process.env)
const credential = await resolveGitHubCredential({ env: process.env })
try {
return await fetchGitHubApiOnce(url, accept, token)
return await fetchGitHubApiOnce(url, accept, credential?.token ?? null)
} catch (error) {
if (!envTokenRejected(error)) {
if (!credential || !githubTokenRejected(error)) {
throw error
}
if (!warnedRejectedGitHubToken) {
warnedRejectedGitHubToken = true
if (credential.source === 'gh-cli') {
// The user may re-login to gh; the next check asks it again.
forgetGhCliToken()
}
if (!warnedRejectedGitHubTokenSources.has(credential.source)) {
warnedRejectedGitHubTokenSources.add(credential.source)
rememberLog(
'[updates] api.github.com rejected the GITHUB_TOKEN / GH_TOKEN from the environment (HTTP 401); ' +
`[updates] api.github.com rejected ${describeGitHubCredentialSource(credential.source)} (HTTP 401); ` +
'retrying the update check anonymously'
)
}
@@ -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) and the desktop app's update check (`GH_TOKEN` also honoured) |
| `GITHUB_TOKEN` | GitHub token for Skills Hub (higher API rate limits, skill publish) and the desktop app's update check (`GH_TOKEN` also honoured; without either, the desktop falls back to the `gh` CLI login, then anonymous) |
| `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)) |
+7 -1
View File
@@ -327,7 +327,13 @@ 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.
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. To spend a 5,000/hour budget instead, the check uses the first credential it finds, in this order:
1. `GITHUB_TOKEN`, then `GH_TOKEN`, from the environment the app was launched from — read on each request, never stored.
2. The [GitHub CLI](https://cli.github.com/)'s own login (`gh auth token`). This is the rung that helps an app started from the Dock, Finder, or a desktop launcher, which inherits a minimal environment without your shell's variables. `gh` is looked up on `PATH` and in the usual install locations (Homebrew, `/usr/local/bin`, `~/.local/bin`, the Windows GitHub CLI installer); the answer is cached until the app restarts, so `gh` runs at most once per session.
3. Anonymous.
A credential GitHub rejects (HTTP 401 — expired or revoked) is logged once in `desktop.log`, naming its source and never the token, and the request is retried anonymously. 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