Files
hermes-agent/apps/desktop/electron/github-api-auth.ts
teknium1 7b085a734b fix(desktop): a logged-out gh is asked again on the next update check
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
2026-09-17 08:54:13 -07:00

219 lines
7.4 KiB
TypeScript

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
// that budget is 60 requests/hour keyed on the *client IP*, so a shared exit —
// office NAT, VPN, a proxy node many users sit behind — exhausts the bucket for
// everyone on it and the check reports a rate limit that reads as "Hermes can't
// reach the update server". Authenticating moves the caller onto the token's
// 5,000/hour budget.
//
// 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: Env = {}): string | null {
for (const name of GITHUB_TOKEN_ENV_VARS) {
const value = env[name]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
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`, cached for the process once it answers with a token. A "none" answer (gh missing,
* logged out, hung) is not cached: a `gh auth login` after launch is honoured by the next check, and
* the check is passive and hourly, so re-asking costs one bounded spawn per check at most.
*/
export function githubTokenFromGhCli(options: GhCliOptions = {}): Promise<string | null> {
ghCliTokenPromise ??= readGhCliToken(options).then(token => {
if (token === null) {
ghCliTokenPromise = null
}
return token
})
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
* `Authorization` header is a 401, so it must be absent, not blank.
*/
export function githubApiHeaders(base: Record<string, string>, token?: string | null): Record<string, string> {
const headers = { ...base }
if (token) {
headers.Authorization = `token ${token}`
}
return headers
}
/**
* 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 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`)'
}