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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ export {
|
||||
clearGitHubAuth,
|
||||
getGitHubClientId,
|
||||
getGitHubScopes,
|
||||
isGhCliDisabled,
|
||||
setGhCliDisabled,
|
||||
GITHUB_AUTH_FILE,
|
||||
} from './auth.js';
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user