Files
hermes-agent/apps/desktop/electron/github-api-auth.test.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

158 lines
6.3 KiB
TypeScript

import assert from 'node:assert/strict'
import { beforeEach, test } from 'vitest'
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.
const UPDATE_CHECK_HEADERS = {
Accept: 'application/vnd.github.sha',
'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')
assert.equal(githubTokenFromEnv({ GITHUB_TOKEN: '', GH_TOKEN: '' }), null)
assert.equal(githubTokenFromEnv({}), 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(anonymous, UPDATE_CHECK_HEADERS)
assert.equal('Authorization' in anonymous, false)
const authed = githubApiHeaders(UPDATE_CHECK_HEADERS, 'pat-a')
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('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)
// "none" is not cached: a `gh auth login` after launch is picked up by the next check without a restart.
const nowLogged = fakeExecFile({ stdout: 'gho_after_login\n' })
assert.deepEqual(await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: nowLogged.execFileFn }), {
token: 'gho_after_login',
source: 'gh-cli'
})
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/)
})