mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
fix(gh-project): diagnose env-shadowed gh tokens in auth errors (#1478)
* fix(gh-project): diagnose env-shadowed gh tokens in auth errors `gh auth refresh -s project` silently no-ops when GITHUB_TOKEN/GH_TOKEN is exported in the user's shell — gh prefers env tokens and refuses to modify them, exiting 0. Users follow the canned remediation, see no error, retry, and stay stuck. Add a one-shot `gh auth status` probe (gh:diagnoseAuth IPC) that: - Detects env-shadowed credentials and rewrites the fix to `unset GITHUB_TOKEN` plus a grep to find where it's exported. - Detects missing gh install, plain missing-scope on a keyring login, and SAML SSO authorization. - Surfaces a tailored multi-button error UI in ProjectViewWrapper and ProjectPicker instead of one canned 'Copy command'. Co-authored-by: Orca <help@stably.ai> * fix(gh-project): address review feedback - Cross-platform shell guidance: PowerShell commands on Windows (Get-ChildItem Env:, Remove-Item Env:, [Environment]::SetEnvironmentVariable) via navigator.userAgent platform check. - Use `window.api.shell.openUrl` for the docs button instead of `window.open`, matching SidebarToolbar's external-URL pattern. - Tighten gh auth status parser: accept single-label hostnames and optional trailing colon; recover host from the inline 'Logged in to <host>' line so a missed section header never silently drops accounts. - Add tests for multi-host output and host-recovery fallback. - Drop dead command/copy locals in ProjectViewWrapper.ErrorState by short-circuiting the auth-error case before they're computed. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseAuthStatus } from './auth-diagnose'
|
||||
|
||||
describe('parseAuthStatus', () => {
|
||||
it('parses an env-shadowed login alongside a keyring login (real gh output)', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account nwparker (GITHUB_TOKEN)
|
||||
- Active account: true
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
|
||||
✓ Logged in to github.com account nwparker (keyring)
|
||||
- Active account: false
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts).toHaveLength(2)
|
||||
expect(accounts[0]).toMatchObject({
|
||||
host: 'github.com',
|
||||
user: 'nwparker',
|
||||
active: true,
|
||||
envToken: 'GITHUB_TOKEN',
|
||||
source: 'env'
|
||||
})
|
||||
expect(accounts[0].scopes).toEqual(['gist', 'read:org', 'repo', 'workflow'])
|
||||
expect(accounts[1]).toMatchObject({
|
||||
active: false,
|
||||
envToken: null,
|
||||
source: 'keyring'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a single keyring login', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account alice (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'project', 'read:org', 'repo'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts).toHaveLength(1)
|
||||
expect(accounts[0]).toMatchObject({
|
||||
user: 'alice',
|
||||
active: true,
|
||||
envToken: null,
|
||||
source: 'keyring'
|
||||
})
|
||||
expect(accounts[0].scopes).toContain('project')
|
||||
})
|
||||
|
||||
it('detects GH_TOKEN env source', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account bot (GH_TOKEN)
|
||||
- Active account: true
|
||||
- Token scopes: 'repo'
|
||||
`
|
||||
const [acc] = parseAuthStatus(text)
|
||||
expect(acc.envToken).toBe('GH_TOKEN')
|
||||
expect(acc.source).toBe('env')
|
||||
})
|
||||
|
||||
it('returns empty array when nothing is logged in', () => {
|
||||
expect(parseAuthStatus('You are not logged into any GitHub hosts.')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses multiple hosts in one output (github.com + GHES)', () => {
|
||||
const text = `github.com
|
||||
✓ Logged in to github.com account alice (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'read:org', 'repo'
|
||||
|
||||
ghe.acme.io
|
||||
✓ Logged in to ghe.acme.io account bob (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'project', 'repo'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts.map((a) => a.host)).toEqual(['github.com', 'ghe.acme.io'])
|
||||
expect(accounts.map((a) => a.user)).toEqual(['alice', 'bob'])
|
||||
expect(accounts[1].scopes).toContain('project')
|
||||
})
|
||||
|
||||
it('recovers host from the Logged-in line when the section header is missing', () => {
|
||||
// gh prints a colon after the host on some versions; we tolerate it,
|
||||
// but if the regex ever fails to match the header we still want
|
||||
// accounts attributed to the host from the inline message.
|
||||
const text = ` ✓ Logged in to github.acme.io account carol (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'project'
|
||||
`
|
||||
const accounts = parseAuthStatus(text)
|
||||
expect(accounts).toHaveLength(1)
|
||||
expect(accounts[0].host).toBe('github.acme.io')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* gh CLI auth diagnostics.
|
||||
*
|
||||
* Why: when project queries fail with "missing scope", the canned
|
||||
* remediation `gh auth refresh -s project ...` silently no-ops if the user
|
||||
* has `GITHUB_TOKEN` (or `GH_TOKEN`) exported in their shell — gh prefers
|
||||
* env tokens over keyring credentials and refuses to refresh env-supplied
|
||||
* tokens. Users follow the instructions, see no error, retry, and stay
|
||||
* stuck. This probe makes that failure mode legible in the UI.
|
||||
*
|
||||
* Output is parsed from `gh auth status`, which prints free-form text but
|
||||
* uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.).
|
||||
*/
|
||||
import { ghExecFileAsync } from '../git/runner'
|
||||
import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types'
|
||||
|
||||
// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the
|
||||
// scope that gates ProjectV2 reads/writes; the others are needed for the
|
||||
// surrounding repo/org queries we already run.
|
||||
const REQUIRED_SCOPES = ['project', 'read:org', 'repo'] as const
|
||||
|
||||
/**
|
||||
* Parse `gh auth status` stderr/stdout. gh writes to stderr by default but
|
||||
* has used stdout in some versions; we accept either. Format (per host):
|
||||
*
|
||||
* github.com
|
||||
* ✓ Logged in to github.com account NAME (GITHUB_TOKEN)
|
||||
* - Active account: true
|
||||
* - Token scopes: 'gist', 'read:org', 'repo'
|
||||
*/
|
||||
export function parseAuthStatus(text: string): GhAuthAccount[] {
|
||||
const accounts: GhAuthAccount[] = []
|
||||
let currentHost: string | null = null
|
||||
let current: GhAuthAccount | null = null
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.replace(/\r$/, '')
|
||||
// Host header: a non-indented hostname token, with an optional
|
||||
// trailing colon some gh versions emit. Permits single-label hostnames
|
||||
// (internal GHES like `github` or `ghe-internal`); we also recover the
|
||||
// host from the `Logged in to <host>` line below if this header was
|
||||
// missed, so a parser miss never silently drops every account.
|
||||
const hostMatch = line.match(/^([a-z0-9][a-z0-9.-]*)\s*:?\s*$/i)
|
||||
if (hostMatch && !/^logged\b/i.test(line)) {
|
||||
currentHost = hostMatch[1]
|
||||
continue
|
||||
}
|
||||
const loggedIn = line.match(/Logged in to (\S+) account (\S+)(?:\s+\(([^)]+)\))?/i)
|
||||
if (loggedIn) {
|
||||
if (current) {
|
||||
accounts.push(current)
|
||||
}
|
||||
// Prefer the host from the `Logged in to <host>` line itself — it's
|
||||
// always present, whereas the section header above can be skipped
|
||||
// by the regex on unfamiliar gh output.
|
||||
const host = loggedIn[1] || currentHost || 'github.com'
|
||||
const sourceLabel = (loggedIn[3] ?? '').trim()
|
||||
// gh emits "(keyring)" for stored creds and "(GITHUB_TOKEN)" /
|
||||
// "(GH_TOKEN)" when an env var is shadowing the keyring.
|
||||
const envToken =
|
||||
sourceLabel === 'GITHUB_TOKEN' || sourceLabel === 'GH_TOKEN' ? sourceLabel : null
|
||||
current = {
|
||||
host,
|
||||
user: loggedIn[2],
|
||||
active: false,
|
||||
envToken,
|
||||
source: envToken ? 'env' : 'keyring',
|
||||
scopes: []
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
const activeMatch = line.match(/Active account:\s*(true|false)/i)
|
||||
if (activeMatch) {
|
||||
current.active = activeMatch[1].toLowerCase() === 'true'
|
||||
continue
|
||||
}
|
||||
const scopesMatch = line.match(/Token scopes:\s*(.+)$/i)
|
||||
if (scopesMatch) {
|
||||
current.scopes = scopesMatch[1]
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
accounts.push(current)
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
export async function diagnoseGhAuth(): Promise<GhAuthDiagnostic> {
|
||||
let raw = ''
|
||||
let ghAvailable = true
|
||||
try {
|
||||
// `gh auth status` exits non-zero when no host is logged in but still
|
||||
// prints the same diagnostic text we want, so capture both streams.
|
||||
const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'])
|
||||
raw = `${stdout}\n${stderr}`
|
||||
} catch (err) {
|
||||
const stderr =
|
||||
err && typeof err === 'object' && 'stderr' in err
|
||||
? String((err as { stderr?: unknown }).stderr ?? '')
|
||||
: ''
|
||||
const stdout =
|
||||
err && typeof err === 'object' && 'stdout' in err
|
||||
? String((err as { stdout?: unknown }).stdout ?? '')
|
||||
: ''
|
||||
raw = `${stdout}\n${stderr}`
|
||||
if (!raw.trim()) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
// Most likely cause: gh CLI not installed or not on PATH.
|
||||
if (/ENOENT|not found|command not found/i.test(message)) {
|
||||
ghAvailable = false
|
||||
}
|
||||
raw = message
|
||||
}
|
||||
}
|
||||
const accounts = parseAuthStatus(raw)
|
||||
const active = accounts.find((a) => a.active) ?? accounts[0] ?? null
|
||||
const envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null = process.env.GH_TOKEN
|
||||
? 'GH_TOKEN'
|
||||
: process.env.GITHUB_TOKEN
|
||||
? 'GITHUB_TOKEN'
|
||||
: null
|
||||
const missingScopes = active
|
||||
? REQUIRED_SCOPES.filter((s) => !active.scopes.includes(s))
|
||||
: [...REQUIRED_SCOPES]
|
||||
// Is there a non-env (keyring) account we could fall back to by unsetting
|
||||
// the env var? Only meaningful if the active account is env-shadowed.
|
||||
const keyringFallback = accounts.find((a) => a.source === 'keyring') ?? null
|
||||
return {
|
||||
ghAvailable,
|
||||
activeAccount: active,
|
||||
accounts,
|
||||
envTokenInProcess,
|
||||
missingScopes,
|
||||
requiredScopes: [...REQUIRED_SCOPES],
|
||||
hasKeyringFallback: Boolean(keyringFallback && keyringFallback !== active)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user