diff --git a/.gitattributes b/.gitattributes index 8bfd4043164..72fdad6e73a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -30,6 +30,8 @@ # source patch under xterm-src/ instead. The sibling patches stay diffable. /config/patches/@xterm__xterm@*.patch -diff /config/patches/xterm-src/*.patch text eol=lf +# pnpm parses these unified diffs during Windows installs; keep checkout bytes stable. +/mobile/patches/*.patch -text # Generated wrapper fixtures: collapse them in the PR diff so they stop drowning # the reviewable change, and pin LF because they are compared byte-for-byte. # Not -diff: the shell diff is the review surface when a wrapper does change. diff --git a/src/main/git/command-runner/gh-bound-account-env.ts b/src/main/git/command-runner/gh-bound-account-env.ts new file mode 100644 index 00000000000..b8ebfce56a5 --- /dev/null +++ b/src/main/git/command-runner/gh-bound-account-env.ts @@ -0,0 +1,66 @@ +import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' +import type { GhExecOptions } from './gh-exec-file' +import { collectGhArgvHostSignals } from './gh-host-args' +import type { ResolvedCommand } from './wsl-command-resolution' + +const GH_BOUND_ACCOUNT_ERROR_CODES = new Set([ + 'gh_bound_account_unavailable', + 'gh_bound_account_host_mismatch', + 'gh_multi_account_unsupported' +]) + +/** True for the fail-closed errors raised while resolving a bound account, before any gh child ran. */ +export function isGhBoundAccountError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + GH_BOUND_ACCOUNT_ERROR_CODES.has(error.code) + ) +} + +/** + * Child env for a bound gh call: pins the host, resolves the bound login's + * token, and injects it into this child only — never process-wide. + */ +export async function resolveBoundGhExecEnv( + options: GhExecOptions, + resolved: ResolvedCommand, + args: readonly string[] +): Promise { + const binding = options.ghAccount + if (!binding || args[0] === 'auth') { + return options.env + } + // Why: dynamic import avoids a load-time cycle (command runner ↔ github token module). + const { + buildBoundGhChildEnv, + createGhBoundAccountHostMismatchError, + createGhBoundAccountUnavailableError, + resolveGhAccountToken + } = await import('../../github/gh-account-token') + const normalized = normalizeGhAccountBinding(binding) + if (!normalized) { + throw createGhBoundAccountUnavailableError(binding) + } + const optionsHost = options.host?.trim().toLowerCase() + if (!optionsHost || optionsHost !== normalized.host) { + throw createGhBoundAccountHostMismatchError(normalized, options.host) + } + for (const host of collectGhArgvHostSignals(args)) { + if (host !== normalized.host) { + throw createGhBoundAccountHostMismatchError(normalized, host) + } + } + const token = await resolveGhAccountToken(normalized, { + cwd: options.cwd, + wslDistro: resolved.wsl?.distro ?? options.wslDistro + }) + return buildBoundGhChildEnv({ + baseEnv: options.env ?? process.env, + binding: normalized, + token, + forWsl: resolved.wsl !== null + }) +} diff --git a/src/main/git/command-runner/gh-exec-file.ts b/src/main/git/command-runner/gh-exec-file.ts index 9a92f1d59de..95654526a24 100644 --- a/src/main/git/command-runner/gh-exec-file.ts +++ b/src/main/git/command-runner/gh-exec-file.ts @@ -4,6 +4,7 @@ import { getGhRateLimitBlockedUntilMs, ghRateLimitScopeKey, isGhPrimaryRateLimitStderr, + isGhLocalOnlyCommand, isGhRateLimitProbe, notifyGhPrimaryRateLimit, type GhRateLimitBucket @@ -24,6 +25,7 @@ import { logHostedCliDeadlineKill } from './hosted-cli-deadline-log' import type { GitExecOptions } from './git-exec-options' import { argsLookIdempotent } from './gh-idempotency' import { applyGhHostToArgs, explicitGhHostname, explicitGhRepoHostname } from './gh-host-args' +import { isGhBoundAccountError, resolveBoundGhExecEnv } from './gh-bound-account-env' import { defaultGhExecTimeoutMs, isTransientGhError, @@ -44,6 +46,8 @@ export type GhExecOptions = Omit & { // runner qualify every spawn once, so call sites can't silently fall back // to github.com for GHES repos; it also scopes the rate-limit breaker. host?: string + /** Per-project account binding; the runner resolves and injects a child-only token. */ + ghAccount?: { host: string; user: string } } function nonInteractiveGhEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { @@ -104,19 +108,68 @@ export async function ghExecFileAsync( ): Promise<{ stdout: string; stderr: string }> { // Why: retry safety must reflect the original call even when fallbacks replace the resolved command. const idempotent = options.idempotent ?? argsLookIdempotent(args) + // Why: legacy github.com ownerRepos omit `host`; bound calls still need a pinned + // options.host before token-var selection and argv qualification. + if (options.ghAccount && !options.host?.trim() && args[0] !== 'auth') { + options = { ...options, host: options.ghAccount.host.trim().toLowerCase() } + } args = applyGhHostToArgs(args, options.host) let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) - // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset. + // Why: while a bucket is rate-limited every spawn returns 403 — fail fast; the probe is exempt so the breaker can learn the reset, + // and `auth token` is exempt because a keyring read never spends API quota (a bound token resolve must not report "unavailable"). // Why: scope by runtime and host so unrelated github.com, GHES, and WSL quotas cannot block each other. const rateLimitBucket = classifyGhRateLimitBucket(args) - const rateLimitProbe = isGhRateLimitProbe(args) + const rateLimitExempt = isGhRateLimitProbe(args) || isGhLocalOnlyCommand(args) + let boundEnv: NodeJS.ProcessEnv | undefined = options.env + let boundEnvResolvedKey: string | null = null + const boundEnvKeyFor = (command: ResolvedCommand): string => + command.wsl ? `wsl:${command.wsl.distro}` : 'native' + // Why: unbound calls must reach the spawn in the same tick — callers that abort + // synchronously would otherwise never get a child to kill. + const ensureBoundEnv = (): Promise | null => { + const key = boundEnvKeyFor(resolved) + if (boundEnvResolvedKey === key) { + return null + } + if (!options.ghAccount || args[0] === 'auth') { + boundEnv = options.env + boundEnvResolvedKey = key + return null + } + return resolveBoundGhExecEnv(options, resolved, args).then((env) => { + boundEnv = env + boundEnvResolvedKey = key + }) + } const timeoutMs = options.timeout ?? defaultGhExecTimeoutMs(options.env) - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitExempt) let lastError: unknown let attemptedHostFallback = false let attemptedDefaultWslFallback = false + // Why: a bound resolve fails before any spawn when gh is missing inside WSL; reuse the + // host fallback and re-resolve the token for the host it now runs on. + const tryHostFallbackAfterBoundResolveFailure = (error: unknown): boolean => { + if (attemptedHostFallback) { + return false + } + const { stderr } = extractExecError(error) + if (!canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { + return false + } + resolved = resolveHostGitHubCli('gh', args) + attemptedHostFallback = true + boundEnvResolvedKey = null + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitExempt) + return true + } for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { try { + const boundEnvReady = ensureBoundEnv() + if (boundEnvReady) { + await boundEnvReady + // Why: the breaker may have tripped while the token resolved. + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitExempt) + } // Why to-termination and not execFileCapture: `gh` on PATH is routinely a // shim (mise, asdf, volta, a hand-written wrapper), so the deadline below // has a chain to reap, not one process. execFileCapture's POSIX kill only @@ -131,7 +184,7 @@ export async function ghExecFileAsync( maxBuffer: options.maxBuffer, // Why: bound gh so one stuck child fails visibly instead of wedging the IPC lane. timeout: timeoutMs, - env: nonInteractiveGhEnv(options.env), + env: nonInteractiveGhEnv(boundEnv ?? options.env), signal: options.signal, onDeadlineKill: () => logHostedCliDeadlineKill('gh', resolved.binary, args, timeoutMs) }, @@ -140,6 +193,13 @@ export async function ghExecFileAsync( return { stdout: stdout as string, stderr: stderr as string } } catch (err) { lastError = err + if (isGhBoundAccountError(err)) { + if (tryHostFallbackAfterBoundResolveFailure(err)) { + attempt = -1 + continue + } + throw err + } const { stderr } = extractExecError(err) if (isGhPrimaryRateLimitStderr(stderr)) { notifyGhPrimaryRateLimit(rateLimitBucket, ghRateLimitScope(args, options, resolved)) @@ -157,7 +217,9 @@ export async function ghExecFileAsync( // Why: WSL-only Windows installs have no host gh.exe, and global calls (rate_limit/auth) carry no cwd to route by. resolved = wslResolved attemptedDefaultWslFallback = true - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + // Why: token and capability are per execution host — re-resolve after native→WSL. + boundEnvResolvedKey = null + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitExempt) attempt = -1 continue } @@ -165,7 +227,9 @@ export async function ghExecFileAsync( if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) { resolved = resolveHostGitHubCli('gh', args) attemptedHostFallback = true - assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitProbe) + // Why: token and capability are per execution host — re-resolve after WSL→host. + boundEnvResolvedKey = null + assertGhRateLimitScopeAvailable(args, options, resolved, rateLimitBucket, rateLimitExempt) attempt = -1 continue } diff --git a/src/main/git/command-runner/gh-host-args.ts b/src/main/git/command-runner/gh-host-args.ts index 37191bab6ee..68b9f98f469 100644 --- a/src/main/git/command-runner/gh-host-args.ts +++ b/src/main/git/command-runner/gh-host-args.ts @@ -81,3 +81,40 @@ export function explicitGhRepoHostname(args: readonly string[]): string | undefi } return undefined } + +/** Every host named in argv (`--hostname`, `--repo HOST/OWNER/REPO`, attached `-R…`), lowercased. */ +export function collectGhArgvHostSignals(args: readonly string[]): string[] { + const hosts: string[] = [] + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--hostname') { + const value = args[i + 1]?.trim() + if (value) { + hosts.push(value.toLowerCase()) + } + continue + } + if (args[i].startsWith('--hostname=')) { + const value = args[i].slice('--hostname='.length).trim() + if (value) { + hosts.push(value.toLowerCase()) + } + continue + } + let repoValue: string | undefined + if (args[i] === '--repo' || args[i] === '-R') { + repoValue = args[i + 1] + } else if (args[i].startsWith('--repo=')) { + repoValue = args[i].slice('--repo='.length) + } else if (args[i].startsWith('-R=')) { + repoValue = args[i].slice('-R='.length) + } else if (args[i].startsWith('-R') && args[i].length > 2 && !args[i].startsWith('-R-')) { + // Why: gh accepts attached `-Rhost/owner/repo`; reject host drift on bound calls. + repoValue = args[i].slice(2) + } + const parts = repoValue?.trim().split('/') + if (parts?.length === 3 && parts.every(Boolean)) { + hosts.push(parts[0].toLowerCase()) + } + } + return hosts +} diff --git a/src/main/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts index 8fa8d667dc5..cc0d675b90f 100644 --- a/src/main/git/gh-rate-limit-breaker.test.ts +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -6,6 +6,7 @@ import { createGhRateLimitBlockedError, getGhRateLimitBlockedUntilMs, isGhPrimaryRateLimitStderr, + isGhLocalOnlyCommand, isGhRateLimitProbe, notifyGhPrimaryRateLimit, parseGhRateLimitScopeKey, @@ -39,6 +40,18 @@ describe('classifyGhRateLimitBucket', () => { }) }) +describe('isGhLocalOnlyCommand', () => { + it('exempts keyring token reads and nothing else', () => { + expect( + isGhLocalOnlyCommand(['auth', 'token', '--user', 'alice', '--hostname', 'github.com']) + ).toBe(true) + expect(isGhLocalOnlyCommand(['auth', 'token', '--help'])).toBe(true) + expect(isGhLocalOnlyCommand(['auth', 'status'])).toBe(false) + expect(isGhLocalOnlyCommand(['auth', 'refresh', '-s', 'project'])).toBe(false) + expect(isGhLocalOnlyCommand(['api', 'user'])).toBe(false) + }) +}) + describe('isGhRateLimitProbe', () => { it('recognizes the exempt rate_limit endpoint only', () => { expect(isGhRateLimitProbe(['api', 'rate_limit'])).toBe(true) diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index 8bc4276490a..dce23864307 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -131,6 +131,11 @@ export function isGhRateLimitProbe(args: readonly string[]): boolean { return endpoint === 'rate_limit' || endpoint === '/rate_limit' } +/** `gh auth token …` is a local keyring read that never reaches the API, so a tripped breaker must not gate it. */ +export function isGhLocalOnlyCommand(args: readonly string[]): boolean { + return args[0] === 'auth' && args[1] === 'token' +} + /** * Primary rate-limit detection. Secondary limits ("secondary rate limit") * carry Retry-After and are handled by the runner's transient retry logic. diff --git a/src/main/git/runner-gh-account-binding.test.ts b/src/main/git/runner-gh-account-binding.test.ts new file mode 100644 index 00000000000..c8ad2759702 --- /dev/null +++ b/src/main/git/runner-gh-account-binding.test.ts @@ -0,0 +1,207 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + spawnMock, + resolveGhAccountTokenMock, + buildBoundGhChildEnvMock, + createGhBoundAccountHostMismatchErrorMock +} = vi.hoisted(() => ({ + spawnMock: vi.fn(), + resolveGhAccountTokenMock: vi.fn(), + buildBoundGhChildEnvMock: vi.fn(), + createGhBoundAccountHostMismatchErrorMock: vi.fn( + (binding: { host: string; user: string }, host?: string) => + Object.assign( + new Error( + host + ? `host mismatch ${binding.host} vs ${host}` + : `host required for ${binding.user}@${binding.host}` + ), + { code: 'gh_bound_account_host_mismatch', stderr: 'host mismatch' } + ) + ) +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock +})) + +import type * as GhAccountTokenModule from '../github/gh-account-token' + +vi.mock('../github/gh-account-token', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveGhAccountToken: resolveGhAccountTokenMock, + buildBoundGhChildEnv: buildBoundGhChildEnvMock, + createGhBoundAccountHostMismatchError: createGhBoundAccountHostMismatchErrorMock + } +}) + +import { ghExecFileAsync } from './runner' +import { _resetGhRateLimitBreaker, recordGhPrimaryRateLimit } from './gh-rate-limit-breaker' + +function mockChild(pid = 4321) { + return Object.assign(new EventEmitter(), { + pid, + kill: vi.fn(() => true), + stdin: Object.assign(new EventEmitter(), { end: vi.fn() }), + stdout: new EventEmitter(), + stderr: new EventEmitter() + }) +} + +/** Spawns a child that settles with `stdout`, capturing the env the runner handed it. */ +function spawnSettledChild(stdout: string): { + env: () => NodeJS.ProcessEnv | undefined +} { + let capturedEnv: NodeJS.ProcessEnv | undefined + spawnMock.mockImplementation( + (_cmd: string, _args: string[], opts: { env?: NodeJS.ProcessEnv }) => { + capturedEnv = opts.env + const child = mockChild() + queueMicrotask(() => { + child.stdout?.emit('data', Buffer.from(stdout)) + child.emit('exit', 0, null) + child.emit('close', 0, null) + }) + return child + } + ) + return { env: () => capturedEnv } +} + +describe('ghExecFileAsync account binding', () => { + beforeEach(() => { + spawnMock.mockReset() + resolveGhAccountTokenMock.mockReset() + buildBoundGhChildEnvMock.mockReset() + createGhBoundAccountHostMismatchErrorMock.mockClear() + _resetGhRateLimitBreaker() + resolveGhAccountTokenMock.mockResolvedValue('bound-token') + buildBoundGhChildEnvMock.mockImplementation( + ({ + baseEnv, + binding, + token + }: { + baseEnv?: NodeJS.ProcessEnv + binding: { host: string; user: string } + token: string + }) => ({ + ...(baseEnv ?? process.env), + GH_PROMPT_DISABLED: '1', + GH_TOKEN: binding.host === 'github.com' ? token : undefined, + GH_ENTERPRISE_TOKEN: binding.host === 'github.com' ? undefined : token + }) + ) + }) + + afterEach(() => { + _resetGhRateLimitBreaker() + }) + + it('injects bound token env for non-auth calls with matching host', async () => { + const spawned = spawnSettledChild('[]') + + await ghExecFileAsync(['api', 'user'], { + host: 'github.com', + ghAccount: { host: 'github.com', user: 'Alice' }, + env: { ...process.env, GH_TOKEN: 'ambient' } + }) + + expect(resolveGhAccountTokenMock).toHaveBeenCalledWith( + { host: 'github.com', user: 'Alice' }, + expect.any(Object) + ) + expect(buildBoundGhChildEnvMock).toHaveBeenCalled() + expect(spawned.env()?.GH_TOKEN).toBe('bound-token') + }) + + it('skips injection for auth probes', async () => { + const spawned = spawnSettledChild('') + + await ghExecFileAsync(['auth', 'status'], { + host: 'github.com', + ghAccount: { host: 'github.com', user: 'Alice' }, + env: { ...process.env, GH_TOKEN: 'ambient' } + }) + + expect(resolveGhAccountTokenMock).not.toHaveBeenCalled() + expect(spawned.env()?.GH_TOKEN).toBe('ambient') + }) + + it('pins options.host from the binding when callers omit host', async () => { + const spawned = spawnSettledChild('[]') + + await ghExecFileAsync(['api', 'user'], { + ghAccount: { host: 'github.com', user: 'Alice' } + }) + + expect(resolveGhAccountTokenMock).toHaveBeenCalled() + expect(spawned.env()?.GH_TOKEN).toBe('bound-token') + }) + + it('fails closed when argv host signals disagree with the binding', async () => { + await expect( + ghExecFileAsync(['pr', 'list', '--repo', 'github.acme.com/a/b'], { + host: 'github.com', + ghAccount: { host: 'github.com', user: 'Alice' } + }) + ).rejects.toMatchObject({ code: 'gh_bound_account_host_mismatch' }) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it.each(['-Rgithub.acme.com/a/b', '-R=github.acme.com/a/b'])( + 'fails closed for the attached %s host form', + async (attached) => { + await expect( + ghExecFileAsync(['pr', 'list', attached], { + host: 'github.com', + ghAccount: { host: 'github.com', user: 'Alice' } + }) + ).rejects.toMatchObject({ code: 'gh_bound_account_host_mismatch' }) + expect(spawnMock).not.toHaveBeenCalled() + } + ) + + it('accepts an attached -R form that names the bound host', async () => { + const spawned = spawnSettledChild('[]') + + await ghExecFileAsync(['pr', 'list', '-Rgithub.com/a/b'], { + host: 'github.com', + ghAccount: { host: 'github.com', user: 'Alice' } + }) + + expect(spawned.env()?.GH_TOKEN).toBe('bound-token') + }) + + it('still spawns the keyring token read while the core bucket is blocked', async () => { + recordGhPrimaryRateLimit('core', Date.now() + 60_000, 'native:github.com') + const spawned = spawnSettledChild('gho_bound\n') + + const { stdout } = await ghExecFileAsync( + ['auth', 'token', '--user', 'Alice', '--hostname', 'github.com'], + { host: 'github.com' } + ) + + expect(stdout.trim()).toBe('gho_bound') + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawned.env()).toBeDefined() + }) + + it('fails a bound API call fast on a blocked bucket without resolving a token', async () => { + recordGhPrimaryRateLimit('core', Date.now() + 60_000, 'native:github.com') + + await expect( + ghExecFileAsync(['api', 'user'], { + host: 'github.com', + ghAccount: { host: 'github.com', user: 'Alice' } + }) + ).rejects.toMatchObject({ ghRateLimitBlocked: true }) + expect(resolveGhAccountTokenMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/runner-gh-host-args.test.ts b/src/main/git/runner-gh-host-args.test.ts index 708f2d732aa..74a6ee096ef 100644 --- a/src/main/git/runner-gh-host-args.test.ts +++ b/src/main/git/runner-gh-host-args.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { applyGhHostToArgs } from './runner' +import { collectGhArgvHostSignals } from './command-runner/gh-host-args' describe('applyGhHostToArgs', () => { it('returns args unchanged when no host is given', () => { @@ -140,3 +141,39 @@ describe('applyGhHostToArgs', () => { ]) }) }) + +describe('collectGhArgvHostSignals', () => { + it('collects --hostname and host-qualified --repo / -R values, lowercased', () => { + expect(collectGhArgvHostSignals(['api', '--hostname', 'GitHub.Acme.com', 'user'])).toEqual([ + 'github.acme.com' + ]) + expect(collectGhArgvHostSignals(['api', '--hostname=ghe.example.com', 'user'])).toEqual([ + 'ghe.example.com' + ]) + expect(collectGhArgvHostSignals(['pr', 'list', '--repo', 'ghe.example.com/a/b'])).toEqual([ + 'ghe.example.com' + ]) + expect(collectGhArgvHostSignals(['pr', 'list', '-R', 'ghe.example.com/a/b'])).toEqual([ + 'ghe.example.com' + ]) + expect(collectGhArgvHostSignals(['pr', 'list', '--repo=ghe.example.com/a/b'])).toEqual([ + 'ghe.example.com' + ]) + }) + + it('reads the attached -R forms gh accepts, so a bound call cannot drift hosts through them', () => { + expect(collectGhArgvHostSignals(['pr', 'list', '-Rghe.example.com/a/b'])).toEqual([ + 'ghe.example.com' + ]) + expect(collectGhArgvHostSignals(['pr', 'list', '-R=ghe.example.com/a/b'])).toEqual([ + 'ghe.example.com' + ]) + }) + + it('ignores bare owner/repo shorthand and free text that merely starts with -R', () => { + expect(collectGhArgvHostSignals(['pr', 'list', '-R', 'a/b'])).toEqual([]) + expect(collectGhArgvHostSignals(['pr', 'list', '-Ra/b'])).toEqual([]) + expect(collectGhArgvHostSignals(['pr', 'create', '--title', '-R-flavoured title'])).toEqual([]) + expect(collectGhArgvHostSignals(['pr', 'list', '-R-'])).toEqual([]) + }) +}) diff --git a/src/main/github/auth-diagnose.ts b/src/main/github/auth-diagnose.ts index 61aafdd7cf8..e5eda3b9f82 100644 --- a/src/main/github/auth-diagnose.ts +++ b/src/main/github/auth-diagnose.ts @@ -90,13 +90,21 @@ export function parseAuthStatus(text: string): GhAuthAccount[] { return accounts } -export async function diagnoseGhAuth(requiredHost?: string): Promise { +export async function diagnoseGhAuth( + requiredHost?: string, + execOptions: { cwd?: string; wslDistro?: string } = {} +): Promise { 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']) + // Why: auth is ambient (no Repo.ghAccount) but must still run on the + // repo's WSL/native execution host so inventory matches token resolution. + const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'], { + ...(execOptions.cwd ? { cwd: execOptions.cwd } : {}), + ...(execOptions.wslDistro ? { wslDistro: execOptions.wslDistro } : {}) + }) raw = `${stdout}\n${stderr}` } catch (err) { const stderr = diff --git a/src/main/github/client/create/create-github-pull-request.ts b/src/main/github/client/create/create-github-pull-request.ts index 04d3d049174..db201c88e76 100644 --- a/src/main/github/client/create/create-github-pull-request.ts +++ b/src/main/github/client/create/create-github-pull-request.ts @@ -104,10 +104,11 @@ export async function createGitHubPullRequest( createArgs.push('--draft') } try { - const context = githubRepoContext(repoPath, connectionId) + const localGitOptions = getHostedReviewLocalGitOptions(options) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) const { stdout } = await ghExecFileAsync(createArgs, { ...ghRepoExecOptions(context), - ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)), + ...(connectionId ? {} : localGitOptions), ...githubHostExecOptions(ownerRepo), timeout: 60_000, idempotent: false diff --git a/src/main/github/client/create/pull-request-template.ts b/src/main/github/client/create/pull-request-template.ts index 7f53d429d90..5d60966a0c2 100644 --- a/src/main/github/client/create/pull-request-template.ts +++ b/src/main/github/client/create/pull-request-template.ts @@ -16,7 +16,8 @@ export async function findOpenPRByHeadBase(args: { connectionId?: string | null options?: HostedReviewExecutionOptions }): Promise<{ number: number; url: string } | null> { - const context = githubRepoContext(args.repoPath, args.connectionId) + const localGitOptions = getHostedReviewLocalGitOptions(args.options) + const context = githubRepoContext(args.repoPath, args.connectionId, localGitOptions) const { stdout } = await ghExecFileAsync( [ 'pr', @@ -36,7 +37,7 @@ export async function findOpenPRByHeadBase(args: { ], { ...ghRepoExecOptions(context), - ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)), + ...(args.connectionId ? {} : localGitOptions), ...githubHostExecOptions(args.repo) } ) diff --git a/src/main/github/gh-account-binding-inventory.ts b/src/main/github/gh-account-binding-inventory.ts new file mode 100644 index 00000000000..c4e2af43f79 --- /dev/null +++ b/src/main/github/gh-account-binding-inventory.ts @@ -0,0 +1,104 @@ +/** + * 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 { + 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 { + 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' } + } +} diff --git a/src/main/github/gh-account-token.test.ts b/src/main/github/gh-account-token.test.ts new file mode 100644 index 00000000000..41265572788 --- /dev/null +++ b/src/main/github/gh-account-token.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock, getGhMultiAccountCapabilityMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getGhMultiAccountCapabilityMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock +})) + +import type * as GhCapabilityStateModule from './gh-capability-state' + +vi.mock('./gh-capability-state', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getGhMultiAccountCapability: getGhMultiAccountCapabilityMock + } +}) + +import { + buildBoundGhChildEnv, + clearGhAccountTokenCacheForTests, + createGhBoundAccountUnavailableError, + invalidateGhAccountTokenCache, + resolveGhAccountToken, + stripAmbientGhTokenEnv +} from './gh-account-token' + +describe('gh-account-token', () => { + beforeEach(() => { + clearGhAccountTokenCacheForTests() + ghExecFileAsyncMock.mockReset() + getGhMultiAccountCapabilityMock.mockReset() + getGhMultiAccountCapabilityMock.mockResolvedValue('supported') + }) + + afterEach(() => { + clearGhAccountTokenCacheForTests() + }) + + it('strips ambient token and host env vars', () => { + const stripped = stripAmbientGhTokenEnv({ + PATH: '/bin', + GH_TOKEN: 'ambient', + GITHUB_TOKEN: 'ambient2', + GH_ENTERPRISE_TOKEN: 'ent', + GITHUB_ENTERPRISE_TOKEN: 'ent2', + GH_HOST: 'github.example.com', + GH_REPO: 'acme/widgets', + KEEP: 'yes' + }) + expect(stripped.GH_TOKEN).toBeUndefined() + expect(stripped.GITHUB_TOKEN).toBeUndefined() + expect(stripped.GH_ENTERPRISE_TOKEN).toBeUndefined() + expect(stripped.GITHUB_ENTERPRISE_TOKEN).toBeUndefined() + expect(stripped.GH_HOST).toBeUndefined() + expect(stripped.GH_REPO).toBeUndefined() + expect(stripped.KEEP).toBe('yes') + }) + + it('injects GH_TOKEN for github.com and GH_ENTERPRISE_TOKEN for GHES', () => { + const cloud = buildBoundGhChildEnv({ + baseEnv: { PATH: '/bin', GH_TOKEN: 'ambient' }, + binding: { host: 'github.com', user: 'alice' }, + token: 'bound-token' + }) + expect(cloud.GH_TOKEN).toBe('bound-token') + expect(cloud.GITHUB_TOKEN).toBeUndefined() + + const enterprise = buildBoundGhChildEnv({ + baseEnv: { PATH: '/bin' }, + binding: { host: 'github.acme.com', user: 'alice' }, + token: 'ent-token', + forWsl: true + }) + expect(enterprise.GH_ENTERPRISE_TOKEN).toBe('ent-token') + expect(enterprise.GH_TOKEN).toBeUndefined() + expect(enterprise.WSLENV?.split(':')).toEqual( + expect.arrayContaining(['GH_ENTERPRISE_TOKEN', 'GH_PROMPT_DISABLED']) + ) + }) + + it('resolves via gh auth token --user/--hostname and caches', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: 'tok\r\n', stderr: '' }) + await expect(resolveGhAccountToken({ host: 'github.com', user: 'Alice' })).resolves.toBe('tok') + await expect(resolveGhAccountToken({ host: 'github.com', user: 'Alice' })).resolves.toBe('tok') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'token', '--user', 'Alice', '--hostname', 'github.com'], + expect.objectContaining({ timeout: 10_000 }) + ) + }) + + it('fails closed without ambient fallback and invalidates cache', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('nope')) + await expect( + resolveGhAccountToken({ host: 'github.com', user: 'Alice' }) + ).rejects.toMatchObject({ code: 'gh_bound_account_unavailable' }) + invalidateGhAccountTokenCache({ host: 'github.com', user: 'Alice' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: 'fresh', stderr: '' }) + await expect(resolveGhAccountToken({ host: 'github.com', user: 'Alice' })).resolves.toBe( + 'fresh' + ) + }) + + it('does not negative-cache transient resolve failures', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('gh timed out'), { code: 'ETIMEDOUT' }) + ) + await expect( + resolveGhAccountToken({ host: 'github.com', user: 'Alice' }) + ).rejects.toMatchObject({ code: 'gh_bound_account_unavailable' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: 'recovered\n', stderr: '' }) + await expect(resolveGhAccountToken({ host: 'github.com', user: 'Alice' })).resolves.toBe( + 'recovered' + ) + }) + + it('surfaces multi-account unsupported from capability gate', async () => { + getGhMultiAccountCapabilityMock.mockResolvedValue('unsupported') + await expect( + resolveGhAccountToken({ host: 'github.com', user: 'Alice' }) + ).rejects.toMatchObject({ code: 'gh_multi_account_unsupported' }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('never puts the token into Error messages', () => { + const error = createGhBoundAccountUnavailableError({ host: 'github.com', user: 'Alice' }) + expect(error.message).not.toContain('gho_') + expect(error.message).toContain('Alice@github.com') + }) +}) diff --git a/src/main/github/gh-account-token.ts b/src/main/github/gh-account-token.ts new file mode 100644 index 00000000000..b9af51ebf33 --- /dev/null +++ b/src/main/github/gh-account-token.ts @@ -0,0 +1,243 @@ +/** + * Resolve a short-lived gh token for a bound account and prepare child env. + * + * Why: Part A injects tokens only into the gh child — never process-wide — + * via `gh auth token --user --hostname`. Tokens are never logged. + */ +import { ghExecFileAsync } from '../git/runner' +import { addWslEnvKeys } from '../../shared/wsl-env' +import { + ghTokenEnvVarForHost, + normalizeGhAccountBinding, + type GhAccountBinding +} from '../../shared/github/account-binding' +import { + createGhMultiAccountUnsupportedError, + getGhExecutionHostKey, + getGhMultiAccountCapability, + normalizeGhCapabilityTarget, + type GhCapabilityTarget +} from './gh-capability-state' + +const TOKEN_RESOLVE_TIMEOUT_MS = 10_000 +const POSITIVE_TTL_MS = 5 * 60_000 +const NEGATIVE_TTL_MS = 30_000 +const CACHE_MAX_ENTRIES = 128 + +const AMBIENT_GH_ENV_KEYS = [ + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'GH_ENTERPRISE_TOKEN', + 'GITHUB_ENTERPRISE_TOKEN', + 'GH_HOST', + 'GH_REPO' +] as const + +type TokenCacheEntry = + | { kind: 'positive'; token: string; expiresAt: number } + | { kind: 'negative'; expiresAt: number } + +const tokenCache = new Map() + +function tokenCacheKey(binding: GhAccountBinding, target: GhCapabilityTarget): string { + return `${getGhExecutionHostKey(target)}\0${binding.host}\0${binding.user}` +} + +function isTransientTokenResolveFailure(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const code = 'code' in error ? error.code : undefined + if (code === 'ETIMEDOUT' || code === 'EAGAIN' || code === 'EBUSY' || code === 'ENOENT') { + return true + } + const message = error instanceof Error ? error.message : String(error) + const stderr = 'stderr' in error ? String(error.stderr ?? '') : '' + const text = `${message}\n${stderr}`.toLowerCase() + return ( + text.includes('etimedout') || + text.includes('timed out') || + text.includes('timeout') || + text.includes('eagain') || + text.includes('enoent') || + text.includes('command not found') || + /(?:^|\s)gh:\s+not found(?:\s|$)/.test(text) + ) +} + +function stderrFromUnknown(error: unknown): string { + if (error && typeof error === 'object' && 'stderr' in error) { + return String(error.stderr ?? '') + } + return error instanceof Error ? error.message : String(error ?? '') +} + +function pruneTokenCache(now: number): void { + for (const [key, entry] of tokenCache) { + if (entry.expiresAt <= now) { + tokenCache.delete(key) + } + } + while (tokenCache.size > CACHE_MAX_ENTRIES) { + const oldest = tokenCache.keys().next().value + if (oldest === undefined) { + return + } + tokenCache.delete(oldest) + } +} + +export function createGhBoundAccountUnavailableError( + binding: GhAccountBinding +): Error & { code: 'gh_bound_account_unavailable'; stderr: string } { + const message = `Bound GitHub account ${binding.user}@${binding.host} is unavailable on this runtime.` + return Object.assign(new Error(message), { + code: 'gh_bound_account_unavailable' as const, + stderr: message + }) +} + +export function createGhBoundAccountHostMismatchError( + binding: GhAccountBinding, + host: string | undefined +): Error & { code: 'gh_bound_account_host_mismatch'; stderr: string } { + const message = host + ? `Bound GitHub account host ${binding.host} does not match request host ${host}.` + : `Bound GitHub account ${binding.user}@${binding.host} requires an explicit matching host.` + return Object.assign(new Error(message), { + code: 'gh_bound_account_host_mismatch' as const, + stderr: message + }) +} + +/** Resolves a short-lived token for `binding`, cached 5 min positive / 30 s negative per execution host. */ +export async function resolveGhAccountToken( + bindingInput: GhAccountBinding, + target: GhCapabilityTarget = {}, + nowMs = Date.now() +): Promise { + const binding = normalizeGhAccountBinding(bindingInput) + if (!binding) { + throw createGhBoundAccountUnavailableError({ host: '?', user: '?' }) + } + + const normalizedTarget = normalizeGhCapabilityTarget(target) + const capability = await getGhMultiAccountCapability(normalizedTarget, nowMs) + if (capability === 'unsupported') { + throw createGhMultiAccountUnsupportedError() + } + if (capability === 'unknown') { + throw createGhBoundAccountUnavailableError(binding) + } + + pruneTokenCache(nowMs) + const key = tokenCacheKey(binding, normalizedTarget) + const cached = tokenCache.get(key) + if (cached && cached.expiresAt > nowMs) { + if (cached.kind === 'positive') { + return cached.token + } + throw createGhBoundAccountUnavailableError(binding) + } + + try { + const resolveEnv = stripAmbientGhTokenEnv(process.env) + resolveEnv.GH_PROMPT_DISABLED = resolveEnv.GH_PROMPT_DISABLED ?? '1' + // Why: the command runner is the only sanctioned gh spawn (deadline, tree kill); + // `auth` argv is exempt from binding, so this cannot recurse. + const { stdout } = await ghExecFileAsync( + ['auth', 'token', '--user', binding.user, '--hostname', binding.host], + { + cwd: normalizedTarget.cwd, + wslDistro: normalizedTarget.wslDistro, + timeout: TOKEN_RESOLVE_TIMEOUT_MS, + env: resolveEnv + } + ) + const token = stdout.replace(/\r?\n/g, '').trim() + if (!token) { + // Why: empty token is a stable auth miss — negative-cache to avoid hammering keyring. + tokenCache.set(key, { kind: 'negative', expiresAt: nowMs + NEGATIVE_TTL_MS }) + pruneTokenCache(nowMs) + throw createGhBoundAccountUnavailableError(binding) + } + tokenCache.set(key, { kind: 'positive', token, expiresAt: nowMs + POSITIVE_TTL_MS }) + pruneTokenCache(nowMs) + return token + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'gh_bound_account_unavailable' || + error.code === 'gh_multi_account_unsupported') + ) { + throw error + } + const unavailable = createGhBoundAccountUnavailableError(binding) + const stderr = stderrFromUnknown(error) + if (stderr) { + unavailable.stderr = stderr + } + // Why: timeouts / missing gh / spawn blips must not poison the cache for 30s. + if (!isTransientTokenResolveFailure(error)) { + tokenCache.set(key, { kind: 'negative', expiresAt: nowMs + NEGATIVE_TTL_MS }) + pruneTokenCache(nowMs) + } + throw unavailable + } +} + +/** Drops ambient GH_TOKEN/GH_HOST/GH_REPO so a bound call cannot inherit the globally active login. */ +export function stripAmbientGhTokenEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next = { ...env } + for (const key of AMBIENT_GH_ENV_KEYS) { + delete next[key] + } + return next +} + +/** Builds the gh child env with the bound token, registering it in WSLENV when the child runs under wsl.exe. */ +export function buildBoundGhChildEnv(args: { + baseEnv?: NodeJS.ProcessEnv + binding: GhAccountBinding + token: string + /** When true, register token + GH_PROMPT_DISABLED in WSLENV for wsl.exe. */ + forWsl?: boolean +}): NodeJS.ProcessEnv { + const binding = normalizeGhAccountBinding(args.binding) + if (!binding) { + throw createGhBoundAccountUnavailableError({ host: '?', user: '?' }) + } + const tokenVar = ghTokenEnvVarForHost(binding.host) + const env = stripAmbientGhTokenEnv(args.baseEnv ?? process.env) + env.GH_PROMPT_DISABLED = env.GH_PROMPT_DISABLED ?? '1' + env[tokenVar] = args.token + if (args.forWsl) { + addWslEnvKeys(env, [tokenVar, 'GH_PROMPT_DISABLED']) + } + return env +} + +/** Drops cached tokens for one binding across every execution host; clears the whole cache when omitted. */ +export function invalidateGhAccountTokenCache(binding?: GhAccountBinding | null): void { + if (!binding) { + tokenCache.clear() + return + } + const normalized = normalizeGhAccountBinding(binding) + if (!normalized) { + tokenCache.clear() + return + } + for (const key of tokenCache.keys()) { + if (key.endsWith(`\0${normalized.host}\0${normalized.user}`)) { + tokenCache.delete(key) + } + } +} + +/** @internal — test-only */ +export function clearGhAccountTokenCacheForTests(): void { + tokenCache.clear() +} diff --git a/src/main/github/gh-capability-state.test.ts b/src/main/github/gh-capability-state.test.ts new file mode 100644 index 00000000000..725b47354b8 --- /dev/null +++ b/src/main/github/gh-capability-state.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock +})) + +import { + clearGhCapabilityStateForTests, + getGhExecutionHostKey, + getGhMultiAccountCapability, + invalidateGhMultiAccountCapability +} from './gh-capability-state' + +describe('gh-capability-state', () => { + beforeEach(() => { + clearGhCapabilityStateForTests() + ghExecFileAsyncMock.mockReset() + }) + + afterEach(() => { + clearGhCapabilityStateForTests() + }) + + it('keys local vs wsl execution hosts', () => { + expect(getGhExecutionHostKey({})).toBe('local') + expect(getGhExecutionHostKey({ wslDistro: 'Ubuntu' })).toBe('wsl:Ubuntu') + }) + + it('reports supported when help lists --user', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: 'Usage: gh auth token [--user login]', + stderr: '' + }) + await expect(getGhMultiAccountCapability()).resolves.toBe('supported') + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'token', '--help'], + expect.objectContaining({ timeout: 10_000 }) + ) + }) + + it('reports unsupported when help omits --user', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: 'Usage: gh auth token', + stderr: '' + }) + await expect(getGhMultiAccountCapability({ wslDistro: 'Ubuntu' })).resolves.toBe('unsupported') + }) + + it('caches per runtime and invalidates on request', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: 'Usage: gh auth token [--user login]', + stderr: '' + }) + await getGhMultiAccountCapability({ wslDistro: 'Ubuntu' }) + await getGhMultiAccountCapability({ wslDistro: 'Ubuntu' }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + + invalidateGhMultiAccountCapability({ wslDistro: 'Ubuntu' }) + await getGhMultiAccountCapability({ wslDistro: 'Ubuntu' }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/github/gh-capability-state.ts b/src/main/github/gh-capability-state.ts new file mode 100644 index 00000000000..14bfe342ad6 --- /dev/null +++ b/src/main/github/gh-capability-state.ts @@ -0,0 +1,165 @@ +/** + * Per-runtime cache for `gh auth token --user` support (gh ≥ 2.40). + * + * Why: binding creation must not offer accounts when the execution host's gh + * is too old; probe once per local/WSL runtime and recheck after fallbacks. + */ +import { ghExecFileAsync } from '../git/runner' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { GhMultiAccountCapability } from '../../shared/github/auth-types' + +export type GhCapabilityTarget = { + cwd?: string + wslDistro?: string +} + +const CAPABILITY_UNSUPPORTED_RETRY_MS = 30 * 60_000 +const CAPABILITY_UNKNOWN_RETRY_MS = 60_000 +const CAPABILITY_PROBE_TIMEOUT_MS = 10_000 + +type CachedCapability = { + value: GhMultiAccountCapability + retryAfterMs: number +} + +const capabilityByExecutionHost = new Map() +const probesByExecutionHost = new Map>() +/** Bumped on invalidate so in-flight probes cannot commit stale results. */ +const generationByExecutionHost = new Map() + +/** Infers the WSL distro from a UNC `cwd` so capability and token probes run on the same host they are cached under. */ +export function normalizeGhCapabilityTarget(target: GhCapabilityTarget = {}): GhCapabilityTarget { + const wslDistro = + target.wslDistro ?? (target.cwd ? parseWslUncPath(target.cwd)?.distro : undefined) + return { + ...(target.cwd ? { cwd: target.cwd } : {}), + ...(wslDistro ? { wslDistro } : {}) + } +} + +/** Cache key isolating native, per-distro WSL, and SSH capability state from each other. */ +export function getGhExecutionHostKey(target: GhCapabilityTarget = {}): string { + const normalized = normalizeGhCapabilityTarget(target) + return normalized.wslDistro ? `wsl:${normalized.wslDistro}` : 'local' +} + +function isNarrowMultiAccountUnsupported(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? '') + const stderr = + error && typeof error === 'object' && 'stderr' in error ? String(error.stderr ?? '') : '' + const text = `${message}\n${stderr}`.toLowerCase() + // Why: only treat option-echo / unknown-flag shapes as unsupported — missing + // gh, auth failures, and timeouts stay unknown so Settings can Retry. + return ( + text.includes('unknown flag: --user') || + text.includes('unknown command') || + (text.includes('auth token') && text.includes('unknown flag')) + ) +} + +async function probeMultiAccountCapability( + target: GhCapabilityTarget +): Promise { + const normalized = normalizeGhCapabilityTarget(target) + try { + const { stdout, stderr } = await ghExecFileAsync(['auth', 'token', '--help'], { + cwd: normalized.cwd, + wslDistro: normalized.wslDistro, + timeout: CAPABILITY_PROBE_TIMEOUT_MS, + env: { + ...process.env, + GH_PROMPT_DISABLED: process.env.GH_PROMPT_DISABLED ?? '1' + } + }) + const help = `${stdout}\n${stderr}` + return help.includes('--user') ? 'supported' : 'unsupported' + } catch (error) { + if (isNarrowMultiAccountUnsupported(error)) { + return 'unsupported' + } + return 'unknown' + } +} + +/** Probes `gh auth token --user` support; 'unknown' means the probe itself failed and must not be cached as unsupported. */ +export async function getGhMultiAccountCapability( + target: GhCapabilityTarget = {}, + nowMs = Date.now() +): Promise { + const normalized = normalizeGhCapabilityTarget(target) + const key = getGhExecutionHostKey(normalized) + const cached = capabilityByExecutionHost.get(key) + if (cached && nowMs < cached.retryAfterMs) { + return cached.value + } + if (cached && nowMs >= cached.retryAfterMs) { + capabilityByExecutionHost.delete(key) + } + + const inFlight = probesByExecutionHost.get(key) + if (inFlight) { + return inFlight + } + + const generation = generationByExecutionHost.get(key) ?? 0 + const probe = probeMultiAccountCapability(normalized).then((value) => { + if ((generationByExecutionHost.get(key) ?? 0) !== generation) { + return value + } + const retryAfterMs = + nowMs + + (value === 'supported' + ? Number.POSITIVE_INFINITY + : value === 'unsupported' + ? CAPABILITY_UNSUPPORTED_RETRY_MS + : CAPABILITY_UNKNOWN_RETRY_MS) + capabilityByExecutionHost.set(key, { + value, + retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : Number.MAX_SAFE_INTEGER + }) + return value + }) + probesByExecutionHost.set(key, probe) + try { + return await probe + } finally { + if (probesByExecutionHost.get(key) === probe) { + probesByExecutionHost.delete(key) + } + } +} + +/** Bumps the generation so a probe already in flight cannot commit its now-stale result. */ +export function invalidateGhMultiAccountCapability(target?: GhCapabilityTarget): void { + if (!target) { + capabilityByExecutionHost.clear() + probesByExecutionHost.clear() + for (const key of generationByExecutionHost.keys()) { + generationByExecutionHost.set(key, (generationByExecutionHost.get(key) ?? 0) + 1) + } + return + } + const key = getGhExecutionHostKey(target) + capabilityByExecutionHost.delete(key) + probesByExecutionHost.delete(key) + generationByExecutionHost.set(key, (generationByExecutionHost.get(key) ?? 0) + 1) +} + +export function createGhMultiAccountUnsupportedError(): Error & { + code: 'gh_multi_account_unsupported' + stderr: string +} { + const message = + 'This GitHub CLI is too old for per-project account binding (needs gh ≥ 2.40 with `gh auth token --user`).' + return Object.assign(new Error(message), { + code: 'gh_multi_account_unsupported' as const, + stderr: message + }) +} + +/** @internal — test-only */ +export function clearGhCapabilityStateForTests(): void { + capabilityByExecutionHost.clear() + probesByExecutionHost.clear() + generationByExecutionHost.clear() +} diff --git a/src/main/github/github-repository-identity.gh-account.test.ts b/src/main/github/github-repository-identity.gh-account.test.ts new file mode 100644 index 00000000000..06f40e31e4a --- /dev/null +++ b/src/main/github/github-repository-identity.gh-account.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { ghRepoExecOptions, githubRepoContext } from './github-repository-identity' + +describe('ghRepoExecOptions ghAccount threading', () => { + it('keeps ghAccount when cwd is present for local repos', () => { + expect( + ghRepoExecOptions( + githubRepoContext('/repo', null, { + wslDistro: 'Ubuntu', + ghAccount: { host: 'github.com', user: 'Alice' } + }) + ) + ).toEqual({ + cwd: '/repo', + wslDistro: 'Ubuntu', + ghAccount: { host: 'github.com', user: 'Alice' } + }) + }) + + it('keeps ghAccount when SSH omits cwd', () => { + expect( + ghRepoExecOptions( + githubRepoContext('/remote/repo', 'ssh-1', { + ghAccount: { host: 'github.acme.com', user: 'Bob' } + }) + ) + ).toEqual({ + ghAccount: { host: 'github.acme.com', user: 'Bob' } + }) + }) +}) diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index c601252deda..61ad30ac537 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -1,5 +1,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { readRemoteUrl } from '../git/remote-url-probe' +import type { GhAccountBinding } from '../../shared/github/account-binding' import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { getSshGitProvider, @@ -26,11 +27,14 @@ export type GitHubRepoContext = { connectionId?: string | null wslDistro?: string admissionTier?: GitAdmissionTier + /** SSH keeps the binding even when cwd is omitted from gh options. */ + ghAccount?: GhAccountBinding } export type LocalGitExecOptions = { wslDistro?: string admissionTier?: GitAdmissionTier + ghAccount?: GhAccountBinding } export type GitHubRemoteIdentityProbeOptions = { @@ -46,7 +50,8 @@ export function githubRepoContext( repoPath, connectionId: connectionId ?? null, ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}), + ...(localGitOptions.ghAccount ? { ghAccount: localGitOptions.ghAccount } : {}) } } @@ -55,13 +60,16 @@ export function ghRepoExecOptions(context: GitHubRepoContext): { encoding?: BufferEncoding wslDistro?: string admissionTier?: GitAdmissionTier + ghAccount?: GhAccountBinding } { + const account = context.ghAccount ? { ghAccount: context.ghAccount } : {} return context.connectionId - ? {} + ? { ...account } : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}), - ...(context.admissionTier ? { admissionTier: context.admissionTier } : {}) + ...(context.admissionTier ? { admissionTier: context.admissionTier } : {}), + ...account } } diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index 9669ef29a93..562cdfcebb8 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -396,18 +396,28 @@ describe('resolveGitHubPrStartPoint', () => { repoPath: '/repo-root', prNumber: 1738, issueSourcePreference: 'origin', + localGitOptions: { ghAccount: { host: 'github.com', user: 'alice' } }, gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, resolveRemote: async () => 'origin' }) - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null, {}, 'origin') + expect(getWorkItemMock).toHaveBeenCalledWith( + '/repo-root', + 1738, + 'pr', + null, + { + ghAccount: { host: 'github.com', user: 'alice' } + }, + 'origin' + ) expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( '/repo-root', 1738, null, - {}, + { ghAccount: { host: 'github.com', user: 'alice' } }, 'origin' ) expect(result).toEqual({ diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 118cbc474ef..0174ac7cbb1 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -1,4 +1,5 @@ import type { IssueSourcePreference } from '../../shared/repo-types' +import type { GhAccountBinding } from '../../shared/github/account-binding' import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/worktree/types' import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch' import { @@ -21,7 +22,7 @@ type ResolveGitHubPrStartPointArgs = { isCrossRepository?: boolean issueSourcePreference?: IssueSourcePreference connectionId?: string | null - localGitOptions?: { wslDistro?: string } + localGitOptions?: { wslDistro?: string; ghAccount?: GhAccountBinding } gitExec: GitExec fetchRemoteTrackingRef: (remote: string, branch: string) => Promise // Why: returns the durable local ref the fetch wrote so resolve can rev-parse diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index d6088552c82..e9853eb7538 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -31,8 +31,14 @@ export { } export type { RateLimitBucketKind } -/** gh exec routing shared by the project-view read/write paths. `host` pins - * GHES requests to the enterprise server via the runner's host qualifier. */ +/** + * gh exec routing shared by the project-view read/write paths. `host` pins + * GHES requests to the enterprise server via the runner's host qualifier. + * + * Intentionally ambient: Project View does not honor `Repo.ghAccount` (Part A). + * Work-items drawer / create-worktree search use bound injection; Project View + * keeps the process login so host-scoped project boards stay stable. + */ export type ProjectGhExecOptions = { cwd?: string; host?: string } // Why: owner/project-addressed calls carry only a host (no repo slug), so they diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index f75d2810321..4e1a515ab89 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -3,6 +3,10 @@ * * Why: heavy fan-out (listWorkItems × repos, org-walks) can drain the core/search buckets; surfacing remaining budget lets users self-regulate rather than throttle. * The probe itself is exempt from rate-limit accounting per GitHub docs. + * + * Part A: breaker stays host/runtime-scoped (not account-scoped). Bound-account + * calls can trip a host breaker that ambient reset probes later clear — known + * cross-account bleed, accepted for Part A. */ import type { GetRateLimitResult, diff --git a/src/main/github/review-head-remote.test.ts b/src/main/github/review-head-remote.test.ts index 216907a7790..90e4203f14e 100644 --- a/src/main/github/review-head-remote.test.ts +++ b/src/main/github/review-head-remote.test.ts @@ -120,12 +120,16 @@ describe('resolveGitHubReviewHeadRemote', () => { await resolveGitHubReviewHeadRemote({ repoPath: '/repo', connectionId: 'ssh-1', - localGitOptions: { wslDistro: 'Ubuntu' }, + localGitOptions: { + wslDistro: 'Ubuntu', + ghAccount: { host: 'github.acme.com', user: 'alice' } + }, gitExec: gitExecWithRemotes(['origin', 'upstream']) }) expect(getGitHubApiRepositoryForRemoteMock).toHaveBeenCalledWith('/repo', 'upstream', 'ssh-1', { - wslDistro: 'Ubuntu' + wslDistro: 'Ubuntu', + ghAccount: { host: 'github.acme.com', user: 'alice' } }) }) }) diff --git a/src/main/github/review-head-remote.ts b/src/main/github/review-head-remote.ts index e408d626ad2..eabd04f86cc 100644 --- a/src/main/github/review-head-remote.ts +++ b/src/main/github/review-head-remote.ts @@ -1,4 +1,5 @@ import type { IssueSourcePreference } from '../../shared/repo-types' +import type { GhAccountBinding } from '../../shared/github/account-binding' import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' import { getDefaultRemote } from '../git/repo' import { getGitHubApiRepositoryForRemote } from './github-api-repository' @@ -11,7 +12,7 @@ export async function resolveGitHubReviewHeadRemote(args: { repoPath: string issueSourcePreference?: IssueSourcePreference connectionId?: string | null - localGitOptions?: { wslDistro?: string } + localGitOptions?: { wslDistro?: string; ghAccount?: GhAccountBinding } gitExec: GitExec }): Promise { const { stdout } = await args.gitExec(['remote']) diff --git a/src/main/ipc/github-account-handlers.ts b/src/main/ipc/github-account-handlers.ts index b5d91b6757a..1b957879811 100644 --- a/src/main/ipc/github-account-handlers.ts +++ b/src/main/ipc/github-account-handlers.ts @@ -1,12 +1,19 @@ import { ipcMain } from 'electron' import { appStarSourceSchema } from '../../shared/gh-star-source' +import { githubHostFromIdentityKey } from '../../shared/github/repository-identity-key' import { diagnoseGhAuth } from '../github/auth-diagnose' import { checkOrcaStarred, getAuthenticatedViewer, starOrca } from '../github/client' +import { + listGhAccountBindingInventory, + validateGhAccountBinding +} from '../github/gh-account-binding-inventory' import { getRateLimit } from '../github/rate-limit' +import type { Store } from '../persistence' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { track } from '../telemetry/client' +import { assertRegisteredGitHubRepo, getGitHubLocalGitOptionArgs } from './github-repo-routing' -export function registerGitHubAccountHandlers(): void { +export function registerGitHubAccountHandlers(store: Store): void { ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { @@ -28,4 +35,29 @@ export function registerGitHubAccountHandlers(): void { ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => diagnoseGhAuth(args?.host) ) + + ipcMain.handle( + 'gh:listBindableAccounts', + (_event, args: { repoPath: string; repoId?: string; refreshCapability?: boolean }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) + return listGhAccountBindingInventory(getGitHubLocalGitOptionArgs(store, repo)[0] ?? {}, { + refreshCapability: args.refreshCapability === true, + ...(requiredHost ? { requiredHost } : {}) + }) + } + ) + + ipcMain.handle( + 'gh:validateAccountBinding', + (_event, args: { repoPath: string; repoId?: string; host: string; user: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) + return validateGhAccountBinding( + { host: args.host, user: args.user }, + getGitHubLocalGitOptionArgs(store, repo)[0] ?? {}, + requiredHost ? { requiredHost } : {} + ) + } + ) } diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts index 22a8b4c4178..1746890199a 100644 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -59,6 +59,8 @@ const EXPECTED_GITHUB_IPC_CHANNELS = [ 'gh:starOrca', 'gh:rateLimit', 'gh:diagnoseAuth', + 'gh:listBindableAccounts', + 'gh:validateAccountBinding', 'gh:listAccessibleProjects', 'gh:resolveProjectRef', 'gh:listProjectViews', diff --git a/src/main/ipc/github-repo-routing.ts b/src/main/ipc/github-repo-routing.ts index a7f93c2b44f..5c44d0e3413 100644 --- a/src/main/ipc/github-repo-routing.ts +++ b/src/main/ipc/github-repo-routing.ts @@ -11,7 +11,10 @@ import { type PRRefreshValidationDenialReason } from '../github/pr-refresh-validation-backoff' import type { Store } from '../persistence' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { + getLocalProjectGhExecOptions, + type LocalProjectGhExecOptions +} from '../project-runtime-git-options' export type GitHubRepoScopedArgs = { repoPath: string @@ -80,8 +83,8 @@ export function getGitHubRepoConnectionId(repo: Repo): string | null { export function getGitHubLocalGitOptionArgs( store: Store, repo: Repo -): [] | [{ wslDistro?: string }] { - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) +): [] | [LocalProjectGhExecOptions] { + const localGitOptions = getLocalProjectGhExecOptions(store, repo) return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] } diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 668d9fdca79..bbb1103144f 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -16,6 +16,6 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi registerGitHubPRReviewHandlers(store) registerGitHubPRMutationHandlers(store) registerGitHubIssueMutationHandlers(store) - registerGitHubAccountHandlers() + registerGitHubAccountHandlers(store) registerGitHubProjectViewHandlers() } diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 6f2a957a20d..aa997d37c9a 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -482,6 +482,78 @@ describe('registerHostedReviewHandlers', () => { expect(createHostedReviewMock).not.toHaveBeenCalled() }) + // Why: these handlers are the Electron path for every hosted-review gh call — without + // the binding they silently run as the ambient login while the RPC path honours it. + describe('gh account binding', () => { + const boundRepo = { ...repo, ghAccount: { host: 'github.com', user: 'octocat' } } + + beforeEach(() => { + store.getRepo.mockImplementation((repoId: string) => (repoId === repo.id ? boundRepo : null)) + store.getRepos.mockReturnValue([boundRepo]) + }) + + it('carries the bound account through creation, stacked creation and branch lookup', async () => { + createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/42' }) + createStackedHostedReviewMock.mockResolvedValueOnce({ + ok: true, + number: 43, + url: 'https://x/43', + stackNumber: 50, + parentReview: { number: 42, url: 'https://x/42' } + }) + getHostedReviewForBranchMock.mockResolvedValueOnce(null) + registerHostedReviewHandlers(store as never, stats as never) + + const base = { repoPath, repoId: repo.id, worktreePath, provider: 'github', base: 'main' } + await handlers['hostedReview:create'](null, { ...base, head: 'feature/pr', title: 'PR' }) + await handlers['hostedReview:createStacked'](null, { + ...base, + base: 'stack/parent', + head: 'stack/child', + title: 'Child' + }) + await handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + branch: 'feature/pr' + }) + + const expected = { + localGitExecOptions: expect.objectContaining({ ghAccount: boundRepo.ghAccount }) + } + expect(createHostedReviewMock).toHaveBeenCalledWith( + worktreePath, + expect.anything(), + 'ssh:ssh-1', + expected + ) + expect(createStackedHostedReviewMock).toHaveBeenCalledWith( + worktreePath, + expect.anything(), + 'ssh:ssh-1', + expected + ) + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(expect.objectContaining(expected)) + }) + + it('leaves an unbound repo on the ambient account', async () => { + store.getRepo.mockImplementation((repoId: string) => (repoId === repo.id ? repo : null)) + store.getRepos.mockReturnValue([repo]) + getHostedReviewForBranchMock.mockResolvedValueOnce(null) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + branch: 'feature/pr' + }) + + expect(getHostedReviewForBranchMock.mock.calls[0][0].localGitExecOptions).not.toHaveProperty( + 'ghAccount' + ) + }) + }) + it('rejects creation when repoId and repoPath point at different registered repos', async () => { store.getRepo.mockImplementation((repoId: string) => repoId === repo.id ? { ...repo, path: '/other/repo' } : null diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 8faecb17865..2b80ef167fa 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -17,7 +17,10 @@ import { createStackedHostedReview } from '../source-control/stacked-hosted-revi import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' import { listRepoWorktreeGraph } from '../repo-worktrees' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { + getLocalProjectGhExecOptions, + getLocalProjectWorktreeGitOptions +} from '../project-runtime-git-options' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../shared/execution-host' import { getRepoHostedReviewExecutionHostId } from '../source-control/hosted-review-execution-host' @@ -107,7 +110,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { const repo = assertRegisteredRepoForBranch(args, store) const localGitOptions = { - ...getLocalProjectWorktreeGitOptions(store, repo), + ...getLocalProjectGhExecOptions(store, repo), admissionTier: args.admissionTier ?? ('background' as const) } const review = await getHostedReviewForBranch({ @@ -140,7 +143,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector async (_event, args: HostedReviewCreationEligibilityArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const localGitOptions = getLocalProjectGhExecOptions(store, repo) return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath, @@ -154,7 +157,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) const localGitOptions = { - ...getLocalProjectWorktreeGitOptions(store, repo), + ...getLocalProjectGhExecOptions(store, repo), admissionTier: 'interactive' as const } // Why: the dirty preflight must not count Orca's own shared symlinks as user work (issue #10451). @@ -201,7 +204,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) const localGitOptions = { - ...getLocalProjectWorktreeGitOptions(store, repo), + ...getLocalProjectGhExecOptions(store, repo), admissionTier: 'interactive' as const } const sharedLinkPaths = getRepoSshConnectionId(repo) ? [] : getWorktreeSharedLinkPaths(repo) diff --git a/src/main/ipc/repos/repo-update-handler.ts b/src/main/ipc/repos/repo-update-handler.ts index 263c2f6f283..5af9e097121 100644 --- a/src/main/ipc/repos/repo-update-handler.ts +++ b/src/main/ipc/repos/repo-update-handler.ts @@ -6,6 +6,8 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { normalizeExecutionHostId } from '../../../shared/execution-host' import { normalizeRepoBadgeColor } from '../../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../../shared/repo-icon' +import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' +import type { GhAccountBinding } from '../../../shared/github/account-binding' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeCustomWorktreeVisibilitySources, @@ -52,6 +54,7 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor externalWorktreeDiscoverySuppressedAt?: | Repo['externalWorktreeDiscoverySuppressedAt'] | null + ghAccount?: GhAccountBinding | null } } ) => { @@ -75,6 +78,19 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor ) { delete updates.forkSyncMode } + // Why: null is the transport sentinel for clearing the binding; malformed shapes are dropped, never coerced. + if ('ghAccount' in updates) { + if (updates.ghAccount == null) { + updates.ghAccount = null + } else { + const normalized = normalizeGhAccountBinding(updates.ghAccount) + if (!normalized) { + delete updates.ghAccount + } else { + updates.ghAccount = normalized + } + } + } // Why: worktree materialization calls .trim() per entry, so strip non-string[] at the boundary to avoid a silent throw later. if ('symlinkPaths' in updates && updates.symlinkPaths !== undefined) { const v = updates.symlinkPaths as unknown diff --git a/src/main/ipc/worktrees/create/register-review-base-handlers.ts b/src/main/ipc/worktrees/create/register-review-base-handlers.ts index 6ebe8f26fbd..ab2892e8954 100644 --- a/src/main/ipc/worktrees/create/register-review-base-handlers.ts +++ b/src/main/ipc/worktrees/create/register-review-base-handlers.ts @@ -4,7 +4,7 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { gitExecFileAsync } from '../../../git/runner' import { getLocalProjectGitExecOptions, - getLocalProjectWorktreeGitOptions + getLocalProjectGhExecOptions } from '../../../project-runtime-git-options' import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' import { @@ -49,6 +49,11 @@ export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { } return provider.exec(args, repo.path) } + const localGhOptions = repo.connectionId + ? repo.ghAccount + ? { ghAccount: repo.ghAccount } + : undefined + : getLocalProjectGhExecOptions(store, repo) // Why: SSH review-head fetches require narrow write-capable RPCs. const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => fetchPrHeadTrackingRef( @@ -75,7 +80,7 @@ export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { isCrossRepository: args.isCrossRepository, issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + localGitOptions: localGhOptions, gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef, @@ -86,7 +91,7 @@ export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { repoPath: repo.path, issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + localGitOptions: localGhOptions, gitExec }) }) diff --git a/src/main/persistence-update-repo.test.ts b/src/main/persistence-update-repo.test.ts index a89378d6c71..d2426729658 100644 --- a/src/main/persistence-update-repo.test.ts +++ b/src/main/persistence-update-repo.test.ts @@ -544,6 +544,23 @@ describe('Store', () => { expect(reloaded.getRepo('r1')!.issueSourcePreference).toBe('upstream') }) + it('updateRepo persists and clears ghAccount bindings', async () => { + const store = await createStore() + store.addRepo(makeRepo()) + + const updated = store.updateRepo('r1', { + ghAccount: { host: ' GitHub.COM ', user: ' Alice ' } + }) + expect(updated!.ghAccount).toEqual({ host: 'github.com', user: 'Alice' }) + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')!.ghAccount).toEqual({ host: 'github.com', user: 'Alice' }) + + const cleared = reloaded.updateRepo('r1', { ghAccount: null }) + expect(cleared!.ghAccount).toBeUndefined() + }) + it('updateRepo persists fork sync mode across reloads', async () => { const store = await createStore() store.addRepo(makeRepo()) diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 2573c63305f..d96f7c31481 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -1,5 +1,6 @@ import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' +import type { GhAccountBinding } from '../../../shared/github/account-binding' import { removeRepoFromHostWorkspaceSessions, removeRepoFromWorkspaceSession @@ -191,6 +192,7 @@ export class RepoLifecycleOperations { agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + ghAccount?: GhAccountBinding | null }, hostId?: ExecutionHostId ): Repo | null { diff --git a/src/main/persistence/tracking-repos/repo-hydration.ts b/src/main/persistence/tracking-repos/repo-hydration.ts index 3c5a455419c..e642ef50f53 100644 --- a/src/main/persistence/tracking-repos/repo-hydration.ts +++ b/src/main/persistence/tracking-repos/repo-hydration.ts @@ -3,6 +3,7 @@ import { getRepoExecutionHostId } from '../../../shared/execution-host' import { getDefaultRepoHookSettings } from '../../../shared/constants' import { isFolderRepo } from '../../../shared/repo-kind' import { sanitizeRepoIcon } from '../../../shared/repo-icon' +import { normalizeGhAccountBinding } from '../../../shared/github/account-binding' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { sanitizeForkSyncMode, @@ -35,6 +36,7 @@ export function hydrateRepo(repo: Repo, gitUsernameCache: ReadonlyMap - > + > & { + ghAccount?: GhAccountBinding | null + } >(updates: T): T { const sanitized = { ...updates } if ('badgeColor' in sanitized) { @@ -138,6 +142,15 @@ export function sanitizeRepoUpdatesForPersistence< sanitized.forkSyncMode = forkSyncMode } } + // Why: `null` is the clear sentinel for updateRepo; only malformed shapes are dropped. + if ('ghAccount' in sanitized && sanitized.ghAccount != null) { + const ghAccount = normalizeGhAccountBinding(sanitized.ghAccount) + if (!ghAccount) { + delete sanitized.ghAccount + } else { + sanitized.ghAccount = ghAccount + } + } if ('customWorktreeVisibilitySources' in sanitized) { const sources = normalizeCustomWorktreeVisibilitySources( sanitized.customWorktreeVisibilitySources diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 5959c9222f4..399d05ac653 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -5,6 +5,8 @@ import { getRepoExecutionHostId } from '../../../shared/execution-host' import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' +import type { GhAccountBinding } from '../../../shared/github/account-binding' +import { invalidateGhAccountTokenCache } from '../../github/gh-account-token' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' export type RepoUpdateMutationOperations = { @@ -71,6 +73,7 @@ export class RepoUpdatePersistenceOperations { agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + ghAccount?: GhAccountBinding | null }, hostId?: ExecutionHostId ): Repo | null { @@ -81,6 +84,7 @@ export class RepoUpdatePersistenceOperations { if (!repo) { return null } + const previousGhAccount = repo.ghAccount const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates) if ( 'executionHostId' in updates && @@ -138,6 +142,10 @@ export class RepoUpdatePersistenceOperations { delete repo.issueSourcePreference delete sanitizedUpdates.issueSourcePreference } + if ('ghAccount' in sanitizedUpdates && sanitizedUpdates.ghAccount == null) { + delete repo.ghAccount + delete sanitizedUpdates.ghAccount + } if ('worktreeBasePath' in sanitizedUpdates && sanitizedUpdates.worktreeBasePath === undefined) { delete repo.worktreeBasePath delete sanitizedUpdates.worktreeBasePath @@ -189,6 +197,13 @@ export class RepoUpdatePersistenceOperations { sanitizedUpdates.sourceControlAi = normalizedSourceControlAi } } + if ('ghAccount' in updates) { + // Why: a rebind or unbind must not reuse a token cached for the previous login. + invalidateGhAccountTokenCache(previousGhAccount) + if (sanitizedUpdates.ghAccount) { + invalidateGhAccountTokenCache(sanitizedUpdates.ghAccount) + } + } Object.assign(repo, sanitizedUpdates) this.bumpLocalWorktreeScanGeneration(id) this.syncProjectHostSetupCompatibilityState() diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index 20aa0e9659a..4e4e90d9312 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -21,6 +21,10 @@ export type LocalProjectWorktreeGitOptions = { wslDistro?: string } +export type LocalProjectGhExecOptions = LocalProjectWorktreeGitOptions & { + ghAccount?: Repo['ghAccount'] +} + export function getLocalProjectGitExecOptions( store: Store, repo: Repo @@ -59,6 +63,19 @@ export function getLocalProjectWorktreeGitOptions( return wslDistro ? { wslDistro } : {} } +/** + * Execution options for repo-scoped gh calls: the project's WSL routing plus its account binding. + * + * Why: every gh call site must resolve options through here — one that reaches for + * `getLocalProjectWorktreeGitOptions` instead silently runs as the ambient login. + */ +export function getLocalProjectGhExecOptions(store: Store, repo: Repo): LocalProjectGhExecOptions { + return { + ...getLocalProjectWorktreeGitOptions(store, repo), + ...(repo.ghAccount ? { ghAccount: repo.ghAccount } : {}) + } +} + /** * Git routing for the speculative worktree-create warm-up. * diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f138970d5ad..f641b7bb036 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -16,8 +16,9 @@ import { DEFAULT_REPO_SEARCH_REFS_LIMIT } from './orca-runtime-postlude' import type { Repo } from '../../shared/repo-types' import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import { - getLocalProjectWorktreeGitOptions, - resolveLocalProjectRuntimeForRepo + getLocalProjectGhExecOptions, + resolveLocalProjectRuntimeForRepo, + type LocalProjectGhExecOptions } from '../project-runtime-git-options' import { getAgentLaunchPlatformForRepo } from './runtime-agent-launch-resolution' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' @@ -239,12 +240,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu repo: Repo, admissionTier?: GitAdmissionTier ): - | { - localGitExecOptions: { - wslDistro?: string - admissionTier?: GitAdmissionTier - } - } + | { localGitExecOptions: LocalProjectGhExecOptions & { admissionTier?: GitAdmissionTier } } | undefined { const localGitOptions = { ...this.getLocalGitExecutionOptionArgs(repo)[0], @@ -255,8 +251,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu : undefined } - protected getLocalGitExecutionOptionArgs(repo: Repo): [] | [{ wslDistro?: string }] { - const localGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + protected getLocalGitExecutionOptionArgs(repo: Repo): [] | [LocalProjectGhExecOptions] { + const localGitOptions = getLocalProjectGhExecOptions(this.requireStore(), repo) return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] } diff --git a/src/main/runtime/rpc/methods/github-account-binding-methods.ts b/src/main/runtime/rpc/methods/github-account-binding-methods.ts new file mode 100644 index 00000000000..30f893141c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/github-account-binding-methods.ts @@ -0,0 +1,25 @@ +import { defineMethod } from '../core' +import { + BindableAccounts, + ValidateAccountBinding +} from '../../../../shared/rpc-contract/github-account-binding-params' + +export const GITHUB_ACCOUNT_BINDING_METHODS = [ + defineMethod({ + name: 'github.listBindableAccounts', + params: BindableAccounts, + handler: async (params, { runtime }) => + runtime.listGitHubBindableAccounts(params.repo, { + refreshCapability: params.refreshCapability + }) + }), + defineMethod({ + name: 'github.validateAccountBinding', + params: ValidateAccountBinding, + handler: async (params, { runtime }) => + runtime.validateGitHubAccountBinding(params.repo, { + host: params.host, + user: params.user + }) + }) +] diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index d2113f9ac22..7d450f0eaec 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -1,3 +1,4 @@ +import { GITHUB_ACCOUNT_BINDING_METHODS } from './github-account-binding-methods' import { GITHUB_ISSUE_METHODS } from './github-issue-methods' import { GITHUB_PROJECT_METHODS } from './github-project-methods' import { GITHUB_PULL_REQUEST_METHODS } from './github-pull-request-methods' @@ -6,6 +7,7 @@ import { GITHUB_REPO_WORK_ITEM_METHODS } from './github-repo-work-item-methods' export const GITHUB_METHODS = [ ...GITHUB_REPO_WORK_ITEM_METHODS, + ...GITHUB_ACCOUNT_BINDING_METHODS, ...GITHUB_ISSUE_METHODS, ...GITHUB_PULL_REQUEST_METHODS, ...GITHUB_PULL_REQUEST_UPDATE_METHODS, diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index a9813823f01..3de0e8feae0 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' -import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrcaRuntimeService } from '../../orca-runtime' import { REPO_METHODS } from './repo' import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../../shared/repo-search-limits' @@ -389,6 +389,43 @@ describe('repo RPC methods', () => { }) }) + it('persists normalized ghAccount bindings and clear sentinels', async () => { + const runtime = new OrcaRuntimeService(null) + vi.spyOn(runtime, 'updateRepo').mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + ghAccount: { host: 'github.com', user: 'Alice' } + }) + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { ghAccount: { host: ' GitHub.COM ', user: ' Alice ' } } + }), + { clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] } + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + ghAccount: { host: 'github.com', user: 'Alice' } + }) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', ghAccount: { host: 'github.com', user: 'Alice' } } } + }) + + await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { ghAccount: null } + }) + ) + expect(runtime.updateRepo).toHaveBeenLastCalledWith('repo-1', { ghAccount: null }) + }) + it('persists agent worktree visibility updates', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/runtime-github-issue-comment-commands.ts b/src/main/runtime/runtime-github-issue-comment-commands.ts index 187ea999009..fcef9602405 100644 --- a/src/main/runtime/runtime-github-issue-comment-commands.ts +++ b/src/main/runtime/runtime-github-issue-comment-commands.ts @@ -2,6 +2,7 @@ import type { GitHubCreateIssueFields, GitHubIssueUpdate } from '../../shared/is import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { GitHubPRReviewCommentInput } from '../../shared/github/comment-types' import type { Repo } from '../../shared/repo-types' +import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' import { addIssueComment, addPRReviewComment, @@ -10,7 +11,7 @@ import { updateIssue } from '../github/client' -type LocalGitArgs = [] | [{ wslDistro?: string }] +type LocalGitArgs = [] | [LocalProjectGhExecOptions] type RuntimeGitHubIssueCommentCommandsDeps = { resolveRepo: (selector: string) => Promise diff --git a/src/main/runtime/runtime-github-repository-query-commands.ts b/src/main/runtime/runtime-github-repository-query-commands.ts index 4850324966b..53f1edc8364 100644 --- a/src/main/runtime/runtime-github-repository-query-commands.ts +++ b/src/main/runtime/runtime-github-repository-query-commands.ts @@ -1,5 +1,6 @@ import type { ListWorkItemsResult } from '../../shared/github/work-item-types' import type { Repo } from '../../shared/repo-types' +import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' import { countWorkItems, getWorkItem, @@ -10,10 +11,15 @@ import { listWorkItems, type MainWorkItem } from '../github/client' +import { + listGhAccountBindingInventory, + validateGhAccountBinding +} from '../github/gh-account-binding-inventory' import { getRateLimit } from '../github/rate-limit' +import { githubHostFromIdentityKey } from '../../shared/github/repository-identity-key' import { getWorkItemDetails } from '../github/work-item-details' -type LocalGitArgs = [] | [{ wslDistro?: string }] +type LocalGitArgs = [] | [LocalProjectGhExecOptions] type RuntimeGitHubRepositoryQueryCommandsDeps = { resolveRepo: (selector: string) => Promise @@ -145,4 +151,29 @@ export class RuntimeGitHubRepositoryQueryCommands { }): Promise>> { return getRateLimit(options) } + + async listGitHubBindableAccounts( + repoSelector: string, + options?: { refreshCapability?: boolean } + ): Promise>> { + const repo = await this.deps.resolveRepo(repoSelector) + const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) + return listGhAccountBindingInventory(this.deps.getLocalGitArgs(repo)[0] ?? {}, { + refreshCapability: options?.refreshCapability === true, + ...(requiredHost ? { requiredHost } : {}) + }) + } + + async validateGitHubAccountBinding( + repoSelector: string, + binding: { host: string; user: string } + ): Promise>> { + const repo = await this.deps.resolveRepo(repoSelector) + const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) + return validateGhAccountBinding( + binding, + this.deps.getLocalGitArgs(repo)[0] ?? {}, + requiredHost ? { requiredHost } : {} + ) + } } diff --git a/src/main/runtime/runtime-github-review-mutation-commands.ts b/src/main/runtime/runtime-github-review-mutation-commands.ts index d3722d3c80f..82182286674 100644 --- a/src/main/runtime/runtime-github-review-mutation-commands.ts +++ b/src/main/runtime/runtime-github-review-mutation-commands.ts @@ -2,6 +2,7 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' import type { GitHubReactionContent } from '../../shared/github/comment-types' import type { Repo } from '../../shared/repo-types' +import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' import { mergePR, markPRReadyForReview, @@ -17,7 +18,7 @@ import { updatePRTitle } from '../github/client' -type LocalGitArgs = [] | [{ wslDistro?: string }] +type LocalGitArgs = [] | [LocalProjectGhExecOptions] type RuntimeGitHubReviewMutationCommandsDeps = { resolveRepo: (selector: string) => Promise diff --git a/src/main/runtime/runtime-github-review-query-commands.ts b/src/main/runtime/runtime-github-review-query-commands.ts index 58433fc5a19..e9d24864828 100644 --- a/src/main/runtime/runtime-github-review-query-commands.ts +++ b/src/main/runtime/runtime-github-review-query-commands.ts @@ -1,9 +1,10 @@ import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' import type { Repo } from '../../shared/repo-types' +import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' import { getIssue, getPRCheckDetails, getPRChecks, getPRComments } from '../github/client' import { getPRFileContents } from '../github/work-item-details' -type LocalGitArgs = [] | [{ wslDistro?: string }] +type LocalGitArgs = [] | [LocalProjectGhExecOptions] type RuntimeGitHubReviewQueryCommandsDeps = { resolveRepo: (selector: string) => Promise diff --git a/src/main/runtime/runtime-github-worktree-base.ts b/src/main/runtime/runtime-github-worktree-base.ts index 0188874182a..b419f099472 100644 --- a/src/main/runtime/runtime-github-worktree-base.ts +++ b/src/main/runtime/runtime-github-worktree-base.ts @@ -9,7 +9,7 @@ import { resolveGitHubReviewHeadRemote } from '../github/review-head-remote' import { gitExecFileAsync } from '../git/runner' import { getLocalProjectGitExecOptions, - getLocalProjectWorktreeGitOptions + getLocalProjectGhExecOptions } from '../project-runtime-git-options' import { requireSshGitProvider } from '../providers/ssh-git-dispatch' import { isFolderRepo } from '../../shared/repo-kind' @@ -43,9 +43,11 @@ export async function resolveRuntimeGitHubWorktreeBase( const localGitExecOptions = sshGitProvider ? undefined : getLocalProjectGitExecOptions(deps.store, repo) - const localWorktreeGitOptions = sshGitProvider - ? {} - : getLocalProjectWorktreeGitOptions(deps.store, repo) + const localGhOptions = sshGitProvider + ? repo.ghAccount + ? { ghAccount: repo.ghAccount } + : undefined + : getLocalProjectGhExecOptions(deps.store, repo) const gitExec = sshGitProvider ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) @@ -54,7 +56,7 @@ export async function resolveRuntimeGitHubWorktreeBase( repoPath: repo.path, issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, - localGitOptions: localWorktreeGitOptions, + localGitOptions: localGhOptions, gitExec }) const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => @@ -81,7 +83,7 @@ export async function resolveRuntimeGitHubWorktreeBase( isCrossRepository: args.isCrossRepository, issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, - localGitOptions: localWorktreeGitOptions, + localGitOptions: localGhOptions, gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef, diff --git a/src/main/runtime/runtime-repository-command-surface.ts b/src/main/runtime/runtime-repository-command-surface.ts index 5c9fcbea425..8820e4e945b 100644 --- a/src/main/runtime/runtime-repository-command-surface.ts +++ b/src/main/runtime/runtime-repository-command-surface.ts @@ -30,6 +30,8 @@ type GitHubRepositoryQueryCommandName = | 'listRepoLabels' | 'listRepoAssignableUsers' | 'getGitHubRateLimit' + | 'listGitHubBindableAccounts' + | 'validateGitHubAccountBinding' export type RuntimeRepositoryCommandSurface = { listProjects: RuntimeProjectHostSetupController['listProjects'] @@ -156,6 +158,8 @@ export function installRuntimeRepositoryCommandSurface( listRepoLabels: queries.listRepoLabels.bind(queries), listRepoAssignableUsers: queries.listRepoAssignableUsers.bind(queries), getGitHubRateLimit: queries.getGitHubRateLimit.bind(queries), + listGitHubBindableAccounts: queries.listGitHubBindableAccounts.bind(queries), + validateGitHubAccountBinding: queries.validateGitHubAccountBinding.bind(queries), getRepoHooks: hooks.getRepoHooks.bind(hooks), checkRepoHooks: hooks.checkRepoHooks.bind(hooks), inspectRepoSetupScriptImports: hooks.inspectRepoSetupScriptImports.bind(hooks), diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index cd74ece61b2..27260d23744 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -1,6 +1,7 @@ import { getRepoExecutionHostId } from '../../shared/execution-host' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' +import type { GhAccountBinding } from '../../shared/github/account-binding' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' @@ -38,6 +39,8 @@ type RepositoryUpdates = Partial< > & { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + /** Only `null` clears; `omitUndefined` drops a stripped (undefined) field so it never unbinds. */ + ghAccount?: GhAccountBinding | null } function omitUndefined>(value: T): Partial { diff --git a/src/main/source-control/hosted-review-git-options.ts b/src/main/source-control/hosted-review-git-options.ts index 060c116bcb2..79d564d38f9 100644 --- a/src/main/source-control/hosted-review-git-options.ts +++ b/src/main/source-control/hosted-review-git-options.ts @@ -1,8 +1,10 @@ import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' +import type { GhAccountBinding } from '../../shared/github/account-binding' export type HostedReviewLocalGitOptions = { wslDistro?: string admissionTier?: GitAdmissionTier + ghAccount?: GhAccountBinding } export type HostedReviewExecutionOptions = { @@ -18,9 +20,11 @@ export function getHostedReviewLocalGitOptions( ): HostedReviewLocalGitOptions { const wslDistro = options.localGitExecOptions?.wslDistro const admissionTier = options.localGitExecOptions?.admissionTier + const ghAccount = options.localGitExecOptions?.ghAccount return { ...(wslDistro ? { wslDistro } : {}), - ...(admissionTier ? { admissionTier } : {}) + ...(admissionTier ? { admissionTier } : {}), + ...(ghAccount ? { ghAccount } : {}) } } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 75e849bf740..9a592ff264c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -27,6 +27,7 @@ import type { EphemeralVmApi } from './api/ephemeral-vm-api' import type { ExportApi, FilesystemApi } from './api/filesystem-api' import type { GitInspectionApi } from './api/git-inspection-api' import type { GitOperationApi } from './api/git-operation-api' +import type { GithubAccountApi } from './api/github-account-api' import type { GithubPullRequestApi } from './api/github-pull-request-api' import type { GithubWorkItemApi } from './api/github-work-item-api' import type { GitLabApi } from './api/gitlab-api' @@ -83,7 +84,7 @@ export type PreloadApi = { feedback: FeedbackApi crashReports: CrashReportsApi export: ExportApi - gh: Merged + gh: Merged hostedReview: HostedReviewApi gl: GitLabApi bitbucket: BitbucketApi diff --git a/src/preload/api/gh-bridge-mutations-and-projects.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts index 80b746bfb79..d02c4b9a50e 100644 --- a/src/preload/api/gh-bridge-mutations-and-projects.ts +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -2,7 +2,11 @@ import { ipcRenderer } from 'electron' import type { GitHubCommentResult } from '../../shared/github/comment-types' import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { GetRateLimitResult } from '../../shared/github/rate-limit-types' -import type { GhAuthDiagnostic } from '../../shared/github/auth-types' +import type { + GhAccountBindingInventory, + GhAccountBindingValidationResult, + GhAuthDiagnostic +} from '../../shared/github/auth-types' import type { TaskSourceContext } from '../../shared/task-source-context' import type { GetProjectViewTableResult, @@ -155,6 +159,18 @@ export const ghMutationsAndProjectsApi = { ipcRenderer.invoke('gh:rateLimit', args), diagnoseAuth: (args?: { host?: string }): Promise => ipcRenderer.invoke('gh:diagnoseAuth', args), + listBindableAccounts: (args: { + repoPath: string + repoId?: string + refreshCapability?: boolean + }): Promise => ipcRenderer.invoke('gh:listBindableAccounts', args), + validateAccountBinding: (args: { + repoPath: string + repoId?: string + host: string + user: string + }): Promise => + ipcRenderer.invoke('gh:validateAccountBinding', args), listAccessibleProjects: ( args?: ListAccessibleProjectsArgs ): Promise => ipcRenderer.invoke('gh:listAccessibleProjects', args), diff --git a/src/preload/api/github-account-api.ts b/src/preload/api/github-account-api.ts new file mode 100644 index 00000000000..fe219d55065 --- /dev/null +++ b/src/preload/api/github-account-api.ts @@ -0,0 +1,18 @@ +import type { + GhAccountBindingInventory, + GhAccountBindingValidationResult +} from '../../shared/github/auth-types' + +export type GithubAccountApi = { + listBindableAccounts: (args: { + repoPath: string + repoId?: string + refreshCapability?: boolean + }) => Promise + validateAccountBinding: (args: { + repoPath: string + repoId?: string + host: string + user: string + }) => Promise +} diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index fd82697fc74..409154f0c15 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from '../../shared/execution-host' +import type { GhAccountBinding } from '../../shared/github/account-binding' import type { HostRepoCatalogSnapshot, ListReposForExecutionHostArgs @@ -70,6 +71,7 @@ export type RepositoryApi = { agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + ghAccount?: GhAccountBinding | null } }) => Promise pickFolder: () => Promise diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx new file mode 100644 index 00000000000..66adda30779 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.test.tsx @@ -0,0 +1,342 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { + GhAccountBindingInventory, + GhAuthAccount, + GhMultiAccountCapability +} from '../../../../shared/github/auth-types' +import type { GhAccountBinding } from '../../../../shared/github/account-binding' +import type * as RepositoryGitHubAccountModule from './repository-github-account' +import { isGhAccountBindingEnforced } from './repository-github-account' +import { RepositoryGitHubAccountSection } from './RepositoryGitHubAccountSection' + +const { listAccountsMock, validateBindingMock, storeState } = vi.hoisted(() => { + const repos: Repo[] = [] + return { + listAccountsMock: vi.fn(), + validateBindingMock: vi.fn(), + storeState: { + settings: null, + repos, + settingsSearchQuery: '' + } + } +}) + +vi.mock('../../store', () => { + const useAppStore = (selector: (state: typeof storeState) => unknown) => selector(storeState) + useAppStore.getState = () => storeState + return { useAppStore } +}) + +vi.mock('@/lib/repo-runtime-owner', () => ({ + getRepoOwnerRoutedSettings: (settings: unknown) => settings +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn() +})) + +vi.mock('./repository-github-account', async (importOriginal) => ({ + ...(await importOriginal()), + listRepositoryGhBindableAccounts: listAccountsMock, + validateRepositoryGhAccountBinding: validateBindingMock +})) + +// Why: Radix Select needs pointer/layout APIs happy-dom lacks; a native select keeps value + disabled testable. +vi.mock('../ui/select', () => ({ + Select: ({ + value, + onValueChange, + disabled, + children + }: { + value: string + onValueChange: (value: string) => void + disabled?: boolean + children: React.ReactNode + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ + value, + disabled, + children + }: { + value: string + disabled?: boolean + children: React.ReactNode + }) => ( + + ) +})) + +const AMBIENT_VALUE = '__ambient_gh_login__' + +const BASE_REPO: Repo = { + id: 'repo-1', + path: '/home/user/project', + displayName: 'My Project', + badgeColor: '#000000', + addedAt: 0 +} + +const ALICE: GhAuthAccount = { + host: 'github.com', + user: 'alice', + active: true, + envToken: null, + source: 'keyring', + scopes: [] +} + +const ENV_BOT: GhAuthAccount = { + host: 'github.com', + user: 'bot', + active: false, + envToken: 'GH_TOKEN', + source: 'env', + scopes: [] +} + +function optionValue(binding: GhAccountBinding): string { + return `${binding.host}\0${binding.user}` +} + +function inventory( + accounts: GhAuthAccount[], + capability: GhMultiAccountCapability = 'supported' +): GhAccountBindingInventory { + return { capability, accounts } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + listAccountsMock.mockReset() + validateBindingMock.mockReset() + storeState.repos = [] +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +type UpdateRepo = (repoId: string, updates: { ghAccount?: GhAccountBinding | null }) => unknown + +/** updateRepo that echoes the binding back through the store, like a current-version host. */ +function echoingUpdateRepo(): ReturnType> { + return vi.fn(async (repoId, updates) => { + storeState.repos = storeState.repos.map((entry) => + entry.id === repoId ? { ...entry, ghAccount: updates.ghAccount ?? undefined } : entry + ) + return true + }) +} + +async function render(repo: Repo, updateRepo: UpdateRepo = echoingUpdateRepo()): Promise { + storeState.repos = [repo] + await act(async () => { + root.render( + React.createElement(RepositoryGitHubAccountSection, { + repo, + updateRepo, + forceVisible: true + }) + ) + }) + // Flush the inventory load kicked off by the mount effect. + await act(async () => {}) +} + +function getSelect(): HTMLSelectElement { + const select = container.querySelector('select') + if (!select) { + throw new Error('account select not found') + } + return select +} + +function getRefreshButton(): HTMLButtonElement { + const button = container.querySelector( + 'button[aria-label="Refresh GitHub accounts"]' + ) + if (!button) { + throw new Error('refresh accounts button not found') + } + return button +} + +function findOption(text: string): HTMLOptionElement { + const option = Array.from(container.querySelectorAll('option')).find((entry) => + entry.textContent?.includes(text) + ) + if (!option) { + throw new Error(`option containing "${text}" not found`) + } + return option +} + +async function choose(value: string): Promise { + await act(async () => { + const select = getSelect() + select.value = value + select.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + +describe('RepositoryGitHubAccountSection', () => { + it('offers keyring accounts, disables env-token accounts, and starts on the ambient option', async () => { + listAccountsMock.mockResolvedValue(inventory([ALICE, ENV_BOT])) + + await render(BASE_REPO) + + expect(getSelect().value).toBe(AMBIENT_VALUE) + expect(findOption('alice @ github.com').disabled).toBe(false) + expect(findOption('bot @ github.com (GH_TOKEN)').disabled).toBe(true) + }) + + it('refreshes the account inventory from the secondary control', async () => { + listAccountsMock.mockResolvedValue(inventory([ALICE])) + + await render(BASE_REPO) + await act(async () => { + getRefreshButton().click() + await Promise.resolve() + }) + + expect(listAccountsMock).toHaveBeenNthCalledWith(2, expect.anything(), BASE_REPO, { + refreshCapability: true + }) + }) + + it('validates and then binds a keyring account', async () => { + listAccountsMock.mockResolvedValue(inventory([ALICE])) + validateBindingMock.mockResolvedValue({ + ok: true, + binding: { host: 'github.com', user: 'alice' } + }) + const updateRepo = echoingUpdateRepo() + + await render(BASE_REPO, updateRepo) + await choose(optionValue(ALICE)) + + expect(validateBindingMock).toHaveBeenCalledWith(expect.anything(), BASE_REPO, { + host: 'github.com', + user: 'alice' + }) + expect(updateRepo).toHaveBeenCalledWith('repo-1', { + ghAccount: { host: 'github.com', user: 'alice' } + }) + expect(container.textContent).not.toContain('not enforced') + }) + + it('clears an existing binding through the ambient option without validating', async () => { + listAccountsMock.mockResolvedValue(inventory([ALICE])) + const updateRepo = echoingUpdateRepo() + + await render({ ...BASE_REPO, ghAccount: { host: 'github.com', user: 'alice' } }, updateRepo) + expect(getSelect().value).toBe(optionValue(ALICE)) + + await choose(AMBIENT_VALUE) + + expect(validateBindingMock).not.toHaveBeenCalled() + expect(updateRepo).toHaveBeenCalledWith('repo-1', { ghAccount: null }) + }) + + it('renders a disabled "(unavailable)" row for a bound account missing from the inventory', async () => { + listAccountsMock.mockResolvedValue(inventory([ALICE])) + const carol: GhAccountBinding = { host: 'github.com', user: 'carol' } + + await render({ ...BASE_REPO, ghAccount: carol }) + + expect(getSelect().value).toBe(optionValue(carol)) + expect(findOption('carol @ github.com (unavailable)').disabled).toBe(true) + }) + + it('disables account rows but keeps the ambient option while capability is unsupported', async () => { + listAccountsMock.mockResolvedValue(inventory([ALICE], 'unsupported')) + + await render({ + ...BASE_REPO, + ghAccount: { host: 'github.com', user: 'alice' } + }) + + expect(findOption('alice @ github.com').disabled).toBe(true) + expect(findOption('Default (ambient gh login)').disabled).toBe(false) + expect(container.textContent).toContain('Binding is unavailable until') + }) + + it('flags a binding the host did not echo back as not enforced', async () => { + listAccountsMock.mockResolvedValue(inventory([ALICE])) + validateBindingMock.mockResolvedValue({ + ok: true, + binding: { host: 'github.com', user: 'alice' } + }) + + await render(BASE_REPO, vi.fn().mockResolvedValue(true)) + await choose(optionValue(ALICE)) + + expect(container.textContent).toContain('Saved, but not enforced') + }) +}) + +describe('isGhAccountBindingEnforced', () => { + it('is enforced when the host echoes the same binding', () => { + expect( + isGhAccountBindingEnforced( + { host: 'github.com', user: 'Alice' }, + { host: 'github.com', user: 'Alice' } + ) + ).toBe(true) + }) + + it('is not enforced when the host drops the binding', () => { + expect(isGhAccountBindingEnforced({ host: 'github.com', user: 'Alice' }, undefined)).toBe(false) + expect(isGhAccountBindingEnforced({ host: 'github.com', user: 'Alice' }, null)).toBe(false) + }) + + it('is not enforced when the host echoes a different account or host', () => { + expect( + isGhAccountBindingEnforced( + { host: 'github.com', user: 'Alice' }, + { host: 'github.com', user: 'Bob' } + ) + ).toBe(false) + expect( + isGhAccountBindingEnforced( + { host: 'github.com', user: 'Alice' }, + { host: 'ghe.example.com', user: 'Alice' } + ) + ).toBe(false) + }) + + it('treats a cleared binding as enforced only when nothing is echoed back', () => { + expect(isGhAccountBindingEnforced(null, undefined)).toBe(true) + expect(isGhAccountBindingEnforced(null, null)).toBe(true) + expect(isGhAccountBindingEnforced(null, { host: 'github.com', user: 'Alice' })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx new file mode 100644 index 00000000000..142ccc45e57 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { RefreshCw } from 'lucide-react' +import type { GhAccountBinding } from '../../../../shared/github/account-binding' +import type { Repo } from '../../../../shared/repo-types' +import type { GhAccountBindingInventory } from '../../../../shared/github/auth-types' +import { Button } from '../ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SearchableSetting } from './SearchableSetting' +import { useAppStore } from '../../store' +import { getRepoOwnerRoutedSettings } from '@/lib/repo-runtime-owner' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { translate } from '@/i18n/i18n' +import { searchKeywords } from './settings-search-keywords' +import { + isGhAccountBindingEnforced, + listRepositoryGhBindableAccounts, + validateRepositoryGhAccountBinding +} from './repository-github-account' + +type RepositoryGitHubAccountSectionProps = { + repo: Repo + updateRepo: (repoId: string, updates: { ghAccount?: GhAccountBinding | null }) => unknown + forceVisible?: boolean +} + +function accountKey(host: string, user: string): string { + return `${host.toLowerCase()}\0${user.toLowerCase()}` +} + +// Why: Radix Select rejects an empty item value, so "inherit the ambient login" needs a sentinel. +const AMBIENT_VALUE = '__ambient_gh_login__' + +export function RepositoryGitHubAccountSection({ + repo, + updateRepo, + forceVisible +}: RepositoryGitHubAccountSectionProps): React.JSX.Element | null { + const settings = useAppStore((state) => state.settings) + const [inventory, setInventory] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [notEnforced, setNotEnforced] = useState(false) + const loadGenerationRef = useRef(0) + const selectLabelId = useId() + + // Why: getRepoOwnerRoutedSettings returns a fresh object each render, and the target + // depends on nothing else — key the memo on the id so it stays stable. + const activeRuntimeEnvironmentId = + getRepoOwnerRoutedSettings(settings, repo)?.activeRuntimeEnvironmentId ?? null + const runtimeTarget = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }), + [activeRuntimeEnvironmentId] + ) + + const loadInventory = useCallback( + async (refreshCapability = false) => { + const generation = ++loadGenerationRef.current + setLoading(true) + setError(null) + try { + const next = await listRepositoryGhBindableAccounts(runtimeTarget, repo, { + refreshCapability + }) + if (generation !== loadGenerationRef.current) { + return + } + setInventory(next) + } catch (err) { + if (generation !== loadGenerationRef.current) { + return + } + setError(err instanceof Error ? err.message : String(err)) + setInventory(null) + } finally { + if (generation === loadGenerationRef.current) { + setLoading(false) + } + } + }, + [repo, runtimeTarget] + ) + + useEffect(() => { + void loadInventory() + }, [loadInventory]) + + const capabilityUnsupported = inventory?.capability === 'unsupported' + const capabilityUnknown = inventory?.capability === 'unknown' + const capabilityBlocksBinding = capabilityUnsupported || capabilityUnknown + const selectedKey = repo.ghAccount ? accountKey(repo.ghAccount.host, repo.ghAccount.user) : '' + const inventoryAccounts = inventory?.accounts ?? [] + const selectedInInventory = Boolean( + repo.ghAccount && + inventoryAccounts.some((entry) => accountKey(entry.host, entry.user) === selectedKey) + ) + + const applyBinding = async (requested: GhAccountBinding | null) => { + if (saving) { + return + } + setSaving(true) + setError(null) + setNotEnforced(false) + try { + let resolved = requested + if (requested) { + const validation = await validateRepositoryGhAccountBinding(runtimeTarget, repo, requested) + if (!validation.ok) { + setError( + validation.error === 'gh_multi_account_unsupported' + ? translate( + 'auto.components.settings.RepositoryGitHubAccountSection.unsupported', + 'This runtime’s GitHub CLI is too old for account binding (needs gh ≥ 2.40).' + ) + : validation.error === 'gh_multi_account_capability_unknown' + ? translate( + 'auto.components.settings.RepositoryGitHubAccountSection.capabilityUnknown', + 'Could not determine whether this runtime’s GitHub CLI supports account binding. Retry.' + ) + : validation.error === 'gh_bound_account_not_keyring' + ? translate( + 'auto.components.settings.RepositoryGitHubAccountSection.envToken', + 'Environment-token accounts cannot be bound. Use a keyring login.' + ) + : validation.error === 'invalid_binding' + ? translate( + 'auto.components.settings.RepositoryGitHubAccountSection.invalidBinding', + 'That GitHub account binding is invalid.' + ) + : translate( + 'auto.components.settings.RepositoryGitHubAccountSection.unavailable', + 'That GitHub account is unavailable on this runtime.' + ) + ) + return + } + resolved = validation.binding + } + const result = await Promise.resolve(updateRepo(repo.id, { ghAccount: resolved })) + if (result === false) { + setError( + translate( + 'auto.components.settings.RepositoryGitHubAccountSection.saveFailed', + 'Could not save the GitHub account binding.' + ) + ) + return + } + const echoed = useAppStore.getState().repos.find((entry) => entry.id === repo.id)?.ghAccount + if (!isGhAccountBindingEnforced(resolved, echoed)) { + setNotEnforced(true) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + } + + return ( + +
+
+
+ {translate( + 'auto.components.settings.RepositoryGitHubAccountSection.title', + 'GitHub Account' + )} +
+

+ {translate( + 'auto.components.settings.RepositoryGitHubAccountSection.longDescription', + 'Orca injects a short-lived token from the selected keyring login into each gh call for this project. Project View stays on the ambient login.' + )} +

+
+
+ +
+
+ + {translate( + 'auto.components.settings.RepositoryGitHubAccountSection.selectLabel', + 'Account' + )} + + +
+ +
+ + {capabilityUnsupported ? ( +

+ {translate( + 'auto.components.settings.RepositoryGitHubAccountSection.capabilityBlocked', + 'Binding is unavailable until this runtime’s GitHub CLI supports `gh auth token --user` (gh ≥ 2.40).' + )} +

+ ) : null} + {capabilityUnknown ? ( +

+ {translate( + 'auto.components.settings.RepositoryGitHubAccountSection.capabilityUnknownHint', + 'Could not determine GitHub CLI account-binding support on this runtime. Clear the binding or retry.' + )} +

+ ) : null} + {notEnforced ? ( +

+ {translate( + 'auto.components.settings.RepositoryGitHubAccountSection.notEnforced', + 'Saved, but not enforced by this runtime (mixed-version host).' + )} +

+ ) : null} + {error ?

{error}

: null} +
+ ) +} diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 02874e5edc4..71a4c2e9c24 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -1,3 +1,4 @@ +import type { GhAccountBinding } from '../../../../shared/github/account-binding' import { useCallback, useRef, useState } from 'react' import type { OrcaHooks, RepoHookSettings } from '../../../../shared/orca-yaml-hook-types' import type { Project, ProjectUpdateArgs } from '../../../../shared/project-types' @@ -24,6 +25,7 @@ import { getRepositoryPaneSearchEntries } from './repository-search' import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput' import { RepositoryForkSyncSection } from './RepositoryForkSyncSection' +import { RepositoryGitHubAccountSection } from './RepositoryGitHubAccountSection' import { translate } from '@/i18n/i18n' import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSection' import { matchesRepositoryIdentitySearch } from './repository-identity-search' @@ -35,10 +37,11 @@ export { matchesRepositoryIdentitySearch } from './repository-identity-search' type RepositoryPaneRepoUpdate = Omit< Partial, - 'sourceControlAi' | 'externalWorktreeVisibility' + 'sourceControlAi' | 'externalWorktreeVisibility' | 'ghAccount' > & { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + ghAccount?: GhAccountBinding | null } const EMPTY_WSL_DISTROS: string[] = [] @@ -179,6 +182,7 @@ export function RepositoryPane({ const identityEntryTitles = new Set([ translate('auto.components.settings.repository.search.7e1e456a95', 'Display Name'), translate('auto.components.settings.repository.search.b24f00294a', 'Project Icon'), + translate('auto.components.settings.repository.search.githubAccount', 'GitHub Account'), translate( 'auto.components.settings.repository.search.keepForkUpToDate', 'Keep Fork Up to Date' @@ -365,6 +369,12 @@ export function RepositoryPane({ forceVisible={forceFullPaneForRepoMatch} /> + + + +const LOCAL_GH_ACCOUNT_IPC_TIMEOUT_MS = 30_000 + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + promise.then( + (value) => { + window.clearTimeout(timer) + resolve(value) + }, + (error: unknown) => { + window.clearTimeout(timer) + reject(error) + } + ) + }) +} + +/** Routes through RPC for remote environments and IPC locally; both read the repo's execution host. */ +export function listRepositoryGhBindableAccounts( + runtimeTarget: RuntimeTarget, + repo: Repo, + options: { refreshCapability?: boolean } = {} +): Promise { + if (runtimeTarget.kind === 'environment') { + return callRuntimeRpc( + runtimeTarget, + 'github.listBindableAccounts', + { repo: repo.id, refreshCapability: options.refreshCapability }, + { timeoutMs: LOCAL_GH_ACCOUNT_IPC_TIMEOUT_MS } + ) + } + return withTimeout( + window.api.gh.listBindableAccounts({ + repoPath: repo.path, + repoId: repo.id, + refreshCapability: options.refreshCapability + }), + LOCAL_GH_ACCOUNT_IPC_TIMEOUT_MS, + 'listBindableAccounts' + ) +} + +export function validateRepositoryGhAccountBinding( + runtimeTarget: RuntimeTarget, + repo: Repo, + binding: GhAccountBinding +): Promise { + if (runtimeTarget.kind === 'environment') { + return callRuntimeRpc( + runtimeTarget, + 'github.validateAccountBinding', + { repo: repo.id, host: binding.host, user: binding.user }, + { timeoutMs: LOCAL_GH_ACCOUNT_IPC_TIMEOUT_MS } + ) + } + return withTimeout( + window.api.gh.validateAccountBinding({ + repoPath: repo.path, + repoId: repo.id, + host: binding.host, + user: binding.user + }), + LOCAL_GH_ACCOUNT_IPC_TIMEOUT_MS, + 'validateAccountBinding' + ) +} + +/** False when an older remote runtime silently strips `ghAccount`, which the UI surfaces as "not enforced". */ +export function isGhAccountBindingEnforced( + requested: GhAccountBinding | null, + echoed: Repo['ghAccount'] | null | undefined +): boolean { + if (!requested) { + return !echoed + } + return ghAccountBindingsEqual(requested, echoed ?? null) +} diff --git a/src/renderer/src/components/settings/repository-search.ts b/src/renderer/src/components/settings/repository-search.ts index c9d2a91682f..cb0b90973d8 100644 --- a/src/renderer/src/components/settings/repository-search.ts +++ b/src/renderer/src/components/settings/repository-search.ts @@ -76,6 +76,40 @@ export function getRepositoryPaneSearchEntries( ) ] }, + ...(!isFolder + ? [ + { + title: translate( + 'auto.components.settings.repository.search.githubAccount', + 'GitHub Account' + ), + description: translate( + 'auto.components.settings.repository.search.githubAccountDescription', + 'Bind a keyring gh login for this project’s GitHub API calls.' + ), + keywords: [ + repo.displayName, + ...translateSearchKeyword( + 'auto.components.settings.repository.search.9dc60d7f6d', + 'github' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.githubAccountKeyword', + 'github account' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.ghAccount', + 'gh account' + ), + ...translateSearchKeyword( + 'auto.components.settings.repository.search.login', + 'login' + ), + ...translateSearchKeyword('auto.components.settings.repository.search.token', 'token') + ] + } + ] + : []), ...(repo.upstream && !isFolder ? [ { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 8fe443f65de..2e2b7db7ff6 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1407,6 +1407,9 @@ "updateOne": "Update server", "updating": "Updating servers…" }, + "RepositoryGitHubAccountSection": { + "retry": "Retry" + }, "RepositoryHooksSection": { "0e0dd5b9a5": "loaded", "32f417fe17": "text-emerald-700 dark:text-emerald-300", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 365ad6d0236..39acaef0a1d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10255,6 +10255,12 @@ "keepForkUpToDateDescription": "Safely fast-forward this fork from upstream.", "projectRuntime": "Project Runtime", "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL.", + "githubAccount": "GitHub Account", + "githubAccountDescription": "Bind a keyring gh login for this project’s GitHub API calls.", + "githubAccountKeyword": "github account", + "ghAccount": "gh account", + "login": "login", + "token": "token", "externalWorktrees": "External worktrees", "externalWorktreesDescription": "Override whether worktrees created outside Orca appear for this project.", "waitForSetupBeforeAgent": "wait for setup before starting agent", @@ -11837,6 +11843,25 @@ "auto": "Agent", "off": "Off" }, + "RepositoryGitHubAccountSection": { + "unsupported": "This runtime’s GitHub CLI is too old for account binding (needs gh ≥ 2.40).", + "capabilityUnknown": "Could not determine whether this runtime’s GitHub CLI supports account binding. Retry.", + "envToken": "Environment-token accounts cannot be bound. Use a keyring login.", + "invalidBinding": "That GitHub account binding is invalid.", + "unavailable": "That GitHub account is unavailable on this runtime.", + "saveFailed": "Could not save the GitHub account binding.", + "title": "GitHub Account", + "description": "Use a specific gh login for this project’s GitHub issue and pull request calls.", + "longDescription": "Orca injects a short-lived token from the selected keyring login into each gh call for this project. Project View stays on the ambient login.", + "retry": "Retry", + "selectLabel": "Account", + "ambient": "Default (ambient gh login)", + "unavailableOption": "{{user}} @ {{host}} (unavailable)", + "capabilityBlocked": "Binding is unavailable until this runtime’s GitHub CLI supports `gh auth token --user` (gh ≥ 2.40).", + "capabilityUnknownHint": "Could not determine GitHub CLI account-binding support on this runtime. Clear the binding or retry.", + "notEnforced": "Saved, but not enforced by this runtime (mixed-version host).", + "refreshAccounts": "Refresh GitHub accounts" + }, "shareSkills": { "title": "Share Skills", "description": "Share your skills with an unlisted link. Anyone who has it can install them.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d85e4e01b56..1afa654b90f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8900,6 +8900,12 @@ "keepForkUpToDateDescription": "Avanza este fork de forma segura desde upstream con fast-forward.", "projectRuntime": "Runtime del proyecto", "projectRuntimeDescription": "Elige si este proyecto se ejecuta en Windows o WSL.", + "githubAccount": "Cuenta de GitHub", + "githubAccountDescription": "Vincula un inicio de sesión de gh del llavero para las llamadas a la API de GitHub de este proyecto.", + "githubAccountKeyword": "cuenta de github", + "ghAccount": "cuenta gh", + "login": "inicio de sesión", + "token": "token", "externalWorktrees": "Worktrees externos", "externalWorktreesDescription": "Sobrescribe si los worktrees creados fuera de Orca aparecen en este proyecto.", "copy": "copy", @@ -9309,6 +9315,24 @@ "auto": "Agente", "off": "Desactivado" }, + "RepositoryGitHubAccountSection": { + "unsupported": "La CLI de GitHub de este runtime es demasiado antigua para vincular cuentas (necesita gh ≥ 2.40).", + "capabilityUnknown": "No se pudo determinar si la CLI de GitHub de este runtime admite la vinculación de cuentas. Reintenta.", + "envToken": "Las cuentas con token de entorno no se pueden vincular. Usa un inicio de sesión del llavero.", + "invalidBinding": "Esa vinculación de cuenta de GitHub no es válida.", + "unavailable": "Esa cuenta de GitHub no está disponible en este runtime.", + "saveFailed": "No se pudo guardar la vinculación de la cuenta de GitHub.", + "title": "Cuenta de GitHub", + "description": "Usa un inicio de sesión de gh específico para las llamadas de issues y pull requests de GitHub de este proyecto.", + "longDescription": "Orca inyecta un token de corta duración del inicio de sesión del llavero seleccionado en cada llamada de gh de este proyecto. La vista de proyecto sigue usando la sesión de gh activa.", + "retry": "Reintentar", + "selectLabel": "Cuenta", + "ambient": "Predeterminado (sesión de gh activa)", + "unavailableOption": "{{user}} @ {{host}} (no disponible)", + "capabilityBlocked": "La vinculación no está disponible hasta que la CLI de GitHub de este runtime admita `gh auth token --user` (gh ≥ 2.40).", + "capabilityUnknownHint": "No se pudo determinar si la CLI de GitHub de este runtime admite la vinculación de cuentas. Borra la vinculación o reintenta.", + "notEnforced": "Guardado, pero este runtime no lo aplica (host de versión mixta)." + }, "agent-status-hooks-copy": { "7707c15abb": "Hooks de estado del agente", "a68a642835": "Muestra los estados trabajando, esperando y completado en Orca. Desactívalo para quitar los hooks administrados por Orca y dejar de reinstalarlos." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 71d5f0797de..14adec55a3f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8922,6 +8922,12 @@ "keepForkUpToDateDescription": "このフォークを upstream から安全に fast-forward します。", "projectRuntime": "プロジェクトのランタイム", "projectRuntimeDescription": "このプロジェクトを Windows と WSL のどちらで実行するかを選択します。", + "githubAccount": "GitHub アカウント", + "githubAccountDescription": "このプロジェクトの GitHub API 呼び出し用のキーリング gh ログインをバインドします。", + "githubAccountKeyword": "GitHub アカウント", + "ghAccount": "gh アカウント", + "login": "ログイン", + "token": "token", "externalWorktrees": "外部ワークツリー", "externalWorktreesDescription": "このプロジェクトで Orca 外のワークツリーを表示するかどうかを上書きします。", "copy": "コピー", @@ -9331,6 +9337,24 @@ "auto": "Agent", "off": "オフ" }, + "RepositoryGitHubAccountSection": { + "unsupported": "このランタイムの GitHub CLI はアカウントバインディングには古すぎます (gh ≥ 2.40 が必要です)。", + "capabilityUnknown": "このランタイムの GitHub CLI がアカウントバインディングをサポートしているかどうかを判断できませんでした。再試行してください。", + "envToken": "環境トークンのアカウントはバインドできません。キーリングのログインを使用してください。", + "invalidBinding": "その GitHub アカウントバインディングは無効です。", + "unavailable": "その GitHub アカウントはこのランタイムでは使用できません。", + "saveFailed": "GitHub アカウントバインディングを保存できませんでした。", + "title": "GitHub アカウント", + "description": "このプロジェクトの GitHub Issue および PR の呼び出しには、特定の gh ログインを使用してください。", + "longDescription": "Orca は、選択したキーリングのログインから有効期間の短いトークンを取得し、このプロジェクトの各 gh 呼び出しに注入します。プロジェクトビューはアクティブな gh ログインを使い続けます。", + "retry": "再試行", + "selectLabel": "アカウント", + "ambient": "デフォルト (アクティブな gh ログイン)", + "unavailableOption": "{{user}} @ {{host}} (利用不可)", + "capabilityBlocked": "このランタイムの GitHub CLI が `gh auth token --user` (gh ≥ 2.40) をサポートするまで、バインディングは使用できません。", + "capabilityUnknownHint": "このランタイムでの GitHub CLI アカウントバインディングのサポートを確認できませんでした。バインディングをクリアするか、再試行してください。", + "notEnforced": "保存されましたが、このランタイム (混合バージョンのホスト) では適用されません。" + }, "agent-status-hooks-copy": { "7707c15abb": "Agent 状態フック", "a68a642835": "Orca で作業中、待機中、完了の状態を表示します。オフにすると Orca 管理のフックを削除し、再インストールを停止します。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6b26edeb2d8..a0e8ca8f561 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8890,6 +8890,12 @@ "keepForkUpToDateDescription": "이 포크를 upstream에서 안전하게 fast-forward합니다.", "projectRuntime": "프로젝트 런타임", "projectRuntimeDescription": "이 프로젝트를 Windows에서 실행할지 WSL에서 실행할지 선택합니다.", + "githubAccount": "GitHub 계정", + "githubAccountDescription": "이 프로젝트의 GitHub API 호출에 키링 gh 로그인을 바인딩합니다.", + "githubAccountKeyword": "GitHub 계정", + "ghAccount": "gh 계정", + "login": "로그인", + "token": "token", "externalWorktrees": "외부 작업 트리", "externalWorktreesDescription": "이 프로젝트에서 Orca 외부 작업 트리를 표시할지 재정의합니다.", "copy": "copy", @@ -9299,6 +9305,24 @@ "auto": "에이전트", "off": "꺼짐" }, + "RepositoryGitHubAccountSection": { + "unsupported": "이 런타임의 GitHub CLI는 계정 바인딩을 지원하기에는 너무 오래되었습니다(gh ≥ 2.40 필요).", + "capabilityUnknown": "이 런타임의 GitHub CLI가 계정 바인딩을 지원하는지 확인할 수 없습니다. 다시 시도하세요.", + "envToken": "환경 토큰 계정은 바인딩할 수 없습니다. 키링 로그인을 사용하세요.", + "invalidBinding": "해당 GitHub 계정 바인딩이 유효하지 않습니다.", + "unavailable": "이 런타임에서는 해당 GitHub 계정을 사용할 수 없습니다.", + "saveFailed": "GitHub 계정 바인딩을 저장할 수 없습니다.", + "title": "GitHub 계정", + "description": "이 프로젝트의 GitHub 이슈 및 PR 호출에 특정 gh 로그인을 사용하세요.", + "longDescription": "Orca는 선택한 키링 로그인의 단기 토큰을 이 프로젝트의 각 gh 호출에 주입합니다. 프로젝트 보기는 활성 gh 로그인을 계속 사용합니다.", + "retry": "다시 시도", + "selectLabel": "계정", + "ambient": "기본값 (활성 gh 로그인)", + "unavailableOption": "{{user}} @ {{host}} (사용할 수 없음)", + "capabilityBlocked": "이 런타임의 GitHub CLI가 `gh auth token --user`(gh ≥ 2.40)를 지원할 때까지 바인딩을 사용할 수 없습니다.", + "capabilityUnknownHint": "이 런타임에서 GitHub CLI 계정 바인딩 지원을 확인할 수 없습니다. 바인딩을 지우거나 다시 시도하세요.", + "notEnforced": "저장되었지만 이 런타임(혼합 버전 호스트)에서는 적용되지 않습니다." + }, "agent-status-hooks-copy": { "7707c15abb": "Agent 상태 훅", "a68a642835": "Orca에서 작업 중, 대기 중, 완료 상태를 표시합니다. 끄면 Orca가 관리하는 훅을 제거하고 재설치를 중단합니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cdd93fa8e53..39c3a09e047 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8955,6 +8955,12 @@ "keepForkUpToDateDescription": "从 upstream 安全地快进此 Fork。", "projectRuntime": "项目运行时", "projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。", + "githubAccount": "GitHub 账户", + "githubAccountDescription": "为该项目的 GitHub API 调用绑定密钥环中的 gh 登录。", + "githubAccountKeyword": "GitHub 账户", + "ghAccount": "gh 账户", + "login": "登录", + "token": "token", "externalWorktrees": "外部工作树", "externalWorktreesDescription": "覆盖是否为此项目显示在 Orca 外部创建的工作树。", "copy": "复制", @@ -9364,6 +9370,24 @@ "auto": "智能体", "off": "关闭" }, + "RepositoryGitHubAccountSection": { + "unsupported": "此运行时的 GitHub CLI 版本过旧,不支持账户绑定(需要 gh ≥ 2.40)。", + "capabilityUnknown": "无法确定此运行时的 GitHub CLI 是否支持账户绑定。请重试。", + "envToken": "环境令牌账户无法绑定。请使用密钥环登录。", + "invalidBinding": "该 GitHub 账户绑定无效。", + "unavailable": "该 GitHub 账户在此运行时不可用。", + "saveFailed": "无法保存 GitHub 账户绑定。", + "title": "GitHub 账户", + "description": "为此项目的 GitHub 议题和拉取请求调用使用特定的 gh 登录。", + "longDescription": "Orca 会将所选密钥环登录的短期令牌注入此项目的每次 gh 调用。项目视图继续使用当前活动的 gh 登录。", + "retry": "重试", + "selectLabel": "账户", + "ambient": "默认(当前活动的 gh 登录)", + "unavailableOption": "{{user}} @ {{host}}(不可用)", + "capabilityBlocked": "在此运行时的 GitHub CLI 支持 `gh auth token --user`(gh ≥ 2.40)之前,无法使用账户绑定。", + "capabilityUnknownHint": "无法确定此运行时的 GitHub CLI 是否支持账户绑定。请清除绑定或重试。", + "notEnforced": "已保存,但此运行时(混合版本主机)不会强制执行。" + }, "agent-status-hooks-copy": { "7707c15abb": "智能体状态钩子", "a68a642835": "在 Orca 中显示工作中、等待中和完成状态。关闭后将移除 Orca 管理的钩子并停止重新安装。" diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 2d845659ddc..2223626f44b 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -1,3 +1,4 @@ +import type { GhAccountBinding } from '../../../../shared/github/account-binding' import type { SshRepoReadoption } from '../../../../shared/ssh-types' import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { @@ -55,6 +56,7 @@ export type RepoUpdate = Partial< agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + ghAccount?: GhAccountBinding | null } export type ProjectUpdate = ProjectUpdateArgs['updates'] diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 29748348852..93da54840a4 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -3,6 +3,7 @@ import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { normalizeGhAccountBinding } from '../../../../shared/github/account-binding' import { findRepoForHost, getRepoHostIdentityForParts, @@ -50,6 +51,14 @@ export function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate { ) { delete sanitized.forkSyncMode } + if ('ghAccount' in sanitized && sanitized.ghAccount != null) { + const normalized = normalizeGhAccountBinding(sanitized.ghAccount) + if (!normalized) { + delete sanitized.ghAccount + } else { + sanitized.ghAccount = normalized + } + } if ('customWorktreeVisibilitySources' in sanitized) { const sources = normalizeCustomWorktreeVisibilitySources( sanitized.customWorktreeVisibilitySources @@ -145,6 +154,7 @@ export function createRepoUpdateActions( const { sourceControlAi, externalWorktreeDiscoverySuppressedAt, + ghAccount, externalWorktreeVisibility, agentWorktreeVisibility, ...updatesWithoutClearSentinels @@ -180,6 +190,12 @@ export function createRepoUpdateActions( } else if (externalWorktreeDiscoverySuppressedAt !== undefined) { mergedRepo = { ...mergedRepo, externalWorktreeDiscoverySuppressedAt } } + if (ghAccount === null) { + const { ghAccount: _ghAccount, ...repoWithoutGhAccount } = mergedRepo + mergedRepo = repoWithoutGhAccount + } else if (ghAccount !== undefined) { + mergedRepo = { ...mergedRepo, ghAccount } + } return mergedRepo }) return { diff --git a/src/renderer/src/web/preload-api/web-github-api.ts b/src/renderer/src/web/preload-api/web-github-api.ts index 330c2a8e11e..55f964716be 100644 --- a/src/renderer/src/web/preload-api/web-github-api.ts +++ b/src/renderer/src/web/preload-api/web-github-api.ts @@ -129,6 +129,16 @@ export function createGitHubApi(): WebGitHubApi { starOrca: () => Promise.resolve(false), rateLimit: (args) => route>(GITHUB_WEB_RPC_METHODS.rateLimit, args), + listBindableAccounts: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listBindableAccounts, + args + ), + validateAccountBinding: (args) => + route>( + GITHUB_WEB_RPC_METHODS.validateAccountBinding, + args + ), diagnoseAuth: () => Promise.resolve({ ok: false, diff --git a/src/renderer/src/web/preload-api/web-github-routes.ts b/src/renderer/src/web/preload-api/web-github-routes.ts index 49c85b58246..c0b712ce58d 100644 --- a/src/renderer/src/web/preload-api/web-github-routes.ts +++ b/src/renderer/src/web/preload-api/web-github-routes.ts @@ -32,6 +32,8 @@ export type WebGitHubRouteKey = | 'listLabels' | 'listAssignableUsers' | 'rateLimit' + | 'listBindableAccounts' + | 'validateAccountBinding' | 'listAccessibleProjects' | 'resolveProjectRef' | 'listProjectViews' @@ -83,6 +85,8 @@ export type WebGitHubRuntimeMethod = | 'github.listLabels' | 'github.listAssignableUsers' | 'github.rateLimit' + | 'github.listBindableAccounts' + | 'github.validateAccountBinding' | 'github.project.listAccessible' | 'github.project.resolveRef' | 'github.project.listViews' @@ -134,6 +138,8 @@ export const GITHUB_WEB_RPC_METHODS = { listLabels: 'github.listLabels', listAssignableUsers: 'github.listAssignableUsers', rateLimit: 'github.rateLimit', + listBindableAccounts: 'github.listBindableAccounts', + validateAccountBinding: 'github.validateAccountBinding', listAccessibleProjects: 'github.project.listAccessible', resolveProjectRef: 'github.project.resolveRef', listProjectViews: 'github.project.listViews', diff --git a/src/renderer/src/web/web-preload-api-github.test.ts b/src/renderer/src/web/web-preload-api-github.test.ts index bd90429736a..2a89a27aed2 100644 --- a/src/renderer/src/web/web-preload-api-github.test.ts +++ b/src/renderer/src/web/web-preload-api-github.test.ts @@ -38,6 +38,7 @@ describe('web GitHub preload API', () => { 'listAccessibleProjects', 'listAssignableUsers', 'listAssignableUsersBySlug', + 'listBindableAccounts', 'listIssueTypesBySlug', 'listIssues', 'listLabels', @@ -77,6 +78,7 @@ describe('web GitHub preload API', () => { 'updatePRTitle', 'updateProjectItemField', 'updatePullRequestBySlug', + 'validateAccountBinding', 'viewer', 'workItem', 'workItemByOwnerRepo', @@ -370,6 +372,18 @@ describe('web GitHub preload API', () => { expectedMethod: 'github.rateLimit', expectedParams: { force: true } }, + { + key: 'listBindableAccounts', + args: { repoPath, refreshCapability: true }, + expectedMethod: 'github.listBindableAccounts', + expectedParams: withRepo({ repoPath, refreshCapability: true }) + }, + { + key: 'validateAccountBinding', + args: { repoPath, host: 'github.com', user: 'octocat' }, + expectedMethod: 'github.validateAccountBinding', + expectedParams: withRepo({ repoPath, host: 'github.com', user: 'octocat' }) + }, { key: 'listAccessibleProjects', args: { host: 'ghe.example.com' }, diff --git a/src/shared/github/account-binding.test.ts b/src/shared/github/account-binding.test.ts new file mode 100644 index 00000000000..9a033de4fef --- /dev/null +++ b/src/shared/github/account-binding.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + ghAccountBindingsEqual, + ghTokenEnvVarForHost, + normalizeGhAccountBinding +} from './account-binding' + +describe('normalizeGhAccountBinding', () => { + it('trims, lowercases host, and preserves user case', () => { + expect(normalizeGhAccountBinding({ host: ' GitHub.COM ', user: ' Alice ' })).toEqual({ + host: 'github.com', + user: 'Alice' + }) + }) + + it('rejects malformed shapes', () => { + expect(normalizeGhAccountBinding(null)).toBeNull() + expect(normalizeGhAccountBinding({ host: '', user: 'a' })).toBeNull() + expect(normalizeGhAccountBinding({ host: 'github.com', user: '' })).toBeNull() + expect(normalizeGhAccountBinding({ host: 'bad host', user: 'a' })).toBeNull() + expect(normalizeGhAccountBinding({ host: 'github.com', user: 'has space' })).toBeNull() + }) + + it('accepts GHES/LDAP logins with underscores and dots', () => { + expect(normalizeGhAccountBinding({ host: 'github.acme.com', user: 'svc_build' })).toEqual({ + host: 'github.acme.com', + user: 'svc_build' + }) + expect(normalizeGhAccountBinding({ host: 'github.acme.com', user: 'first.last' })).toEqual({ + host: 'github.acme.com', + user: 'first.last' + }) + }) +}) + +describe('ghTokenEnvVarForHost', () => { + it('selects GH_TOKEN for github.com, github.localhost, and *.ghe.com', () => { + expect(ghTokenEnvVarForHost('github.com')).toBe('GH_TOKEN') + expect(ghTokenEnvVarForHost('github.localhost')).toBe('GH_TOKEN') + expect(ghTokenEnvVarForHost('acme.ghe.com')).toBe('GH_TOKEN') + }) + + it('selects GH_ENTERPRISE_TOKEN for classic GHES hosts', () => { + expect(ghTokenEnvVarForHost('github.acme-corp.com')).toBe('GH_ENTERPRISE_TOKEN') + expect(ghTokenEnvVarForHost('ghe.internal')).toBe('GH_ENTERPRISE_TOKEN') + }) +}) + +describe('ghAccountBindingsEqual', () => { + it('compares normalized host/user pairs', () => { + expect( + ghAccountBindingsEqual( + { host: 'github.com', user: 'Alice' }, + { host: 'github.com', user: 'Alice' } + ) + ).toBe(true) + expect( + ghAccountBindingsEqual( + { host: 'github.com', user: 'Alice' }, + { host: 'github.com', user: 'alice' } + ) + ).toBe(false) + }) +}) diff --git a/src/shared/github/account-binding.ts b/src/shared/github/account-binding.ts new file mode 100644 index 00000000000..9400f61445b --- /dev/null +++ b/src/shared/github/account-binding.ts @@ -0,0 +1,65 @@ +/** + * 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' +} diff --git a/src/shared/github/auth-types.ts b/src/shared/github/auth-types.ts index b0ce3fa90e0..a037a2bfaa6 100644 --- a/src/shared/github/auth-types.ts +++ b/src/shared/github/auth-types.ts @@ -2,6 +2,8 @@ * Shared types for `gh auth status` diagnostics surfaced to the renderer. */ +import type { GhAccountBinding } from './account-binding' + export type GhAuthAccount = { host: string user: string @@ -44,3 +46,26 @@ export type GhAuthDiagnostic = { /** Whether gh has any account for `requiredHost`; null without host context. */ requiredHostAuthenticated: boolean | null } + +/** Per-runtime support for `gh auth token --user` (gh ≥ 2.40). */ +export type GhMultiAccountCapability = 'supported' | 'unsupported' | 'unknown' + +/** + * Repo-scoped inventory for Settings binding UI. + * Env-token accounts are listed but not bindable; keyring accounts are. + */ +export type GhAccountBindingInventory = { + capability: GhMultiAccountCapability + accounts: GhAuthAccount[] +} + +export type GhAccountBindingValidationError = + | 'gh_multi_account_unsupported' + | 'gh_multi_account_capability_unknown' + | 'gh_bound_account_unavailable' + | 'gh_bound_account_not_keyring' + | 'invalid_binding' + +export type GhAccountBindingValidationResult = + | { ok: true; binding: GhAccountBinding } + | { ok: false; error: GhAccountBindingValidationError; message?: string } diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts index 9e3e53cf7a3..f537b14fa0b 100644 --- a/src/shared/repo-types.ts +++ b/src/shared/repo-types.ts @@ -1,4 +1,5 @@ import type { RepoIcon } from './repo-icon' +import type { GhAccountBinding } from './github/account-binding' import type { GitHubRepositoryIdentity } from './github/pull-request-types' import type { RepoHookSettings } from './orca-yaml-hook-types' import type { ForkSyncMode } from './git-fork-sync' @@ -70,6 +71,12 @@ export type Repo = { * identically to `'auto'`; writers leave it undefined on creation so * existing persisted records stay forward-compatible. */ issueSourcePreference?: IssueSourcePreference + /** + * Per-project gh account binding (host + login only — never a token). + * At spawn, Orca resolves a short-lived token via `gh auth token --user` + * and injects it into that child env only. + */ + ghAccount?: GhAccountBinding /** Controls Orca's fork-default-branch sync offer for repos with upstream metadata. */ forkSyncMode?: ForkSyncMode /** Canonical identity for the repo remote Orca should use for provider-level grouping. */ diff --git a/src/shared/rpc-contract/github-account-binding-params.ts b/src/shared/rpc-contract/github-account-binding-params.ts new file mode 100644 index 00000000000..8e055c645ca --- /dev/null +++ b/src/shared/rpc-contract/github-account-binding-params.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { RepoSelector } from './github-repo-target-params' +import { requiredString } from './rpc-param-primitives' + +export const BindableAccounts = RepoSelector.extend({ + refreshCapability: z.boolean().optional() +}) + +export const ValidateAccountBinding = RepoSelector.extend({ + host: requiredString('Missing host'), + user: requiredString('Missing user') +}) diff --git a/src/shared/rpc-contract/repo-update-params.ts b/src/shared/rpc-contract/repo-update-params.ts index 3eb1477adb2..d4bf49e7992 100644 --- a/src/shared/rpc-contract/repo-update-params.ts +++ b/src/shared/rpc-contract/repo-update-params.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { normalizeRepoSourceControlAiOverrides } from '../source-control-ai' import { normalizeRepoBadgeColor } from '../repo-badge-color' import { sanitizeRepoIcon } from '../repo-icon' +import { normalizeGhAccountBinding } from '../github/account-binding' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences @@ -56,6 +57,19 @@ export function createRepoUpdateSchema { + if (value === undefined) { + return undefined + } + if (value === null) { + return null + } + // Why: malformed bindings must omit the key (IPC deletes); never clear via undefined. + return normalizeGhAccountBinding(value) ?? undefined + }), forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(), externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(), externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 7278e5eda8e..0f3e261ed5c 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -213,6 +213,7 @@ import { GitTargetedRemote, WorktreeSelector as WorktreeSelectorOfGitParams } from './git-params' +import { BindableAccounts, ValidateAccountBinding } from './github-account-binding-params' import { CreateIssue, Issue, IssueComment, UpdateIssue } from './github-issue-params' import { ClearProjectItemField, @@ -818,6 +819,7 @@ export const RPC_PARAMS_BY_METHOD = { 'github.createIssue': CreateIssue, 'github.issue': Issue, 'github.listAssignableUsers': RepoSelector, + 'github.listBindableAccounts': BindableAccounts, 'github.listIssues': IssuesList, 'github.listLabels': RepoSelector, 'github.listWorkItems': WorkItemsList, @@ -858,6 +860,7 @@ export const RPC_PARAMS_BY_METHOD = { 'github.updatePR': UpdatePr, 'github.updatePRState': UpdatePrState, 'github.updatePRTitle': UpdatePrTitle, + 'github.validateAccountBinding': ValidateAccountBinding, 'github.workItem': WorkItem, 'github.workItemByOwnerRepo': WorkItemByOwnerRepo, 'github.workItemDetails': WorkItem,