Files
orca/src/main/github/gh-account-binding-inventory.ts
T
c34b944136 feat(github): bind projects to a specific gh account (#13664)
* feat(github): bind projects to a specific gh account

Adds per-project `Repo.ghAccount` so repo-scoped gh calls (create-worktree
issue/PR search, work items, hosted-review reads and mutations) run as the bound
account via ephemeral child-env token injection instead of the globally active
gh login. Multi-account resolution is capability-gated (gh >= 2.40) and fails
closed when the bound account or host is unavailable; Project View stays
ambient by design.

Repository settings gains a section for selecting or clearing a keyring-backed
account (shadcn `Select`), with mixed-version "not enforced" handling for older
remote runtimes. Attached `-Rhost/owner/repo` forms are covered by the host-drift
guard and its tests; es/ja/ko/zh catalogs carry the section's strings.

`getLocalProjectGhExecOptions` centralizes the binding lookup so every gh
execution path picks it up, including the Electron `hostedReview:*` handlers
that previously stayed on the ambient login. `gh auth token` (a keyring read)
is exempt from the rate-limit breaker gate so a tripped bucket cannot turn a
bound-token resolve into a false "unavailable".

The `ghAccount` update field and the two binding RPC methods live in the shared
RPC params contract; the generated catalog is regenerated.

Fixes #13612

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012B3QEP5iP4WGGEpPLtkHqA

* fix(settings): make GitHub account refresh secondary

* fix(github): satisfy strict casting quality checks

* test(rpc): use runtime fixture for repo binding

* fix(github): preserve project account for PR worktree lookups

* test(rpc): avoid incomplete runtime settings fixture

* fix(i18n): add GitHub account refresh label

* fix(i18n): refresh runtime required catalog

* fix(windows): preserve mobile patch bytes

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 20:39:52 -07:00

105 lines
3.6 KiB
TypeScript

/**
* Repo-scoped inventory + validation for Settings GitHub account binding.
*/
import { diagnoseGhAuth } from './auth-diagnose'
import {
getGhMultiAccountCapability,
invalidateGhMultiAccountCapability,
normalizeGhCapabilityTarget,
type GhCapabilityTarget
} from './gh-capability-state'
import { invalidateGhAccountTokenCache, resolveGhAccountToken } from './gh-account-token'
import { normalizeGhAccountBinding } from '../../shared/github/account-binding'
import type {
GhAccountBindingInventory,
GhAccountBindingValidationResult
} from '../../shared/github/auth-types'
import type { LocalGitExecOptions } from './github-repository-identity'
function capabilityTargetFromLocalGitOptions(
localGitOptions: LocalGitExecOptions = {}
): GhCapabilityTarget {
return normalizeGhCapabilityTarget(
localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}
)
}
/** Lists keyring logins on the repo's execution host, filtered to `requiredHost` when the repo pins one. */
export async function listGhAccountBindingInventory(
localGitOptions: LocalGitExecOptions = {},
options: { refreshCapability?: boolean; requiredHost?: string } = {}
): Promise<GhAccountBindingInventory> {
const target = capabilityTargetFromLocalGitOptions(localGitOptions)
if (options.refreshCapability) {
invalidateGhMultiAccountCapability(target)
invalidateGhAccountTokenCache()
}
const requiredHost = options.requiredHost?.trim().toLowerCase() || undefined
const [capability, diagnostic] = await Promise.all([
getGhMultiAccountCapability(target),
diagnoseGhAuth(requiredHost, {
cwd: target.cwd,
wslDistro: target.wslDistro
})
])
const accounts = requiredHost
? diagnostic.accounts.filter((entry) => entry.host.trim().toLowerCase() === requiredHost)
: diagnostic.accounts
return {
capability,
accounts
}
}
/** Fails closed: a binding is only `ok` when the CLI supports multi-account and the login exists on this host. */
export async function validateGhAccountBinding(
bindingInput: { host: string; user: string },
localGitOptions: LocalGitExecOptions = {},
options: { requiredHost?: string } = {}
): Promise<GhAccountBindingValidationResult> {
const binding = normalizeGhAccountBinding(bindingInput)
if (!binding) {
return { ok: false, error: 'invalid_binding' }
}
const requiredHost = options.requiredHost?.trim().toLowerCase() || undefined
if (requiredHost && binding.host !== requiredHost) {
return { ok: false, error: 'gh_bound_account_unavailable' }
}
const inventory = await listGhAccountBindingInventory(localGitOptions, { requiredHost })
if (inventory.capability === 'unsupported') {
return { ok: false, error: 'gh_multi_account_unsupported' }
}
if (inventory.capability === 'unknown') {
return { ok: false, error: 'gh_multi_account_capability_unknown' }
}
const account = inventory.accounts.find(
(entry) =>
entry.host.trim().toLowerCase() === binding.host &&
entry.user.trim().toLowerCase() === binding.user.toLowerCase()
)
if (!account) {
return { ok: false, error: 'gh_bound_account_unavailable' }
}
if (account.source !== 'keyring') {
return { ok: false, error: 'gh_bound_account_not_keyring' }
}
try {
await resolveGhAccountToken(binding, capabilityTargetFromLocalGitOptions(localGitOptions))
return { ok: true, binding }
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'gh_multi_account_unsupported'
) {
return { ok: false, error: 'gh_multi_account_unsupported' }
}
return { ok: false, error: 'gh_bound_account_unavailable' }
}
}