Files
orca/src/shared/github/account-binding.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

66 lines
2.1 KiB
TypeScript

/**
* Normalize persisted per-project GitHub account bindings.
*
* Why: host comparison and gh `--hostname` must be case-insensitive/canonical,
* while login casing is preserved for display and `gh auth token --user`.
*/
export type GhAccountBinding = {
host: string
user: string
}
const HOST_RE = /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/i
// Why: GHES/LDAP logins often contain `_` or `.`; github.com's hyphen grammar is too strict here.
const USER_RE = /^\S{1,255}$/
export function normalizeGhAccountBinding(value: unknown): GhAccountBinding | null {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const hostRaw = 'host' in value ? value.host : undefined
const userRaw = 'user' in value ? value.user : undefined
if (typeof hostRaw !== 'string' || typeof userRaw !== 'string') {
return null
}
const host = hostRaw.trim().toLowerCase()
const user = userRaw.trim()
// Why: reject embedded NUL without a control-char regex (oxlint no-control-regex).
if (!host || !user || user.includes('\u0000') || !HOST_RE.test(host) || !USER_RE.test(user)) {
return null
}
return { host, user }
}
/** Host-insensitive, login-exact comparison — matches how gh resolves `--hostname` and `--user`. */
export function ghAccountBindingsEqual(
a: GhAccountBinding | null | undefined,
b: GhAccountBinding | null | undefined
): boolean {
if (!a || !b) {
return a === b
}
return a.host === b.host && a.user === b.user
}
/**
* Choose the env var gh uses for a bound host.
*
* Why: github.com / github.localhost / *.ghe.com take `GH_TOKEN`; other hosts
* need `GH_ENTERPRISE_TOKEN`. Classifying `*.ghe.com` as cloud requires gh ≥
* 2.46 — older gh may ignore `GH_TOKEN` for those hosts and fail closed as
* `gh_bound_account_unavailable`.
*/
export function ghTokenEnvVarForHost(host: string): 'GH_TOKEN' | 'GH_ENTERPRISE_TOKEN' {
const normalized = host.trim().toLowerCase()
if (
!normalized ||
normalized === 'github.com' ||
normalized === 'github.localhost' ||
normalized.endsWith('.ghe.com')
) {
return 'GH_TOKEN'
}
return 'GH_ENTERPRISE_TOKEN'
}