mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
a1238adfae
commit
c61a11474b
5 files changed
+311
-35
No files matched your search
@@ -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/)
|
||||
})
|
||||
@@ -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`)'
|
||||
}
|
||||
@@ -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)) |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user