diff --git a/server/modules/providers/list/claude/claude-auth.provider.ts b/server/modules/providers/list/claude/claude-auth.provider.ts new file mode 100644 index 00000000..88e9bbc6 --- /dev/null +++ b/server/modules/providers/list/claude/claude-auth.provider.ts @@ -0,0 +1,126 @@ +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import spawn from 'cross-spawn'; + +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; +import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; + +type ClaudeCredentialsStatus = { + authenticated: boolean; + email: string | null; + method: string | null; + error?: string; +}; + +export class ClaudeAuthProvider implements IProviderAuthRuntime { + /** + * Checks whether the Claude Code CLI is available on this host. + */ + private checkInstalled(): boolean { + const cliPath = process.env.CLAUDE_CLI_PATH || 'claude'; + try { + spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + /** + * Returns Claude installation and credential status using Claude Code's auth priority. + */ + async getStatus(): Promise { + console.log("Checking Claude authentication status...") + const installed = this.checkInstalled(); + + if (!installed) { + return { + installed, + provider: 'claude', + authenticated: false, + email: null, + method: null, + error: 'Claude Code CLI is not installed', + }; + } + + const credentials = await this.checkCredentials(); + + console.log("Credientials status for Claude:", credentials) + return { + installed, + provider: 'claude', + authenticated: credentials.authenticated, + email: credentials.authenticated ? credentials.email || 'Authenticated' : credentials.email, + method: credentials.method, + error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated', + }; + } + + /** + * Reads Claude settings env values that the CLI can use even when the server process env is empty. + */ + private async loadSettingsEnv(): Promise> { + try { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + const content = await readFile(settingsPath, 'utf8'); + const settings = readObjectRecord(JSON.parse(content)); + console.log("Settings env for Claude:", settings) + return readObjectRecord(settings?.env) ?? {}; + } catch { + return {}; + } + } + + /** + * Checks Claude credentials in the same priority order used by Claude Code. + */ + private async checkCredentials(): Promise { + if (process.env.ANTHROPIC_API_KEY?.trim()) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + const settingsEnv = await this.loadSettingsEnv(); + if (readOptionalString(settingsEnv.ANTHROPIC_API_KEY)) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + if (readOptionalString(settingsEnv.ANTHROPIC_AUTH_TOKEN)) { + return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' }; + } + + try { + const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); + const content = await readFile(credPath, 'utf8'); + const creds = readObjectRecord(JSON.parse(content)) ?? {}; + const oauth = readObjectRecord(creds.claudeAiOauth); + const accessToken = readOptionalString(oauth?.accessToken); + + if (accessToken) { + const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : undefined; + const email = readOptionalString(creds.email) ?? readOptionalString(creds.user) ?? null; + if (!expiresAt || Date.now() < expiresAt) { + return { + authenticated: true, + email, + method: 'credentials_file', + }; + } + + return { + authenticated: false, + email, + method: 'credentials_file', + error: 'OAuth token has expired. Please re-authenticate with claude login', + }; + } + + return { authenticated: false, email: null, method: null }; + } catch { + return { authenticated: false, email: null, method: null }; + } + } +} diff --git a/server/modules/providers/list/claude/claude.provider.ts b/server/modules/providers/list/claude/claude.provider.ts index 0b971a93..0cdfaf82 100644 --- a/server/modules/providers/list/claude/claude.provider.ts +++ b/server/modules/providers/list/claude/claude.provider.ts @@ -1,6 +1,8 @@ import { getSessionMessages } from '@/projects.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { ClaudeAuthProvider } from '@/modules/providers/list/claude/claude-auth.provider.js'; import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js'; +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; @@ -55,6 +57,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null { export class ClaudeProvider extends AbstractProvider { readonly mcp = new ClaudeMcpProvider(); + readonly auth: IProviderAuthRuntime = new ClaudeAuthProvider(); constructor() { super('claude'); diff --git a/server/modules/providers/list/codex/codex-auth.provider.ts b/server/modules/providers/list/codex/codex-auth.provider.ts new file mode 100644 index 00000000..1e7790c8 --- /dev/null +++ b/server/modules/providers/list/codex/codex-auth.provider.ts @@ -0,0 +1,100 @@ +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import spawn from 'cross-spawn'; + +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; +import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; + +type CodexCredentialsStatus = { + authenticated: boolean; + email: string | null; + method: string | null; + error?: string; +}; + +export class CodexAuthProvider implements IProviderAuthRuntime { + /** + * Checks whether Codex is available to the server runtime. + */ + private checkInstalled(): boolean { + try { + spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + /** + * Returns Codex SDK availability and credential status. + */ + async getStatus(): Promise { + const installed = this.checkInstalled(); + const credentials = await this.checkCredentials(); + + return { + installed, + provider: 'codex', + authenticated: credentials.authenticated, + email: credentials.email, + method: credentials.method, + error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated', + }; + } + + /** + * Reads Codex auth.json and checks OAuth tokens or an API key fallback. + */ + private async checkCredentials(): Promise { + try { + const authPath = path.join(os.homedir(), '.codex', 'auth.json'); + const content = await readFile(authPath, 'utf8'); + const auth = readObjectRecord(JSON.parse(content)) ?? {}; + const tokens = readObjectRecord(auth.tokens) ?? {}; + const idToken = readOptionalString(tokens.id_token); + const accessToken = readOptionalString(tokens.access_token); + + if (idToken || accessToken) { + return { + authenticated: true, + email: idToken ? this.readEmailFromIdToken(idToken) : 'Authenticated', + method: 'credentials_file', + }; + } + + if (readOptionalString(auth.OPENAI_API_KEY)) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + return { authenticated: false, email: null, method: null, error: 'No valid tokens found' }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { + authenticated: false, + email: null, + method: null, + error: code === 'ENOENT' ? 'Codex not configured' : error instanceof Error ? error.message : 'Failed to read Codex auth', + }; + } + } + + /** + * Extracts the user email from a Codex id_token when a readable JWT payload exists. + */ + private readEmailFromIdToken(idToken: string): string { + try { + const parts = idToken.split('.'); + if (parts.length >= 2) { + const payload = readObjectRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))); + return readOptionalString(payload?.email) ?? readOptionalString(payload?.user) ?? 'Authenticated'; + } + } catch { + // Fall back to a generic authenticated marker if the token payload is not readable. + } + + return 'Authenticated'; + } +} diff --git a/server/modules/providers/list/codex/codex.provider.ts b/server/modules/providers/list/codex/codex.provider.ts index fad9e1bb..a9a0a4b4 100644 --- a/server/modules/providers/list/codex/codex.provider.ts +++ b/server/modules/providers/list/codex/codex.provider.ts @@ -1,6 +1,8 @@ import { getCodexSessionMessages } from '@/projects.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { CodexAuthProvider } from '@/modules/providers/list/codex/codex-auth.provider.js'; import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js'; +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; @@ -29,6 +31,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null { export class CodexProvider extends AbstractProvider { readonly mcp = new CodexMcpProvider(); + readonly auth: IProviderAuthRuntime = new CodexAuthProvider(); constructor() { super('codex'); diff --git a/server/modules/providers/list/cursor/cursor-auth.provider.ts b/server/modules/providers/list/cursor/cursor-auth.provider.ts new file mode 100644 index 00000000..764bff5e --- /dev/null +++ b/server/modules/providers/list/cursor/cursor-auth.provider.ts @@ -0,0 +1,143 @@ +import spawn from 'cross-spawn'; + +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; + +type CursorLoginStatus = { + authenticated: boolean; + email: string | null; + method: string | null; + error?: string; +}; + +export class CursorAuthProvider implements IProviderAuthRuntime { + /** + * Checks whether the cursor-agent CLI is available on this host. + */ + private checkInstalled(): boolean { + try { + spawn.sync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + /** + * Returns Cursor CLI installation and login status. + */ + async getStatus(): Promise { + const installed = this.checkInstalled(); + + if (!installed) { + return { + installed, + provider: 'cursor', + authenticated: false, + email: null, + method: null, + error: 'Cursor CLI is not installed', + }; + } + + const login = await this.checkCursorLogin(); + + return { + installed, + provider: 'cursor', + authenticated: login.authenticated, + email: login.email, + method: login.method, + error: login.authenticated ? undefined : login.error || 'Not logged in', + }; + } + + /** + * Runs cursor-agent status and parses the login marker from stdout. + */ + private checkCursorLogin(): Promise { + return new Promise((resolve) => { + let processCompleted = false; + let childProcess: ReturnType | undefined; + + const timeout = setTimeout(() => { + if (!processCompleted) { + processCompleted = true; + childProcess?.kill(); + resolve({ + authenticated: false, + email: null, + method: null, + error: 'Command timeout', + }); + } + }, 5000); + + try { + childProcess = spawn('cursor-agent', ['status']); + } catch { + clearTimeout(timeout); + processCompleted = true; + resolve({ + authenticated: false, + email: null, + method: null, + error: 'Cursor CLI not found or not installed', + }); + return; + } + + let stdout = ''; + let stderr = ''; + + childProcess.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + childProcess.on('close', (code) => { + if (processCompleted) { + return; + } + processCompleted = true; + clearTimeout(timeout); + + if (code === 0) { + const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); + if (emailMatch?.[1]) { + resolve({ authenticated: true, email: emailMatch[1], method: 'cli' }); + return; + } + + if (stdout.includes('Logged in')) { + resolve({ authenticated: true, email: 'Logged in', method: 'cli' }); + return; + } + + resolve({ authenticated: false, email: null, method: null, error: 'Not logged in' }); + return; + } + + resolve({ authenticated: false, email: null, method: null, error: stderr || 'Not logged in' }); + }); + + childProcess.on('error', () => { + if (processCompleted) { + return; + } + processCompleted = true; + clearTimeout(timeout); + + resolve({ + authenticated: false, + email: null, + method: null, + error: 'Cursor CLI not found or not installed', + }); + }); + }); + } +} diff --git a/server/modules/providers/list/cursor/cursor.provider.ts b/server/modules/providers/list/cursor/cursor.provider.ts index 254b1e84..2c727574 100644 --- a/server/modules/providers/list/cursor/cursor.provider.ts +++ b/server/modules/providers/list/cursor/cursor.provider.ts @@ -3,7 +3,9 @@ import os from 'node:os'; import path from 'node:path'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { CursorAuthProvider } from '@/modules/providers/list/cursor/cursor-auth.provider.js'; import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js'; +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; @@ -34,6 +36,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null { export class CursorProvider extends AbstractProvider { readonly mcp = new CursorMcpProvider(); + readonly auth: IProviderAuthRuntime = new CursorAuthProvider(); constructor() { super('cursor'); diff --git a/server/modules/providers/list/gemini/gemini-auth.provider.ts b/server/modules/providers/list/gemini/gemini-auth.provider.ts new file mode 100644 index 00000000..637f3bdc --- /dev/null +++ b/server/modules/providers/list/gemini/gemini-auth.provider.ts @@ -0,0 +1,151 @@ +import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import spawn from 'cross-spawn'; + +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; +import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; + +type GeminiCredentialsStatus = { + authenticated: boolean; + email: string | null; + method: string | null; + error?: string; +}; + +export class GeminiAuthProvider implements IProviderAuthRuntime { + /** + * Checks whether the Gemini CLI is available on this host. + */ + private checkInstalled(): boolean { + const cliPath = process.env.GEMINI_PATH || 'gemini'; + try { + spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + /** + * Returns Gemini CLI installation and credential status. + */ + async getStatus(): Promise { + const installed = this.checkInstalled(); + + if (!installed) { + return { + installed, + provider: 'gemini', + authenticated: false, + email: null, + method: null, + error: 'Gemini CLI is not installed', + }; + } + + const credentials = await this.checkCredentials(); + + return { + installed, + provider: 'gemini', + authenticated: credentials.authenticated, + email: credentials.email, + method: credentials.method, + error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated', + }; + } + + /** + * Checks Gemini credentials from API key env vars or local OAuth credential files. + */ + private async checkCredentials(): Promise { + if (process.env.GEMINI_API_KEY?.trim()) { + return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; + } + + try { + const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + const content = await readFile(credsPath, 'utf8'); + const creds = readObjectRecord(JSON.parse(content)) ?? {}; + const accessToken = readOptionalString(creds.access_token); + + if (!accessToken) { + return { + authenticated: false, + email: null, + method: null, + error: 'No valid tokens found in oauth_creds', + }; + } + + const refreshToken = readOptionalString(creds.refresh_token); + const tokenInfo = await this.getTokenInfoEmail(accessToken); + if (tokenInfo.valid) { + return { + authenticated: true, + email: tokenInfo.email || 'OAuth Session', + method: 'credentials_file', + }; + } + + if (!refreshToken) { + return { + authenticated: false, + email: null, + method: 'credentials_file', + error: 'Access token invalid and no refresh token found', + }; + } + + return { + authenticated: true, + email: await this.getActiveAccountEmail() || 'OAuth Session', + method: 'credentials_file', + }; + } catch { + return { + authenticated: false, + email: null, + method: null, + error: 'Gemini CLI not configured', + }; + } + } + + /** + * Validates a Gemini OAuth access token and returns an email when Google reports one. + */ + private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> { + try { + const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`); + if (!tokenRes.ok) { + return { valid: false, email: null }; + } + + const tokenInfo = readObjectRecord(await tokenRes.json()); + return { + valid: true, + email: readOptionalString(tokenInfo?.email) ?? null, + }; + } catch { + return { valid: false, email: null }; + } + } + + /** + * Reads Gemini's active local Google account as an offline fallback for display. + */ + private async getActiveAccountEmail(): Promise { + try { + const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const accContent = await readFile(accPath, 'utf8'); + const accounts = readObjectRecord(JSON.parse(accContent)); + return readOptionalString(accounts?.active) ?? null; + } catch { + return null; + } + } +} diff --git a/server/modules/providers/list/gemini/gemini.provider.ts b/server/modules/providers/list/gemini/gemini.provider.ts index a2017653..78e3f931 100644 --- a/server/modules/providers/list/gemini/gemini.provider.ts +++ b/server/modules/providers/list/gemini/gemini.provider.ts @@ -1,7 +1,9 @@ import sessionManager from '@/sessionManager.js'; import { getGeminiCliSessionMessages } from '@/projects.js'; import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import { GeminiAuthProvider } from '@/modules/providers/list/gemini/gemini-auth.provider.js'; import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js'; +import type { IProviderAuthRuntime } from '@/shared/interfaces.js'; import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js'; @@ -15,6 +17,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null { export class GeminiProvider extends AbstractProvider { readonly mcp = new GeminiMcpProvider(); + readonly auth: IProviderAuthRuntime = new GeminiAuthProvider(); constructor() { super('gemini'); diff --git a/server/modules/providers/services/provider-auth.service.ts b/server/modules/providers/services/provider-auth.service.ts new file mode 100644 index 00000000..26b48e2b --- /dev/null +++ b/server/modules/providers/services/provider-auth.service.ts @@ -0,0 +1,12 @@ +import { providerRegistry } from '@/modules/providers/provider.registry.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; + +export const providerAuthService = { + /** + * Resolves a provider and returns its installation/authentication status. + */ + async getProviderAuthStatus(providerName: string): Promise { + const provider = providerRegistry.resolveProvider(providerName); + return provider.auth.getStatus(); + }, +}; diff --git a/server/modules/providers/shared/base/abstract.provider.ts b/server/modules/providers/shared/base/abstract.provider.ts index dba18dac..6f76af8c 100644 --- a/server/modules/providers/shared/base/abstract.provider.ts +++ b/server/modules/providers/shared/base/abstract.provider.ts @@ -1,4 +1,4 @@ -import type { IProvider, IProviderMcpRuntime } from '@/shared/interfaces.js'; +import type { IProvider, IProviderAuthRuntime, IProviderMcpRuntime } from '@/shared/interfaces.js'; import type { FetchHistoryOptions, FetchHistoryResult, @@ -9,12 +9,14 @@ import type { /** * Shared provider base. * - * Concrete providers must implement message normalization and history loading - * because both behaviors depend on each provider's native SDK/CLI event format. + * Concrete providers must expose auth/MCP runtimes and implement message + * normalization/history loading because those behaviors depend on native + * SDK/CLI formats. */ export abstract class AbstractProvider implements IProvider { readonly id: LLMProvider; abstract readonly mcp: IProviderMcpRuntime; + abstract readonly auth: IProviderAuthRuntime; protected constructor(id: LLMProvider) { this.id = id; diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index 78ffa30b..4a15c1d3 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -1,434 +1,35 @@ import express from 'express'; -import { spawn } from 'child_process'; -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; + +import { providerAuthService } from '../modules/providers/services/provider-auth.service.js'; const router = express.Router(); -router.get('/claude/status', async (req, res) => { - try { - const credentialsResult = await checkClaudeCredentials(); - - if (credentialsResult.authenticated) { - return res.json({ - authenticated: true, - email: credentialsResult.email || 'Authenticated', - method: credentialsResult.method // 'api_key' or 'credentials_file' - }); - } - - return res.json({ - authenticated: false, - email: null, - method: null, - error: credentialsResult.error || 'Not authenticated' - }); - - } catch (error) { - console.error('Error checking Claude auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - method: null, - error: error.message - }); - } -}); - -router.get('/cursor/status', async (req, res) => { - try { - const result = await checkCursorStatus(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Cursor auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -router.get('/codex/status', async (req, res) => { - try { - const result = await checkCodexCredentials(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Codex auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -router.get('/gemini/status', async (req, res) => { - try { - const result = await checkGeminiCredentials(); - - res.json({ - authenticated: result.authenticated, - email: result.email, - error: result.error - }); - - } catch (error) { - console.error('Error checking Gemini auth status:', error); - res.status(500).json({ - authenticated: false, - email: null, - error: error.message - }); - } -}); - -async function loadClaudeSettingsEnv() { - try { - const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); - const content = await fs.readFile(settingsPath, 'utf8'); - const settings = JSON.parse(content); - - if (settings?.env && typeof settings.env === 'object') { - return settings.env; - } - } catch (error) { - // Ignore missing or malformed settings and fall back to other auth sources. - } - - return {}; -} - /** - * Checks Claude authentication credentials using two methods with priority order: - * - * Priority 1: ANTHROPIC_API_KEY environment variable - * Priority 1b: ~/.claude/settings.json env values - * Priority 2: ~/.claude/.credentials.json OAuth tokens - * - * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions. - * This matching behavior ensures consistency with how the SDK authenticates. - * - * References: - * - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code - * "Claude Code prioritizes environment variable API keys over authenticated subscriptions" - * - https://platform.claude.com/docs/en/agent-sdk/overview - * SDK authentication documentation - * - * @returns {Promise} Authentication status with { authenticated, email, method } - * - authenticated: boolean indicating if valid credentials exist - * - email: user email or auth method identifier - * - method: 'api_key' for env var, 'credentials_file' for OAuth tokens + * Creates a status route handler for one provider while preserving the existing + * /api/cli//status endpoint shape. */ -async function checkClaudeCredentials() { - // Priority 1: Check for ANTHROPIC_API_KEY environment variable - // The SDK checks this first and uses it if present, even if OAuth tokens exist. - // When set, API calls are charged via pay-as-you-go rates instead of subscription. - if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth', - method: 'api_key' - }; - } - - // Priority 1b: Check ~/.claude/settings.json env values. - // Claude Code can read proxy/auth values from settings.json even when the - // CloudCLI server process itself was not started with those env vars exported. - const settingsEnv = await loadClaudeSettingsEnv(); - - if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth', - method: 'api_key' - }; - } - - if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) { - return { - authenticated: true, - email: 'Configured via settings.json', - method: 'api_key' - }; - } - - // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens - // This is the standard authentication method used by Claude CLI after running - // 'claude /login' or 'claude setup-token' commands. - try { - const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); - const content = await fs.readFile(credPath, 'utf8'); - const creds = JSON.parse(content); - - const oauth = creds.claudeAiOauth; - if (oauth && oauth.accessToken) { - const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt; - - if (!isExpired) { - return { - authenticated: true, - email: creds.email || creds.user || null, - method: 'credentials_file' - }; - } - } - - return { - authenticated: false, - email: null, - method: null - }; - } catch (error) { - return { - authenticated: false, - email: null, - method: null - }; - } -} - -function checkCursorStatus() { - return new Promise((resolve) => { - let processCompleted = false; - - const timeout = setTimeout(() => { - if (!processCompleted) { - processCompleted = true; - if (childProcess) { - childProcess.kill(); - } - resolve({ - authenticated: false, - email: null, - error: 'Command timeout' - }); - } - }, 5000); - - let childProcess; +function createProviderStatusHandler(providerName) { + return async (req, res) => { try { - childProcess = spawn('cursor-agent', ['status']); - } catch (err) { - clearTimeout(timeout); - processCompleted = true; - resolve({ + const status = await providerAuthService.getProviderAuthStatus(providerName); + return res.json(status); + } catch (error) { + console.error(`Error checking ${providerName} auth status:`, error); + return res.status(500).json({ + installed: false, + provider: providerName, authenticated: false, email: null, - error: 'Cursor CLI not found or not installed' + method: null, + error: error instanceof Error ? error.message : 'Failed to check provider auth status', }); - return; } - - let stdout = ''; - let stderr = ''; - - childProcess.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - childProcess.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - childProcess.on('close', (code) => { - if (processCompleted) return; - processCompleted = true; - clearTimeout(timeout); - - if (code === 0) { - const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); - - if (emailMatch) { - resolve({ - authenticated: true, - email: emailMatch[1], - output: stdout - }); - } else if (stdout.includes('Logged in')) { - resolve({ - authenticated: true, - email: 'Logged in', - output: stdout - }); - } else { - resolve({ - authenticated: false, - email: null, - error: 'Not logged in' - }); - } - } else { - resolve({ - authenticated: false, - email: null, - error: stderr || 'Not logged in' - }); - } - }); - - childProcess.on('error', (err) => { - if (processCompleted) return; - processCompleted = true; - clearTimeout(timeout); - - resolve({ - authenticated: false, - email: null, - error: 'Cursor CLI not found or not installed' - }); - }); - }); + }; } -async function checkCodexCredentials() { - try { - const authPath = path.join(os.homedir(), '.codex', 'auth.json'); - const content = await fs.readFile(authPath, 'utf8'); - const auth = JSON.parse(content); - - // Tokens are nested under 'tokens' key - const tokens = auth.tokens || {}; - - // Check for valid tokens (id_token or access_token) - if (tokens.id_token || tokens.access_token) { - // Try to extract email from id_token JWT payload - let email = 'Authenticated'; - if (tokens.id_token) { - try { - // JWT is base64url encoded: header.payload.signature - const parts = tokens.id_token.split('.'); - if (parts.length >= 2) { - // Decode the payload (second part) - const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); - email = payload.email || payload.user || 'Authenticated'; - } - } catch { - // If JWT decoding fails, use fallback - email = 'Authenticated'; - } - } - - return { - authenticated: true, - email - }; - } - - // Also check for OPENAI_API_KEY as fallback auth method - if (auth.OPENAI_API_KEY) { - return { - authenticated: true, - email: 'API Key Auth' - }; - } - - return { - authenticated: false, - email: null, - error: 'No valid tokens found' - }; - } catch (error) { - if (error.code === 'ENOENT') { - return { - authenticated: false, - email: null, - error: 'Codex not configured' - }; - } - return { - authenticated: false, - email: null, - error: error.message - }; - } -} - -async function checkGeminiCredentials() { - if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) { - return { - authenticated: true, - email: 'API Key Auth' - }; - } - - try { - const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); - const content = await fs.readFile(credsPath, 'utf8'); - const creds = JSON.parse(content); - - if (creds.access_token) { - let email = 'OAuth Session'; - - try { - // Validate token against Google API - const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`); - if (tokenRes.ok) { - const tokenInfo = await tokenRes.json(); - if (tokenInfo.email) { - email = tokenInfo.email; - } - } else if (!creds.refresh_token) { - // Token invalid and no refresh token available - return { - authenticated: false, - email: null, - error: 'Access token invalid and no refresh token found' - }; - } else { - // Token might be expired but we have a refresh token, so CLI will refresh it - try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); - const accContent = await fs.readFile(accPath, 'utf8'); - const accounts = JSON.parse(accContent); - if (accounts.active) { - email = accounts.active; - } - } catch (e) { } - } - } catch (e) { - // Network error, fallback to checking local accounts file - try { - const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); - const accContent = await fs.readFile(accPath, 'utf8'); - const accounts = JSON.parse(accContent); - if (accounts.active) { - email = accounts.active; - } - } catch (err) { } - } - - return { - authenticated: true, - email: email - }; - } - - return { - authenticated: false, - email: null, - error: 'No valid tokens found in oauth_creds' - }; - } catch (error) { - return { - authenticated: false, - email: null, - error: 'Gemini CLI not configured' - }; - } -} +router.get('/claude/status', createProviderStatusHandler('claude')); +router.get('/cursor/status', createProviderStatusHandler('cursor')); +router.get('/codex/status', createProviderStatusHandler('codex')); +router.get('/gemini/status', createProviderStatusHandler('gemini')); export default router; diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index 2bc3d7da..9f3cf80d 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -5,10 +5,36 @@ import type { McpScope, McpTransport, NormalizedMessage, + ProviderAuthStatus, ProviderMcpServer, UpsertProviderMcpServerInput, } from '@/shared/types.js'; +/** + * Main provider contract for CLI and SDK integrations. + * + * Each concrete provider owns its MCP/auth runtimes plus the provider-specific + * logic for converting native events/history into the app's normalized shape. + */ +export interface IProvider { + readonly id: LLMProvider; + readonly mcp: IProviderMcpRuntime; + readonly auth: IProviderAuthRuntime; + + normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[]; + fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise; +} + + +/** + * Auth runtime contract for one provider. + */ +export interface IProviderAuthRuntime { + /** + * Checks whether the provider runtime is installed and has usable credentials. + */ + getStatus(): Promise; +} /** * MCP runtime contract for one provider. @@ -32,17 +58,3 @@ export interface IProviderMcpRuntime { error?: string; }>; } - -/** - * Main provider contract for CLI and SDK integrations. - * - * Each concrete provider owns its MCP runtime plus the provider-specific logic - * for converting native events/history into the app's normalized message shape. - */ -export interface IProvider { - readonly id: LLMProvider; - readonly mcp: IProviderMcpRuntime; - - normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[]; - fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise; -} diff --git a/server/shared/types.ts b/server/shared/types.ts index 2c24ece3..86de0f71 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -155,3 +155,25 @@ export type UpsertProviderMcpServerInput = { bearerTokenEnvVar?: string; envHttpHeaders?: Record; }; + +// --------------------------------------------------------------------------------------------- + +// -------------------- Provider auth status types -------------------- +/** + * Result of a provider status check (installation + authentication). + * + * installed - Whether the provider's CLI/SDK is available + * provider - Provider id the status belongs to + * authenticated - Whether valid credentials exist + * email - User email or auth method identifier + * method - Auth method (e.g. 'api_key', 'credentials_file') + * [error] - Error message if not installed or not authenticated + */ +export type ProviderAuthStatus = { + installed: boolean; + provider: LLMProvider; + authenticated: boolean; + email: string | null; + method: string | null; + error?: string; +};