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:
Neil
2026-05-05 17:36:27 -07:00
committed by GitHub
co-authored by Orca
parent 8565f7f186
commit e49d90bee4
9 changed files with 578 additions and 51 deletions
+97
View File
@@ -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')
})
})
+142
View File
@@ -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)
}
}