mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-20 20:41:31 +00:00
refactor(providers): move auth status checks into provider runtimes
Move provider authentication status logic out of the CLI auth route so auth checks live with the provider implementations that understand each provider's install and credential model. Add provider-specific auth runtime classes for Claude, Codex, Cursor, and Gemini, and expose them through the shared provider contract as `provider.auth`. Add a provider auth service that resolves providers through the registry and delegates status checks via `auth.getStatus()`. Keep the existing `/api/cli/<provider>/status` endpoints, but make them thin route adapters over the new provider auth service. This removes duplicated route-local credential parsing and makes auth status a first-class provider capability beside MCP and message handling.
This commit is contained in:
126
server/modules/providers/list/claude/claude-auth.provider.ts
Normal file
126
server/modules/providers/list/claude/claude-auth.provider.ts
Normal file
@@ -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<ProviderAuthStatus> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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<ClaudeCredentialsStatus> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getSessionMessages } from '@/projects.js';
|
import { getSessionMessages } from '@/projects.js';
|
||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.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 { 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 type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
|||||||
|
|
||||||
export class ClaudeProvider extends AbstractProvider {
|
export class ClaudeProvider extends AbstractProvider {
|
||||||
readonly mcp = new ClaudeMcpProvider();
|
readonly mcp = new ClaudeMcpProvider();
|
||||||
|
readonly auth: IProviderAuthRuntime = new ClaudeAuthProvider();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('claude');
|
super('claude');
|
||||||
|
|||||||
100
server/modules/providers/list/codex/codex-auth.provider.ts
Normal file
100
server/modules/providers/list/codex/codex-auth.provider.ts
Normal file
@@ -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<ProviderAuthStatus> {
|
||||||
|
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<CodexCredentialsStatus> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getCodexSessionMessages } from '@/projects.js';
|
import { getCodexSessionMessages } from '@/projects.js';
|
||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.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 { 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 type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
|||||||
|
|
||||||
export class CodexProvider extends AbstractProvider {
|
export class CodexProvider extends AbstractProvider {
|
||||||
readonly mcp = new CodexMcpProvider();
|
readonly mcp = new CodexMcpProvider();
|
||||||
|
readonly auth: IProviderAuthRuntime = new CodexAuthProvider();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('codex');
|
super('codex');
|
||||||
|
|||||||
143
server/modules/providers/list/cursor/cursor-auth.provider.ts
Normal file
143
server/modules/providers/list/cursor/cursor-auth.provider.ts
Normal file
@@ -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<ProviderAuthStatus> {
|
||||||
|
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<CursorLoginStatus> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let processCompleted = false;
|
||||||
|
let childProcess: ReturnType<typeof spawn> | 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
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 { 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 type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
|||||||
|
|
||||||
export class CursorProvider extends AbstractProvider {
|
export class CursorProvider extends AbstractProvider {
|
||||||
readonly mcp = new CursorMcpProvider();
|
readonly mcp = new CursorMcpProvider();
|
||||||
|
readonly auth: IProviderAuthRuntime = new CursorAuthProvider();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('cursor');
|
super('cursor');
|
||||||
|
|||||||
151
server/modules/providers/list/gemini/gemini-auth.provider.ts
Normal file
151
server/modules/providers/list/gemini/gemini-auth.provider.ts
Normal file
@@ -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<ProviderAuthStatus> {
|
||||||
|
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<GeminiCredentialsStatus> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import sessionManager from '@/sessionManager.js';
|
import sessionManager from '@/sessionManager.js';
|
||||||
import { getGeminiCliSessionMessages } from '@/projects.js';
|
import { getGeminiCliSessionMessages } from '@/projects.js';
|
||||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.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 { 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 type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
|||||||
|
|
||||||
export class GeminiProvider extends AbstractProvider {
|
export class GeminiProvider extends AbstractProvider {
|
||||||
readonly mcp = new GeminiMcpProvider();
|
readonly mcp = new GeminiMcpProvider();
|
||||||
|
readonly auth: IProviderAuthRuntime = new GeminiAuthProvider();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('gemini');
|
super('gemini');
|
||||||
|
|||||||
12
server/modules/providers/services/provider-auth.service.ts
Normal file
12
server/modules/providers/services/provider-auth.service.ts
Normal file
@@ -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<ProviderAuthStatus> {
|
||||||
|
const provider = providerRegistry.resolveProvider(providerName);
|
||||||
|
return provider.auth.getStatus();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IProvider, IProviderMcpRuntime } from '@/shared/interfaces.js';
|
import type { IProvider, IProviderAuthRuntime, IProviderMcpRuntime } from '@/shared/interfaces.js';
|
||||||
import type {
|
import type {
|
||||||
FetchHistoryOptions,
|
FetchHistoryOptions,
|
||||||
FetchHistoryResult,
|
FetchHistoryResult,
|
||||||
@@ -9,12 +9,14 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* Shared provider base.
|
* Shared provider base.
|
||||||
*
|
*
|
||||||
* Concrete providers must implement message normalization and history loading
|
* Concrete providers must expose auth/MCP runtimes and implement message
|
||||||
* because both behaviors depend on each provider's native SDK/CLI event format.
|
* normalization/history loading because those behaviors depend on native
|
||||||
|
* SDK/CLI formats.
|
||||||
*/
|
*/
|
||||||
export abstract class AbstractProvider implements IProvider {
|
export abstract class AbstractProvider implements IProvider {
|
||||||
readonly id: LLMProvider;
|
readonly id: LLMProvider;
|
||||||
abstract readonly mcp: IProviderMcpRuntime;
|
abstract readonly mcp: IProviderMcpRuntime;
|
||||||
|
abstract readonly auth: IProviderAuthRuntime;
|
||||||
|
|
||||||
protected constructor(id: LLMProvider) {
|
protected constructor(id: LLMProvider) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
|||||||
@@ -1,434 +1,35 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import fs from 'fs/promises';
|
import { providerAuthService } from '../modules/providers/services/provider-auth.service.js';
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
const router = express.Router();
|
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:
|
* Creates a status route handler for one provider while preserving the existing
|
||||||
*
|
* /api/cli/<provider>/status endpoint shape.
|
||||||
* 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<Object>} 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() {
|
function createProviderStatusHandler(providerName) {
|
||||||
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
|
return async (req, res) => {
|
||||||
// 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 {
|
try {
|
||||||
childProcess = spawn('cursor-agent', ['status']);
|
const status = await providerAuthService.getProviderAuthStatus(providerName);
|
||||||
} catch (err) {
|
return res.json(status);
|
||||||
clearTimeout(timeout);
|
} catch (error) {
|
||||||
processCompleted = true;
|
console.error(`Error checking ${providerName} auth status:`, error);
|
||||||
resolve({
|
return res.status(500).json({
|
||||||
|
installed: false,
|
||||||
|
provider: providerName,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
email: null,
|
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() {
|
router.get('/claude/status', createProviderStatusHandler('claude'));
|
||||||
try {
|
router.get('/cursor/status', createProviderStatusHandler('cursor'));
|
||||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
router.get('/codex/status', createProviderStatusHandler('codex'));
|
||||||
const content = await fs.readFile(authPath, 'utf8');
|
router.get('/gemini/status', createProviderStatusHandler('gemini'));
|
||||||
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;
|
export default router;
|
||||||
|
|||||||
@@ -5,10 +5,36 @@ import type {
|
|||||||
McpScope,
|
McpScope,
|
||||||
McpTransport,
|
McpTransport,
|
||||||
NormalizedMessage,
|
NormalizedMessage,
|
||||||
|
ProviderAuthStatus,
|
||||||
ProviderMcpServer,
|
ProviderMcpServer,
|
||||||
UpsertProviderMcpServerInput,
|
UpsertProviderMcpServerInput,
|
||||||
} from '@/shared/types.js';
|
} 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<FetchHistoryResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth runtime contract for one provider.
|
||||||
|
*/
|
||||||
|
export interface IProviderAuthRuntime {
|
||||||
|
/**
|
||||||
|
* Checks whether the provider runtime is installed and has usable credentials.
|
||||||
|
*/
|
||||||
|
getStatus(): Promise<ProviderAuthStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP runtime contract for one provider.
|
* MCP runtime contract for one provider.
|
||||||
@@ -32,17 +58,3 @@ export interface IProviderMcpRuntime {
|
|||||||
error?: string;
|
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<FetchHistoryResult>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -155,3 +155,25 @@ export type UpsertProviderMcpServerInput = {
|
|||||||
bearerTokenEnvVar?: string;
|
bearerTokenEnvVar?: string;
|
||||||
envHttpHeaders?: Record<string, string>;
|
envHttpHeaders?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// -------------------- 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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user