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
+38
View File
@@ -305,3 +305,41 @@ export function getGitHubScopes() {
}
export const GITHUB_AUTH_FILE = STORAGE_FILE;
export function isGhCliDisabled() {
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
return Boolean(parsed?.ghCliDisabled);
}
} catch {
// ignore
}
return false;
}
export function setGhCliDisabled(disabled) {
ensureStorageDir();
let settings = {};
try {
if (fs.existsSync(SETTINGS_FILE)) {
settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) || {};
}
} catch {
// ignore
}
settings.ghCliDisabled = Boolean(disabled);
const tmpFile = `${SETTINGS_FILE}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(settings, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, SETTINGS_FILE);
try {
fs.chmodSync(SETTINGS_FILE, 0o600);
} catch {
// best-effort
}
}
@@ -0,0 +1,38 @@
import { execFileSync } from 'child_process';
const CACHE_TTL_MS = 30_000;
let cachedToken = null;
let cachedAt = 0;
let hasCachedToken = false;
function fetchGhCliToken() {
try {
const token = execFileSync('gh', ['auth', 'token'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
windowsHide: true,
}).trim();
return token || null;
} catch {
return null;
}
}
export function getGhCliToken() {
const now = Date.now();
if (hasCachedToken && now - cachedAt < CACHE_TTL_MS) {
return cachedToken;
}
const token = fetchGhCliToken();
cachedToken = token;
cachedAt = now;
hasCachedToken = true;
return token;
}
export function clearGhCliTokenCache() {
cachedToken = null;
cachedAt = 0;
hasCachedToken = false;
}
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
const execFileSyncMock = mock(() => '');
mock.module('child_process', () => ({
execFileSync: execFileSyncMock,
}));
const { clearGhCliTokenCache, getGhCliToken } = await import('./gh-cli-credential.js');
describe('gh CLI credential lookup', () => {
beforeEach(() => {
execFileSyncMock.mockReset();
clearGhCliTokenCache();
});
test('hides the subprocess window on Windows', () => {
execFileSyncMock.mockReturnValueOnce('token\n');
expect(getGhCliToken()).toBe('token');
expect(execFileSyncMock).toHaveBeenCalledWith('gh', ['auth', 'token'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
windowsHide: true,
});
});
test('caches unavailable gh CLI result until cache is cleared', () => {
execFileSyncMock.mockImplementation(() => {
throw new Error('gh unavailable');
});
expect(getGhCliToken()).toBeNull();
expect(getGhCliToken()).toBeNull();
expect(execFileSyncMock).toHaveBeenCalledTimes(1);
clearGhCliTokenCache();
expect(getGhCliToken()).toBeNull();
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
});
});
+2
View File
@@ -6,6 +6,8 @@ export {
clearGitHubAuth,
getGitHubClientId,
getGitHubScopes,
isGhCliDisabled,
setGhCliDisabled,
GITHUB_AUTH_FILE,
} from './auth.js';
+5 -3
View File
@@ -1,10 +1,12 @@
import { Octokit } from '@octokit/rest';
import { getGitHubAuth } from './auth.js';
import { getGitHubAuth, isGhCliDisabled } from './auth.js';
import { getGhCliToken } from './gh-cli-credential.js';
export function getOctokitOrNull() {
const auth = getGitHubAuth();
if (!auth?.accessToken) {
const token = auth?.accessToken || (!isGhCliDisabled() ? getGhCliToken() : null);
if (!token) {
return null;
}
return new Octokit({ auth: auth.accessToken });
return new Octokit({ auth: token });
}
+34 -9
View File
@@ -76,16 +76,25 @@ export function registerGitHubRoutes(app) {
app.get('/api/github/auth/status', async (_req, res) => {
try {
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, isGhCliDisabled } = await getGitHubLibraries();
const { getGhCliToken } = await import('./gh-cli-credential.js');
const auth = getGitHubAuth();
const accounts = getGitHubAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts });
}
const ghCliDisabled = isGhCliDisabled();
const ghToken = getGhCliToken();
const usingOwnToken = Boolean(auth?.accessToken);
const buildGhCli = (activeUser = null) => ({
available: ghToken !== null,
disabled: ghCliDisabled,
active: !usingOwnToken && ghToken !== null && !ghCliDisabled,
...(activeUser ? { user: activeUser } : {}),
});
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, accounts });
return res.json({ connected: false, accounts, ghCli: buildGhCli() });
}
let user = null;
@@ -93,19 +102,21 @@ export function registerGitHubRoutes(app) {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (isGitHubAuthInvalid(error)) {
clearGitHubAuth();
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
if (usingOwnToken) clearGitHubAuth();
return res.json({ connected: false, accounts: getGitHubAuthAccounts(), ghCli: buildGhCli() });
}
}
const fallback = auth.user;
const fallback = usingOwnToken ? auth.user : null;
const mergedUser = user || fallback;
const ghIsActive = !usingOwnToken && ghToken !== null && !ghCliDisabled;
return res.json({
connected: true,
user: mergedUser,
scope: auth.scope,
scope: usingOwnToken ? auth.scope : undefined,
accounts,
ghCli: buildGhCli(ghIsActive ? mergedUser : null),
});
} catch (error) {
console.error('Failed to get GitHub auth status:', error);
@@ -113,6 +124,20 @@ export function registerGitHubRoutes(app) {
}
});
app.post('/api/github/auth/gh-cli', async (req, res) => {
try {
const { setGhCliDisabled, isGhCliDisabled } = await getGitHubLibraries();
const { clearGhCliTokenCache } = await import('./gh-cli-credential.js');
const disabled = Boolean(req.body?.disabled);
setGhCliDisabled(disabled);
clearGhCliTokenCache();
return res.json({ disabled: isGhCliDisabled() });
} catch (error) {
console.error('Failed to update gh CLI setting:', error);
return res.status(500).json({ error: error.message || 'Failed to update gh CLI setting' });
}
});
app.post('/api/github/auth/start', async (_req, res) => {
try {
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
+13
View File
@@ -88,6 +88,19 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
return payload;
},
async authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }> {
const response = await runtimeFetch('/api/github/auth/gh-cli', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ disabled }),
});
const payload = await jsonOrNull<{ disabled?: boolean; error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to update gh CLI setting');
}
return { disabled: Boolean(payload.disabled) };
},
async me(): Promise<GitHubUserSummary> {
const response = await runtimeFetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);