Fallback to gh CLI credentials if available (#1515)

Adds `gh` CLI as a GitHub credential fallback for users who already have
`gh auth login` configured locally. OpenChamber-owned OAuth credentials
remain the primary source of truth; the `gh` token is only used when no
stored OpenChamber GitHub access token exists and the fallback is not
disabled.

The fallback is implemented as a credential provider only: GitHub features
continue to use the existing Octokit/GitHub API paths for issues, pull
requests, checks, merges, and related operations. The PR does not replace
those endpoints with `gh issue` or `gh pr` CLI commands.

Server changes:
- Add `gh-cli-credential.js` to read `gh auth token` with a bounded timeout.
- Cache the `gh` token lookup for 30 seconds, including negative results,
  to avoid repeated subprocess spawning on status/polling paths.
- Hide the subprocess window on Windows via `windowsHide: true`.
- Clear the gh CLI token cache when the fallback setting changes.
- Update `getOctokitOrNull()` to prefer stored OpenChamber OAuth tokens and
  fall back to the `gh` token only when enabled.
- Add `ghCliDisabled` persistence in the existing settings file with atomic
  writes and `0o600` file permissions.
- Add `POST /api/github/auth/gh-cli` to enable or disable the fallback.
- Extend `/api/github/auth/status` with `ghCli` metadata: availability,
  disabled state, active state, and active user when applicable.

UI/runtime changes:
- Extend `GitHubAuthStatus` and `GitHubAPI` with gh CLI fallback metadata
  and toggle support.
- Add web RuntimeAPI support for toggling the gh CLI fallback through
  `runtimeFetch`, preserving active runtime/remote target behavior.
- Add deterministic VS Code unsupported handling for the gh CLI toggle.
- Update GitHub Settings to show gh CLI availability and active status.
- When gh CLI is the active auth source, show it in the connected account
  card and offer Disable instead of Disconnect.
- Keep Add Account available so users can still connect an OpenChamber OAuth
  account, which then takes priority over gh CLI.
- Add localized gh CLI settings strings across supported settings locales.

Fixes addressed during review:
- Removed unreachable UI branches in the inactive gh CLI card.
- Avoided duplicate and repeated `gh auth token` subprocess calls.
- Hardened settings file permissions for the new persisted flag.
- Routed the gh CLI toggle through the RuntimeAPI/runtimeFetch path instead
  of direct browser `fetch`.
- Added targeted tests for hidden subprocess options and negative-result
  cache behavior.
- Fixed a VS Code webview Response body typing issue that blocked type-check.
This commit is contained in:
Tom Rochette
2026-06-11 18:43:41 +03:00
committed by GitHub
parent 6386b4a404
commit 33e614c76b
18 changed files with 329 additions and 15 deletions
@@ -174,6 +174,30 @@ export const GitHubSettings: React.FC = () => {
};
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling, t]);
const toggleGhCli = React.useCallback(async (disabled: boolean) => {
setIsBusy(true);
try {
if (runtimeGitHub) {
await runtimeGitHub.authSetGhCliDisabled(disabled);
} else {
const response = await runtimeFetch('/api/github/auth/gh-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ disabled }),
});
const body = (await response.json().catch(() => null)) as { error?: string } | null;
if (!response.ok) throw new Error(body?.error || response.statusText);
}
toast.success(disabled ? t('settings.github.page.toast.ghCliDisabled') : t('settings.github.page.toast.ghCliEnabled'));
await refreshStatus(runtimeGitHub, { force: true });
} catch (error) {
console.error('Failed to update gh CLI setting:', error);
toast.error(t('settings.github.page.toast.ghCliUpdateFailed'));
} finally {
setIsBusy(false);
}
}, [refreshStatus, runtimeGitHub, t]);
const disconnect = React.useCallback(async () => {
setIsBusy(true);
try {
@@ -239,6 +263,7 @@ export const GitHubSettings: React.FC = () => {
const connected = Boolean(status?.connected);
const user = status?.user;
const accounts = status?.accounts ?? [];
const ghCli = status?.ghCli ?? null;
return (
<div className="mb-8">
@@ -287,12 +312,23 @@ export const GitHubSettings: React.FC = () => {
{t('settings.github.page.label.scopes', { value: status.scope })}
</div>
)}
{ghCli?.active && (
<div className="typography-micro text-muted-foreground/70 mt-0.5">
{t('settings.github.page.ghCli.activeDescription')}
</div>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
{t('settings.github.page.actions.disconnect')}
</Button>
{ghCli?.active ? (
<Button size="sm" variant="outline" onClick={() => toggleGhCli(true)} disabled={isBusy} className={cn(isMobile ? "w-full" : undefined)}>
{t('settings.github.page.ghCli.actions.disable')}
</Button>
) : (
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
{t('settings.github.page.actions.disconnect')}
</Button>
)}
</div>
) : (
<div className="flex items-center justify-between gap-4 px-4 py-4">
@@ -412,6 +448,41 @@ export const GitHubSettings: React.FC = () => {
</div>
</div>
)}
{ghCli?.available && !ghCli?.active && (
<div className="mt-6">
<h3 className="typography-ui-header font-semibold text-foreground mb-3 px-1">
{t('settings.github.page.ghCli.title')}
</h3>
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden">
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
<Icon name="terminal" className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className={cn("typography-meta text-muted-foreground", ghCli.disabled ? "opacity-60" : undefined)}>
{ghCli.disabled
? t('settings.github.page.ghCli.disabledDescription')
: t('settings.github.page.ghCli.fallbackDescription')}
</div>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => toggleGhCli(!ghCli.disabled)}
disabled={isBusy}
className={cn(isMobile ? "w-full" : undefined)}
>
{ghCli.disabled
? t('settings.github.page.ghCli.actions.enable')
: t('settings.github.page.ghCli.actions.disable')}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
+7
View File
@@ -1039,6 +1039,12 @@ export type GitHubAuthStatus = {
user?: GitHubUserSummary | null;
scope?: string;
accounts?: GitHubAuthAccount[];
ghCli?: {
available: boolean;
disabled: boolean;
active: boolean;
user?: GitHubUserSummary | null;
} | null;
};
export type GitHubAuthAccount = {
@@ -1068,6 +1074,7 @@ export interface GitHubAPI {
authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete>;
authDisconnect(): Promise<{ removed: boolean }>;
authActivate(accountId: string): Promise<GitHubAuthStatus>;
authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>;
me?(): Promise<GitHubUserSummary>;
prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus>;
@@ -1407,6 +1407,15 @@ export const settingsDict = {
'settings.github.page.toast.disconnectFailed': 'Failed to disconnect GitHub',
'settings.github.page.toast.accountSwitched': 'GitHub account switched',
'settings.github.page.toast.accountSwitchFailed': 'Failed to switch GitHub account',
'settings.github.page.ghCli.title': 'gh CLI',
'settings.github.page.ghCli.activeDescription': 'Authenticated via the gh CLI',
'settings.github.page.ghCli.fallbackDescription': 'Available as fallback if OpenChamber auth is removed',
'settings.github.page.ghCli.disabledDescription': 'Installed but not used as a fallback',
'settings.github.page.ghCli.actions.disable': 'Disable',
'settings.github.page.ghCli.actions.enable': 'Enable',
'settings.github.page.toast.ghCliEnabled': 'gh CLI fallback enabled',
'settings.github.page.toast.ghCliDisabled': 'gh CLI fallback disabled',
'settings.github.page.toast.ghCliUpdateFailed': 'Failed to update gh CLI setting',
'settings.notifications.page.delivery.title': 'Notification Delivery',
'settings.notifications.page.delivery.enableAria': 'Enable notifications',
'settings.notifications.page.delivery.enableLabel': 'Enable Notifications',
@@ -1374,6 +1374,15 @@ export const settingsDict = {
"settings.github.page.toast.disconnectFailed": "No se pudo desconectar de GitHub",
"settings.github.page.toast.accountSwitched": "Cuenta de GitHub cambiada",
"settings.github.page.toast.accountSwitchFailed": "No se pudo cambiar de cuenta de GitHub",
"settings.github.page.ghCli.title": "gh CLI",
"settings.github.page.ghCli.activeDescription": "Autenticado a través de gh CLI",
"settings.github.page.ghCli.fallbackDescription": "Disponible como respaldo si se elimina la autenticación de OpenChamber",
"settings.github.page.ghCli.disabledDescription": "Instalado pero no usado como respaldo",
"settings.github.page.ghCli.actions.disable": "Desactivar",
"settings.github.page.ghCli.actions.enable": "Activar",
"settings.github.page.toast.ghCliEnabled": "Respaldo de gh CLI activado",
"settings.github.page.toast.ghCliDisabled": "Respaldo de gh CLI desactivado",
"settings.github.page.toast.ghCliUpdateFailed": "No se pudo actualizar la configuración de gh CLI",
"settings.notifications.page.delivery.title": "Entrega de notificaciones",
"settings.notifications.page.delivery.enableAria": "Habilitar notificaciones",
"settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones",
@@ -1374,6 +1374,15 @@ export const settingsDict = {
'settings.github.page.toast.disconnectFailed': 'GitHub 연결을 해제하지 못했습니다',
'settings.github.page.toast.accountSwitched': 'GitHub 계정이 전환되었습니다',
'settings.github.page.toast.accountSwitchFailed': 'GitHub 계정을 전환하지 못했습니다',
'settings.github.page.ghCli.title': 'gh CLI',
'settings.github.page.ghCli.activeDescription': 'gh CLI를 통해 인증됨',
'settings.github.page.ghCli.fallbackDescription': 'OpenChamber 인증이 제거된 경우 대체로 사용 가능',
'settings.github.page.ghCli.disabledDescription': '설치되어 있지만 대체로 사용되지 않음',
'settings.github.page.ghCli.actions.disable': '비활성화',
'settings.github.page.ghCli.actions.enable': '활성화',
'settings.github.page.toast.ghCliEnabled': 'gh CLI 대체 활성화됨',
'settings.github.page.toast.ghCliDisabled': 'gh CLI 대체 비활성화됨',
'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 설정을 업데이트하지 못했습니다',
'settings.notifications.page.delivery.title': '알림',
'settings.notifications.page.delivery.enableAria': '알림 활성화',
'settings.notifications.page.delivery.enableLabel': '알림 활성화',
@@ -246,6 +246,15 @@ export const settingsDict = {
'settings.github.page.toast.disconnectFailed': 'Nie udało się odłączyć GitHub',
'settings.github.page.toast.disconnected': 'Odłączono od GitHub',
'settings.github.page.toast.startConnectFailed': 'Nie udało się rozpocząć połączenia z GitHub',
'settings.github.page.toast.ghCliEnabled': 'Rezerwa gh CLI włączona',
'settings.github.page.toast.ghCliDisabled': 'Rezerwa gh CLI wyłączona',
'settings.github.page.toast.ghCliUpdateFailed': 'Nie udało się zaktualizować ustawienia gh CLI',
'settings.github.page.ghCli.title': 'gh CLI',
'settings.github.page.ghCli.activeDescription': 'Uwierzytelniono przez gh CLI',
'settings.github.page.ghCli.fallbackDescription': 'Dostępne jako rezerwa, jeśli uwierzytelnienie OpenChamber zostanie usunięte',
'settings.github.page.ghCli.disabledDescription': 'Zainstalowane, ale nieużywane jako rezerwa',
'settings.github.page.ghCli.actions.disable': 'Wyłącz',
'settings.github.page.ghCli.actions.enable': 'Włącz',
'settings.github.page.tooltip.connectAccount': 'Połącz konto GitHub, aby korzystać z przepływów pracy dla PR i Issue w aplikacji.',
'settings.magicPrompts.page.actions.resetAllOverrides': 'Zresetuj wszystkie nadpisania',
'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych',
@@ -1374,6 +1374,15 @@ export const settingsDict = {
"settings.github.page.toast.disconnectFailed": "Não foi possível desconectar de GitHub",
"settings.github.page.toast.accountSwitched": "Conta de GitHub cambiada",
"settings.github.page.toast.accountSwitchFailed": "Não foi possível mudar de conta de GitHub",
"settings.github.page.ghCli.title": "gh CLI",
"settings.github.page.ghCli.activeDescription": "Autenticado via gh CLI",
"settings.github.page.ghCli.fallbackDescription": "Disponível como alternativa se a autenticação do OpenChamber for removida",
"settings.github.page.ghCli.disabledDescription": "Instalado, mas não usado como alternativa",
"settings.github.page.ghCli.actions.disable": "Desativar",
"settings.github.page.ghCli.actions.enable": "Ativar",
"settings.github.page.toast.ghCliEnabled": "Alternativa gh CLI ativada",
"settings.github.page.toast.ghCliDisabled": "Alternativa gh CLI desativada",
"settings.github.page.toast.ghCliUpdateFailed": "Falha ao atualizar configuração do gh CLI",
"settings.notifications.page.delivery.title": "Entrega de notificações",
"settings.notifications.page.delivery.enableAria": "Ativar notificações",
"settings.notifications.page.delivery.enableLabel": "Ativar notificações",
@@ -1374,6 +1374,15 @@ export const settingsDict = {
"settings.github.page.toast.disconnectFailed": "Не вдалося відключити GitHub",
"settings.github.page.toast.accountSwitched": "Обліковий запис GitHub змінено",
"settings.github.page.toast.accountSwitchFailed": "Не вдалося змінити обліковий запис GitHub",
"settings.github.page.ghCli.title": "gh CLI",
"settings.github.page.ghCli.activeDescription": "Автентифіковано через gh CLI",
"settings.github.page.ghCli.fallbackDescription": "Доступно як резервний варіант, якщо автентифікацію OpenChamber видалено",
"settings.github.page.ghCli.disabledDescription": "Встановлено, але не використовується як резервний варіант",
"settings.github.page.ghCli.actions.disable": "Вимкнути",
"settings.github.page.ghCli.actions.enable": "Увімкнути",
"settings.github.page.toast.ghCliEnabled": "Резервний варіант gh CLI увімкнено",
"settings.github.page.toast.ghCliDisabled": "Резервний варіант gh CLI вимкнено",
"settings.github.page.toast.ghCliUpdateFailed": "Не вдалося оновити налаштування gh CLI",
"settings.notifications.page.delivery.title": "Доставка сповіщень",
"settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення",
"settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення",
@@ -1374,6 +1374,15 @@ export const settingsDict = {
'settings.github.page.toast.disconnectFailed': '断开 GitHub 失败',
'settings.github.page.toast.accountSwitched': 'GitHub 账号已切换',
'settings.github.page.toast.accountSwitchFailed': '切换 GitHub 账号失败',
'settings.github.page.ghCli.title': 'gh CLI',
'settings.github.page.ghCli.activeDescription': '已通过 gh CLI 认证',
'settings.github.page.ghCli.fallbackDescription': '在 OpenChamber 认证被移除时可作为备用',
'settings.github.page.ghCli.disabledDescription': '已安装但未作为备用使用',
'settings.github.page.ghCli.actions.disable': '禁用',
'settings.github.page.ghCli.actions.enable': '启用',
'settings.github.page.toast.ghCliEnabled': 'gh CLI 备用已启用',
'settings.github.page.toast.ghCliDisabled': 'gh CLI 备用已禁用',
'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 设置失败',
'settings.notifications.page.delivery.title': '通知投递',
'settings.notifications.page.delivery.enableAria': '启用通知',
'settings.notifications.page.delivery.enableLabel': '启用通知',
@@ -1295,6 +1295,15 @@
'settings.github.page.toast.disconnectFailed': '中斷 GitHub 失敗',
'settings.github.page.toast.accountSwitched': 'GitHub 帳號已切換',
'settings.github.page.toast.accountSwitchFailed': '切換 GitHub 帳號失敗',
'settings.github.page.ghCli.title': 'gh CLI',
'settings.github.page.ghCli.activeDescription': '已透過 gh CLI 認證',
'settings.github.page.ghCli.fallbackDescription': '在 OpenChamber 認證被移除時可作為備用',
'settings.github.page.ghCli.disabledDescription': '已安裝但未作為備用使用',
'settings.github.page.ghCli.actions.disable': '停用',
'settings.github.page.ghCli.actions.enable': '啟用',
'settings.github.page.toast.ghCliEnabled': 'gh CLI 備用已啟用',
'settings.github.page.toast.ghCliDisabled': 'gh CLI 備用已停用',
'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 設定失敗',
'settings.notifications.page.delivery.title': '通知傳遞',
'settings.notifications.page.delivery.enableAria': '啟用通知',
'settings.notifications.page.delivery.enableLabel': '啟用通知',