From a9d778e3fb0422e737bae8889518779b42ff19fb Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Wed, 8 Apr 2026 18:45:39 +0300 Subject: [PATCH] refactor: make cli-auth part of the providers class --- .../modules/ai-runtime/ai-runtime.routes.ts | 10 + .../providers/base/abstract.provider.ts | 2 + .../providers/claude/claude-auth.runtime.ts | 120 +++++ .../providers/claude/claude.provider.ts | 3 + .../providers/codex/codex-auth.runtime.ts | 88 ++++ .../providers/codex/codex.provider.ts | 3 + .../providers/cursor/cursor-auth.runtime.ts | 126 +++++ .../providers/cursor/cursor.provider.ts | 3 + .../providers/gemini/gemini-auth.runtime.ts | 115 +++++ .../providers/gemini/gemini.provider.ts | 3 + .../ai-runtime/services/auth.service.ts | 12 + .../modules/ai-runtime/types/auth.types.ts | 19 + server/src/modules/ai-runtime/types/index.ts | 1 + .../ai-runtime/types/provider.types.ts | 2 + server/src/modules/cli-auth/.gitkeep | 1 - .../src/modules/cli-auth/cli-auth.routes.js | 434 ------------------ server/src/runner.ts | 5 - src/App.tsx | 140 +----- src/components/onboarding/view/Onboarding.tsx | 35 +- .../refactored/chat/view/ChatInterface.tsx | 20 - .../settings/constants/constants.ts | 8 +- .../settings/hooks/useSettingsController.ts | 30 +- 22 files changed, 572 insertions(+), 608 deletions(-) create mode 100644 server/src/modules/ai-runtime/providers/claude/claude-auth.runtime.ts create mode 100644 server/src/modules/ai-runtime/providers/codex/codex-auth.runtime.ts create mode 100644 server/src/modules/ai-runtime/providers/cursor/cursor-auth.runtime.ts create mode 100644 server/src/modules/ai-runtime/providers/gemini/gemini-auth.runtime.ts create mode 100644 server/src/modules/ai-runtime/services/auth.service.ts create mode 100644 server/src/modules/ai-runtime/types/auth.types.ts delete mode 100644 server/src/modules/cli-auth/.gitkeep delete mode 100644 server/src/modules/cli-auth/cli-auth.routes.js delete mode 100644 src/components/refactored/chat/view/ChatInterface.tsx diff --git a/server/src/modules/ai-runtime/ai-runtime.routes.ts b/server/src/modules/ai-runtime/ai-runtime.routes.ts index 106362f2..7edb8ec7 100644 --- a/server/src/modules/ai-runtime/ai-runtime.routes.ts +++ b/server/src/modules/ai-runtime/ai-runtime.routes.ts @@ -4,6 +4,7 @@ import { asyncHandler } from '@/shared/http/async-handler.js'; import { AppError } from '@/shared/utils/app-error.js'; import { createApiErrorResponse, createApiSuccessResponse } from '@/shared/http/api-response.js'; import { llmService } from '@/modules/ai-runtime/services/ai-runtime.service.js'; +import { llmAuthService } from '@/modules/ai-runtime/services/auth.service.js'; import { llmSessionsService } from '@/modules/ai-runtime/services/sessions.service.js'; import { llmMcpService } from '@/modules/ai-runtime/services/mcp.service.js'; import { llmSkillsService } from '@/modules/ai-runtime/services/skills.service.js'; @@ -233,6 +234,15 @@ router.get( }), ); +router.get( + '/providers/:provider/auth/status', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const auth = await llmAuthService.getProviderAuthStatus(provider); + res.json(createApiSuccessResponse({ provider, auth })); + }), +); + router.get( '/providers/:provider/sessions', asyncHandler(async (req: Request, res: Response) => { diff --git a/server/src/modules/ai-runtime/providers/base/abstract.provider.ts b/server/src/modules/ai-runtime/providers/base/abstract.provider.ts index 0ebd8c44..d45a3445 100644 --- a/server/src/modules/ai-runtime/providers/base/abstract.provider.ts +++ b/server/src/modules/ai-runtime/providers/base/abstract.provider.ts @@ -1,5 +1,6 @@ import type { IProvider, + IProviderAuthRuntime, IProviderMcpRuntime, IProviderSessionSynchronizerRuntime, IProviderSkillsRuntime, @@ -25,6 +26,7 @@ export abstract class AbstractProvider implements IProvider { abstract readonly mcp: IProviderMcpRuntime; abstract readonly skills: IProviderSkillsRuntime; abstract readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime; + abstract readonly auth: IProviderAuthRuntime; protected readonly sessions = new Map(); diff --git a/server/src/modules/ai-runtime/providers/claude/claude-auth.runtime.ts b/server/src/modules/ai-runtime/providers/claude/claude-auth.runtime.ts new file mode 100644 index 00000000..93c58495 --- /dev/null +++ b/server/src/modules/ai-runtime/providers/claude/claude-auth.runtime.ts @@ -0,0 +1,120 @@ +import os from 'node:os'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js'; + +type ClaudeCredentialsFile = { + email?: string; + user?: string; + claudeAiOauth?: { + accessToken?: string; + expiresAt?: number | string; + }; +}; + +/** + * Reads auth status for Claude from env/settings and OAuth credentials. + */ +export class ClaudeAuthRuntime implements IProviderAuthRuntime { + async getStatus(): Promise { + try { + if (process.env.ANTHROPIC_API_KEY?.trim()) { + return { + provider: 'claude', + authenticated: true, + email: 'API Key Auth', + method: 'api_key', + }; + } + + const settingsEnv = await this.loadClaudeSettingsEnv(); + if (settingsEnv.ANTHROPIC_API_KEY?.trim()) { + return { + provider: 'claude', + authenticated: true, + email: 'API Key Auth', + method: 'api_key', + }; + } + + if (settingsEnv.ANTHROPIC_AUTH_TOKEN?.trim()) { + return { + provider: 'claude', + authenticated: true, + email: 'Configured via settings.json', + method: 'api_key', + }; + } + + const credentialsPath = path.join(os.homedir(), '.claude', '.credentials.json'); + const content = await readFile(credentialsPath, 'utf8'); + const credentials = JSON.parse(content) as ClaudeCredentialsFile; + const oauth = credentials.claudeAiOauth; + const accessToken = oauth?.accessToken; + + if (accessToken && !this.isExpired(oauth?.expiresAt)) { + return { + provider: 'claude', + authenticated: true, + email: credentials.email ?? credentials.user ?? null, + method: 'credentials_file', + }; + } + + return { + provider: 'claude', + authenticated: false, + email: null, + method: null, + error: 'Not authenticated', + }; + } catch { + return { + provider: 'claude', + authenticated: false, + email: null, + method: null, + error: 'Not authenticated', + }; + } + } + + /** + * Reads optional env values from ~/.claude/settings.json. + */ + private async loadClaudeSettingsEnv(): Promise> { + try { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + const content = await readFile(settingsPath, 'utf8'); + const settings = JSON.parse(content) as { env?: unknown }; + if (!settings.env || typeof settings.env !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(settings.env as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ); + } catch { + return {}; + } + } + + /** + * Returns true when an OAuth expiration timestamp is in the past. + */ + private isExpired(expiresAt: number | string | undefined): boolean { + if (expiresAt === undefined) { + return false; + } + + if (typeof expiresAt === 'number') { + return Date.now() >= expiresAt; + } + + const numeric = Number.parseInt(expiresAt, 10); + return Number.isFinite(numeric) ? Date.now() >= numeric : false; + } +} diff --git a/server/src/modules/ai-runtime/providers/claude/claude.provider.ts b/server/src/modules/ai-runtime/providers/claude/claude.provider.ts index f2a414d5..3c916b6e 100644 --- a/server/src/modules/ai-runtime/providers/claude/claude.provider.ts +++ b/server/src/modules/ai-runtime/providers/claude/claude.provider.ts @@ -9,6 +9,7 @@ import { readFile } from 'node:fs/promises'; import { BaseSdkProvider } from '@/modules/ai-runtime/providers/base/base-sdk.provider.js'; import type { + IProviderAuthRuntime, IProviderMcpRuntime, IProviderSessionSynchronizerRuntime, IProviderSkillsRuntime, @@ -18,6 +19,7 @@ import type { StartSessionInput, } from '@/modules/ai-runtime/types/index.js'; import { ClaudeMcpRuntime } from '@/modules/ai-runtime/providers/claude/claude-mcp.runtime.js'; +import { ClaudeAuthRuntime } from '@/modules/ai-runtime/providers/claude/claude-auth.runtime.js'; import { ClaudeSkillsRuntime } from '@/modules/ai-runtime/providers/claude/claude-skills.runtime.js'; import { ClaudeSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/claude/claude-session-synchronizer.runtime.js'; @@ -75,6 +77,7 @@ const readString = (value: unknown): string | undefined => { * Claude SDK provider implementation. */ export class ClaudeProvider extends BaseSdkProvider { + readonly auth: IProviderAuthRuntime = new ClaudeAuthRuntime(); readonly mcp: IProviderMcpRuntime = new ClaudeMcpRuntime(); readonly skills: IProviderSkillsRuntime = new ClaudeSkillsRuntime(); readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new ClaudeSessionSynchronizerRuntime(); diff --git a/server/src/modules/ai-runtime/providers/codex/codex-auth.runtime.ts b/server/src/modules/ai-runtime/providers/codex/codex-auth.runtime.ts new file mode 100644 index 00000000..3524fa9e --- /dev/null +++ b/server/src/modules/ai-runtime/providers/codex/codex-auth.runtime.ts @@ -0,0 +1,88 @@ +import os from 'node:os'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js'; + +type CodexAuthFile = { + OPENAI_API_KEY?: string; + tokens?: { + id_token?: string; + access_token?: string; + }; +}; + +/** + * Reads auth status from ~/.codex/auth.json. + */ +export class CodexAuthRuntime implements IProviderAuthRuntime { + async getStatus(): Promise { + try { + const authPath = path.join(os.homedir(), '.codex', 'auth.json'); + const content = await readFile(authPath, 'utf8'); + const auth = JSON.parse(content) as CodexAuthFile; + const tokens = auth.tokens ?? {}; + + if (tokens.id_token || tokens.access_token) { + return { + provider: 'codex', + authenticated: true, + email: this.extractEmail(tokens.id_token), + method: 'token_file', + }; + } + + if (auth.OPENAI_API_KEY?.trim()) { + return { + provider: 'codex', + authenticated: true, + email: 'API Key Auth', + method: 'api_key', + }; + } + + return { + provider: 'codex', + authenticated: false, + email: null, + method: null, + error: 'No valid tokens found', + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + return { + provider: 'codex', + authenticated: false, + email: null, + method: null, + error: code === 'ENOENT' + ? 'Codex not configured' + : (error instanceof Error ? error.message : 'Failed to read Codex auth state'), + }; + } + } + + /** + * Best-effort id_token email extraction from JWT payload. + */ + private extractEmail(idToken: string | undefined): string { + if (!idToken) { + return 'Authenticated'; + } + + try { + const parts = idToken.split('.'); + if (parts.length < 2) { + return 'Authenticated'; + } + + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as { + email?: string; + user?: string; + }; + return payload.email ?? payload.user ?? 'Authenticated'; + } catch { + return 'Authenticated'; + } + } +} diff --git a/server/src/modules/ai-runtime/providers/codex/codex.provider.ts b/server/src/modules/ai-runtime/providers/codex/codex.provider.ts index cb885ab3..33875033 100644 --- a/server/src/modules/ai-runtime/providers/codex/codex.provider.ts +++ b/server/src/modules/ai-runtime/providers/codex/codex.provider.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'; import { BaseSdkProvider } from '@/modules/ai-runtime/providers/base/base-sdk.provider.js'; import type { + IProviderAuthRuntime, IProviderMcpRuntime, IProviderSessionSynchronizerRuntime, IProviderSkillsRuntime, @@ -12,6 +13,7 @@ import type { StartSessionInput, } from '@/modules/ai-runtime/types/index.js'; import { CodexMcpRuntime } from '@/modules/ai-runtime/providers/codex/codex-mcp.runtime.js'; +import { CodexAuthRuntime } from '@/modules/ai-runtime/providers/codex/codex-auth.runtime.js'; import { CodexSkillsRuntime } from '@/modules/ai-runtime/providers/codex/codex-skills.runtime.js'; import { CodexSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/codex/codex-session-synchronizer.runtime.js'; import { AppError } from '@/shared/utils/app-error.js'; @@ -67,6 +69,7 @@ type CodexSdkModule = { * Codex SDK provider implementation. */ export class CodexProvider extends BaseSdkProvider { + readonly auth: IProviderAuthRuntime = new CodexAuthRuntime(); readonly mcp: IProviderMcpRuntime = new CodexMcpRuntime(); readonly skills: IProviderSkillsRuntime = new CodexSkillsRuntime(); readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new CodexSessionSynchronizerRuntime(); diff --git a/server/src/modules/ai-runtime/providers/cursor/cursor-auth.runtime.ts b/server/src/modules/ai-runtime/providers/cursor/cursor-auth.runtime.ts new file mode 100644 index 00000000..89161a0d --- /dev/null +++ b/server/src/modules/ai-runtime/providers/cursor/cursor-auth.runtime.ts @@ -0,0 +1,126 @@ +import spawn from 'cross-spawn'; + +import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js'; + +const CURSOR_STATUS_TIMEOUT_MS = 5_000; + +/** + * Reads auth status from `cursor-agent status`. + */ +export class CursorAuthRuntime implements IProviderAuthRuntime { + async getStatus(): Promise { + return new Promise((resolve) => { + let completed = false; + let childProcess: ReturnType | null = null; + const timeout = setTimeout(() => { + if (completed) { + return; + } + + completed = true; + if (childProcess) { + childProcess.kill(); + } + + resolve({ + provider: 'cursor', + authenticated: false, + email: null, + method: null, + error: 'Command timeout', + }); + }, CURSOR_STATUS_TIMEOUT_MS); + + try { + childProcess = spawn('cursor-agent', ['status']); + } catch { + clearTimeout(timeout); + completed = true; + resolve({ + provider: 'cursor', + authenticated: false, + email: null, + method: null, + error: 'Cursor CLI not found or not installed', + }); + return; + } + + let stdout = ''; + let stderr = ''; + + childProcess.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + childProcess.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + childProcess.on('close', (code) => { + if (completed) { + return; + } + + completed = 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({ + provider: 'cursor', + authenticated: true, + email: emailMatch[1], + method: null, + }); + return; + } + + if (stdout.includes('Logged in')) { + resolve({ + provider: 'cursor', + authenticated: true, + email: 'Logged in', + method: null, + }); + return; + } + + resolve({ + provider: 'cursor', + authenticated: false, + email: null, + method: null, + error: 'Not logged in', + }); + return; + } + + resolve({ + provider: 'cursor', + authenticated: false, + email: null, + method: null, + error: stderr.trim() || 'Not logged in', + }); + }); + + childProcess.on('error', () => { + if (completed) { + return; + } + + completed = true; + clearTimeout(timeout); + resolve({ + provider: 'cursor', + authenticated: false, + email: null, + method: null, + error: 'Cursor CLI not found or not installed', + }); + }); + }); + } +} diff --git a/server/src/modules/ai-runtime/providers/cursor/cursor.provider.ts b/server/src/modules/ai-runtime/providers/cursor/cursor.provider.ts index ed2c4864..672a9ef0 100644 --- a/server/src/modules/ai-runtime/providers/cursor/cursor.provider.ts +++ b/server/src/modules/ai-runtime/providers/cursor/cursor.provider.ts @@ -1,5 +1,6 @@ import { BaseCliProvider } from '@/modules/ai-runtime/providers/base/base-cli.provider.js'; import type { + IProviderAuthRuntime, IProviderMcpRuntime, IProviderSessionSynchronizerRuntime, IProviderSkillsRuntime, @@ -7,6 +8,7 @@ import type { StartSessionInput, } from '@/modules/ai-runtime/types/index.js'; import { CursorMcpRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-mcp.runtime.js'; +import { CursorAuthRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-auth.runtime.js'; import { CursorSkillsRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-skills.runtime.js'; import { CursorSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/cursor/cursor-session-synchronizer.runtime.js'; @@ -23,6 +25,7 @@ const ANSI_REGEX = * Cursor CLI provider implementation. */ export class CursorProvider extends BaseCliProvider { + readonly auth: IProviderAuthRuntime = new CursorAuthRuntime(); readonly mcp: IProviderMcpRuntime = new CursorMcpRuntime(); readonly skills: IProviderSkillsRuntime = new CursorSkillsRuntime(); readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new CursorSessionSynchronizerRuntime(); diff --git a/server/src/modules/ai-runtime/providers/gemini/gemini-auth.runtime.ts b/server/src/modules/ai-runtime/providers/gemini/gemini-auth.runtime.ts new file mode 100644 index 00000000..7fe9d975 --- /dev/null +++ b/server/src/modules/ai-runtime/providers/gemini/gemini-auth.runtime.ts @@ -0,0 +1,115 @@ +import os from 'node:os'; +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; + +import type { IProviderAuthRuntime, ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js'; + +type GeminiOauthCreds = { + access_token?: string; + refresh_token?: string; +}; + +/** + * Reads auth status from env and Gemini OAuth files. + */ +export class GeminiAuthRuntime implements IProviderAuthRuntime { + async getStatus(): Promise { + if (process.env.GEMINI_API_KEY?.trim()) { + return { + provider: 'gemini', + 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 = JSON.parse(content) as GeminiOauthCreds; + if (!creds.access_token) { + return { + provider: 'gemini', + authenticated: false, + email: null, + method: null, + error: 'No valid tokens found in oauth_creds', + }; + } + + const validated = await this.resolveEmailFromAccessToken(creds.access_token); + if (validated.email) { + return { + provider: 'gemini', + authenticated: true, + email: validated.email, + method: 'oauth', + }; + } + + if (!validated.tokenValid && !creds.refresh_token) { + return { + provider: 'gemini', + authenticated: false, + email: null, + method: null, + error: 'Access token invalid and no refresh token found', + }; + } + + const fallbackEmail = await this.readActiveGoogleAccountEmail(); + return { + provider: 'gemini', + authenticated: true, + email: fallbackEmail ?? 'OAuth Session', + method: 'oauth', + }; + } catch { + return { + provider: 'gemini', + authenticated: false, + email: null, + method: null, + error: 'Gemini CLI not configured', + }; + } + } + + /** + * Validates token and extracts email via Google's tokeninfo endpoint. + */ + private async resolveEmailFromAccessToken( + accessToken: string, + ): Promise<{ tokenValid: boolean; email: string | null }> { + try { + const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`); + if (!response.ok) { + return { tokenValid: false, email: null }; + } + + const tokenInfo = await response.json() as { email?: string }; + return { + tokenValid: true, + email: tokenInfo.email ?? null, + }; + } catch { + return { tokenValid: false, email: null }; + } + } + + /** + * Reads active Google account email from ~/.gemini/google_accounts.json. + */ + private async readActiveGoogleAccountEmail(): Promise { + try { + const accountsPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); + const content = await readFile(accountsPath, 'utf8'); + const accounts = JSON.parse(content) as { active?: string }; + return typeof accounts.active === 'string' && accounts.active.trim() + ? accounts.active + : null; + } catch { + return null; + } + } +} diff --git a/server/src/modules/ai-runtime/providers/gemini/gemini.provider.ts b/server/src/modules/ai-runtime/providers/gemini/gemini.provider.ts index 630909ea..a95e6748 100644 --- a/server/src/modules/ai-runtime/providers/gemini/gemini.provider.ts +++ b/server/src/modules/ai-runtime/providers/gemini/gemini.provider.ts @@ -1,5 +1,6 @@ import { BaseCliProvider } from '@/modules/ai-runtime/providers/base/base-cli.provider.js'; import type { + IProviderAuthRuntime, IProviderMcpRuntime, IProviderSessionSynchronizerRuntime, IProviderSkillsRuntime, @@ -7,6 +8,7 @@ import type { StartSessionInput, } from '@/modules/ai-runtime/types/index.js'; import { GeminiMcpRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-mcp.runtime.js'; +import { GeminiAuthRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-auth.runtime.js'; import { GeminiSkillsRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-skills.runtime.js'; import { GeminiSessionSynchronizerRuntime } from '@/modules/ai-runtime/providers/gemini/gemini-session-synchronizer.runtime.js'; @@ -31,6 +33,7 @@ const GEMINI_MODELS: ProviderModel[] = [ * Gemini CLI provider implementation. */ export class GeminiProvider extends BaseCliProvider { + readonly auth: IProviderAuthRuntime = new GeminiAuthRuntime(); readonly mcp: IProviderMcpRuntime = new GeminiMcpRuntime(); readonly skills: IProviderSkillsRuntime = new GeminiSkillsRuntime(); readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime = new GeminiSessionSynchronizerRuntime(); diff --git a/server/src/modules/ai-runtime/services/auth.service.ts b/server/src/modules/ai-runtime/services/auth.service.ts new file mode 100644 index 00000000..25fb7a23 --- /dev/null +++ b/server/src/modules/ai-runtime/services/auth.service.ts @@ -0,0 +1,12 @@ +import { llmProviderRegistry } from '@/modules/ai-runtime/ai-runtime.registry.js'; +import type { ProviderAuthStatus } from '@/modules/ai-runtime/types/index.js'; + +export const llmAuthService = { + /** + * Returns auth status for one provider. + */ + async getProviderAuthStatus(providerName: string): Promise { + const provider = llmProviderRegistry.resolveProvider(providerName); + return provider.auth.getStatus(); + }, +}; diff --git a/server/src/modules/ai-runtime/types/auth.types.ts b/server/src/modules/ai-runtime/types/auth.types.ts new file mode 100644 index 00000000..acd27319 --- /dev/null +++ b/server/src/modules/ai-runtime/types/auth.types.ts @@ -0,0 +1,19 @@ +import type { LLMProvider } from '@/shared/types/app.js'; + +/** + * Provider authentication status normalized for frontend consumption. + */ +export type ProviderAuthStatus = { + provider: LLMProvider; + authenticated: boolean; + email: string | null; + method: string | null; + error?: string; +}; + +/** + * Auth runtime contract for one provider. + */ +export interface IProviderAuthRuntime { + getStatus(): Promise; +} diff --git a/server/src/modules/ai-runtime/types/index.ts b/server/src/modules/ai-runtime/types/index.ts index 7ab87bbb..bdc9e72b 100644 --- a/server/src/modules/ai-runtime/types/index.ts +++ b/server/src/modules/ai-runtime/types/index.ts @@ -2,3 +2,4 @@ export * from '@/modules/ai-runtime/types/provider.types.js'; export * from '@/modules/ai-runtime/types/mcp.types.js'; export * from '@/modules/ai-runtime/types/skills.types.js'; export * from '@/modules/ai-runtime/types/session-synchronizer.types.js'; +export * from '@/modules/ai-runtime/types/auth.types.js'; diff --git a/server/src/modules/ai-runtime/types/provider.types.ts b/server/src/modules/ai-runtime/types/provider.types.ts index 1e5260ab..f7cb7d92 100644 --- a/server/src/modules/ai-runtime/types/provider.types.ts +++ b/server/src/modules/ai-runtime/types/provider.types.ts @@ -2,6 +2,7 @@ import type { LLMProvider } from '@/shared/types/app.js'; import type { IProviderMcpRuntime } from '@/modules/ai-runtime/types/mcp.types.js'; import type { IProviderSkillsRuntime } from '@/modules/ai-runtime/types/skills.types.js'; import type { IProviderSessionSynchronizerRuntime } from '@/modules/ai-runtime/types/session-synchronizer.types.js'; +import type { IProviderAuthRuntime } from '@/modules/ai-runtime/types/auth.types.js'; export type ProviderExecutionFamily = 'sdk' | 'cli'; @@ -80,6 +81,7 @@ export interface IProvider { readonly mcp: IProviderMcpRuntime; readonly skills: IProviderSkillsRuntime; readonly sessionSynchronizer: IProviderSessionSynchronizerRuntime; + readonly auth: IProviderAuthRuntime; listModels(): Promise; diff --git a/server/src/modules/cli-auth/.gitkeep b/server/src/modules/cli-auth/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/server/src/modules/cli-auth/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/server/src/modules/cli-auth/cli-auth.routes.js b/server/src/modules/cli-auth/cli-auth.routes.js deleted file mode 100644 index fcd94106..00000000 --- a/server/src/modules/cli-auth/cli-auth.routes.js +++ /dev/null @@ -1,434 +0,0 @@ -import express from 'express'; -import spawn from 'cross-spawn'; -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; - -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 - */ -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; - try { - childProcess = spawn('cursor-agent', ['status']); - } catch (err) { - clearTimeout(timeout); - processCompleted = true; - resolve({ - authenticated: false, - email: null, - error: 'Cursor CLI not found or not installed' - }); - 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' - }; - } -} - -export default router; diff --git a/server/src/runner.ts b/server/src/runner.ts index 6cdbdfa4..a37d4159 100644 --- a/server/src/runner.ts +++ b/server/src/runner.ts @@ -56,7 +56,6 @@ const [ pushSubRoutes, agentRoutes, projectsRoutes, - cliAuthRoutes, codexRoutes, geminiRoutes, pluginsRoutes, @@ -83,7 +82,6 @@ const [ importRoute('./modules/push-sub/push-sub.routes.js'), importRoute('./modules/agent/agent.routes.js'), importRoute('./modules/projects/projects.routes.js'), - importRoute('./modules/cli-auth/cli-auth.routes.js'), importRoute('./modules/codex/codex.routes.js'), importRoute('./modules/gemini/gemini.routes.js'), importRoute('./modules/plugins/plugins.routes.js'), @@ -162,9 +160,6 @@ app.use('/api/credentials', authenticateToken, credentialsRoutes); app.use('/api/notification-preferences', authenticateToken, notificationPreferencesRoutes); app.use('/api/push-sub', authenticateToken, pushSubRoutes); -// CLI Authentication API Routes (protected) -app.use('/api/cli', authenticateToken, cliAuthRoutes); - // User API Routes (protected) app.use('/api/user', authenticateToken, userRoutes); diff --git a/src/App.tsx b/src/App.tsx index 8757151e..2f922b28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,8 @@ import { I18nextProvider } from 'react-i18next'; import { Navigate, - Outlet, RouterProvider, createBrowserRouter, - useLocation, - useParams, } from 'react-router-dom'; import { AuthProvider, ProtectedRoute } from './components/auth'; import { ThemeProvider } from './contexts/ThemeContext'; @@ -21,95 +18,8 @@ import FileTreeRouterAdapter from '@/components/file-tree/view/FileTreeRouterAda import GitPanelRouterAdapter from '@/components/git-panel/view/GitPanelRouterAdapter.js'; import { TaskMasterPanel } from '@/components/task-master/index.js'; import PluginContentRouterAdapter from '@/components/plugins/view/PluginContentRouterAdapter.js'; -import ChatInterface from '@/components/refactored/chat/view/ChatInterface.js'; import ChooseWorkspaceView from '@/components/refactored/shared/view/ChooseWorkspaceView.js'; -const isValidRouteTab = (value: string | undefined): boolean => { - if (!value) { - return false; - } - - const normalizedValue = decodeURIComponent(value); - return ( - normalizedValue === 'chat' || - normalizedValue === 'shell' || - normalizedValue === 'files' || - normalizedValue === 'git' || - normalizedValue === 'tasks' || - normalizedValue === 'plugins' || - normalizedValue === 'preview' || - normalizedValue.startsWith('plugin:') - ); -}; - -function NoWorkspaceRoute() { - return ( -
-
-

Choose Your Project

-

- This is the root route (`/`) empty state. Select a workspace from the sidebar to continue. -

-
-
- ); -} - -function WorkspaceLayout() { - const { workspaceId } = useParams<{ workspaceId: string }>(); - if (!workspaceId) { - return ; - } - - return ( -
- -
- ); -} - -function WorkspaceTabRoute() { - const location = useLocation(); - const { workspaceId, sessionId, tab } = useParams<{ - workspaceId: string; - sessionId?: string; - tab: string; - }>(); - - if (!workspaceId && !sessionId) { - return ; - } - - if (!isValidRouteTab(tab)) { - return ; - } - - const decodedWorkspaceId = workspaceId ? decodeURIComponent(workspaceId) : null; - const decodedSessionId = sessionId ? decodeURIComponent(sessionId) : null; - const decodedTab = tab ? decodeURIComponent(tab) : 'chat'; - const pluginName = decodeURIComponent(new URLSearchParams(location.search).get('name') || ''); - const tabLabel = decodedTab === 'plugins' && pluginName ? `plugin:${pluginName}` : decodedTab; - - return ( -
-
-

{tabLabel} view

-

- Workspace:{' '} - - {decodedWorkspaceId || 'none (session-level route)'} - -

-

- Session:{' '} - - {decodedSessionId || 'none (workspace-level tab)'} - -

-
-
- ); -} const router = createBrowserRouter( [ @@ -120,16 +30,14 @@ const router = createBrowserRouter( { index: true, element: }, // TODO: Show empty state component loader here. { path: 'workspaces/:workspaceId', - element: , children: [ { index: true, element: }, - { path: 'chat', element: }, { path: 'shell', element: }, { path: 'files', element: }, { path: 'git', element: }, { path: 'tasks', element: }, { path: 'plugins', element: }, - { path: ':tab', element: }, + { path: 'chat', element:

Sample component

} ], }, { @@ -137,7 +45,11 @@ const router = createBrowserRouter( children: [ { index: true, element: }, { path: 'shell', element: }, - { path: ':tab', element: }, + { path: 'files', element: }, + { path: 'git', element: }, + { path: 'tasks', element: }, + { path: 'plugins', element: }, + { path: 'chat', element:

Sample component

} ], }, ], @@ -168,42 +80,4 @@ export default function App() { ); -} - -// import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; -// import { I18nextProvider } from 'react-i18next'; -// import { ThemeProvider } from './contexts/ThemeContext'; -// import { AuthProvider, ProtectedRoute } from './components/auth'; -// import { TaskMasterProvider } from './contexts/TaskMasterContext'; -// import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; -// import { WebSocketProvider } from './contexts/WebSocketContext'; -// import { PluginsProvider } from './contexts/PluginsContext'; -// import AppContent from './components/app/AppContent'; -// import i18n from './i18n/config.js'; - -// export default function App() { -// return ( -// -// -// -// -// 1 -// -// -// -// -// -// } /> -// } /> -// -// -// -// -// -// -// -// -// -// -// ); -// } +} \ No newline at end of file diff --git a/src/components/onboarding/view/Onboarding.tsx b/src/components/onboarding/view/Onboarding.tsx index a5830826..4e2c4d07 100644 --- a/src/components/onboarding/view/Onboarding.tsx +++ b/src/components/onboarding/view/Onboarding.tsx @@ -31,7 +31,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) { const checkProviderAuthStatus = useCallback(async (provider: CliProvider) => { try { - const response = await authenticatedFetch(`/api/cli/${provider}/status`); + const response = await authenticatedFetch(`/api/llm/providers/${provider}/auth/status`); if (!response.ok) { setProviderStatuses((previous) => ({ ...previous, @@ -46,18 +46,39 @@ export default function Onboarding({ onComplete }: OnboardingProps) { } const payload = (await response.json()) as { - authenticated?: boolean; - email?: string | null; - error?: string | null; + success?: boolean; + data?: { + auth?: { + authenticated?: boolean; + email?: string | null; + error?: string | null; + }; + }; + error?: { + message?: string; + }; }; + const auth = payload.data?.auth; + if (!payload.success || !auth) { + setProviderStatuses((previous) => ({ + ...previous, + [provider]: { + authenticated: false, + email: null, + loading: false, + error: payload.error?.message ?? 'Failed to parse authentication status', + }, + })); + return; + } setProviderStatuses((previous) => ({ ...previous, [provider]: { - authenticated: Boolean(payload.authenticated), - email: payload.email ?? null, + authenticated: Boolean(auth.authenticated), + email: auth.email ?? null, loading: false, - error: payload.error ?? null, + error: auth.error ?? null, }, })); } catch (caughtError) { diff --git a/src/components/refactored/chat/view/ChatInterface.tsx b/src/components/refactored/chat/view/ChatInterface.tsx deleted file mode 100644 index 4d1997fd..00000000 --- a/src/components/refactored/chat/view/ChatInterface.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// Create a sample component - -// TODO: Place this in a shared folder -import ErrorBoundary from "@/components/main-content/view/ErrorBoundary.js"; -import { QuickSettingsPanel } from "@/components/quick-settings-panel/index.js"; - -export default function ChatInterface() { - return ( -
- -
-

Chat interface goes here

-
-
- - -
- - ); -} \ No newline at end of file diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 5b701805..6863af5a 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -80,8 +80,8 @@ export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = { }; export const AUTH_STATUS_ENDPOINTS: Record = { - claude: '/api/cli/claude/status', - cursor: '/api/cli/cursor/status', - codex: '/api/cli/codex/status', - gemini: '/api/cli/gemini/status', + claude: '/api/llm/providers/claude/auth/status', + cursor: '/api/llm/providers/cursor/auth/status', + codex: '/api/llm/providers/codex/auth/status', + gemini: '/api/llm/providers/gemini/auth/status', }; diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index 96f2e283..22853373 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -33,11 +33,22 @@ type UseSettingsControllerArgs = { onClose: () => void; }; -type StatusApiResponse = { +type AuthStatusPayload = { authenticated?: boolean; email?: string | null; error?: string | null; - method?: string; + method?: string | null; +}; + +type StatusApiResponse = { + success?: boolean; + data?: { + provider?: AgentProvider; + auth?: AuthStatusPayload; + }; + error?: { + message?: string; + }; }; type JsonResult = { @@ -249,13 +260,24 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl return; } - const data = await toResponseJson(response); + const payload = await toResponseJson(response); + const data = payload.data?.auth; + if (!payload.success || !data) { + setAuthStatusByProvider(provider, { + authenticated: false, + email: null, + loading: false, + error: payload.error?.message || 'Failed to parse authentication status', + }); + return; + } + setAuthStatusByProvider(provider, { authenticated: Boolean(data.authenticated), email: data.email || null, loading: false, error: data.error || null, - method: data.method, + method: data.method ?? undefined, }); } catch (error) { console.error(`Error checking ${provider} auth status:`, error);