diff --git a/.env.example b/.env.example index 52d6ef8c0a..5dea5c23a3 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # GitHub OAuth Configuration for GitHub Copilot -# Copy this file to .env and fill in the actual values -# Both Client ID and Client Secret are required +# Device Flow uses a built-in public Client ID and does not require a Client Secret. +# For traditional OAuth, configure a matching Client ID, Client Secret, and redirect URI. VITE_GITHUB_CLIENT_ID=Iv1.b507a08c87ecfe98 VITE_GITHUB_CLIENT_SECRET=your_github_client_secret_here VITE_GITHUB_REDIRECT_URI=https://deepchatai.cn/auth/github/callback diff --git a/src/main/provider/auth/githubCopilotDeviceFlow.ts b/src/main/provider/auth/githubCopilotDeviceFlow.ts index 9926a6a87d..30f41ae214 100644 --- a/src/main/provider/auth/githubCopilotDeviceFlow.ts +++ b/src/main/provider/auth/githubCopilotDeviceFlow.ts @@ -1,8 +1,12 @@ import { BrowserWindow, clipboard, shell } from 'electron' import { exec } from 'child_process' import logger from '@shared/logger' +import { z } from 'zod' +import { createProviderHttpErrorFromResponse, type ProviderHttpError } from '../providerFailure' const GITHUB_DEVICE_URL = 'https://github.com/login/device' +const GITHUB_COPILOT_DEVICE_FLOW_CLIENT_ID = 'Iv1.b507a08c87ecfe98' +const GITHUB_TOKEN_PATTERN = /^[\x21-\x7e]+$/ export interface DeviceFlowConfig { clientId: string @@ -31,20 +35,96 @@ export interface CopilotTokenResponse { refresh_in?: number } -export interface ApiToken { - apiKey: string - expiresAt: Date +// Copilot chat uses VS Code integration metadata independently of the DeepChat app version. +export const GITHUB_COPILOT_CHAT_HEADERS = { + 'User-Agent': 'GitHubCopilotChat/0.44.0', + 'Editor-Version': 'vscode/1.115.0', + 'Editor-Plugin-Version': 'copilot-chat/0.44.0', + 'Copilot-Integration-Id': 'vscode-chat', + 'X-GitHub-Api-Version': '2026-08-01' } -export interface CopilotConfig { - oauthToken?: string - apiToken?: ApiToken +const CopilotTokenSchema = z.object({ + token: z.string().regex(GITHUB_TOKEN_PATTERN), + expires_at: z.number().positive().finite(), + refresh_in: z.number().nonnegative().finite().optional() +}) + +export async function createGitHubCopilotHttpError( + response: Response, + phase: 'token' | 'chat', + secrets: string[] +): Promise { + const body = await response.json().catch(() => null) + const sanitize = (value: unknown): string => { + if (typeof value !== 'string') return '' + let text = value + for (const secret of secrets) { + if (secret) text = text.replaceAll(secret, '[redacted]') + } + return text + .replace(/(?:gh[opusr]_|github_pat_)[A-Za-z0-9_]+/g, '[redacted]') + .replace(/Bearer\s+[^\s"',}]+/gi, 'Bearer [redacted]') + .replace(/(?:access_token|refresh_token|api_key)["\s:=]+[^"'\s,}]+/gi, '[redacted]') + .replace(/[\r\n\0]/g, ' ') + .slice(0, 500) + } + const serverMessage = sanitize( + body?.error?.message ?? body?.error_details?.message ?? body?.message + ) + const serverCode = sanitize(body?.error?.code ?? body?.error_details?.code ?? body?.code) + const requestId = sanitize( + response.headers.get('x-github-request-id') ?? response.headers.get('x-request-id') + ) + const details = [ + serverMessage, + /^[A-Za-z0-9._:-]{1,128}$/.test(serverCode) ? `code=${serverCode}` : '', + /^[A-Za-z0-9._:-]{1,128}$/.test(requestId) ? `request_id=${requestId}` : '' + ].filter(Boolean) + const description = phase === 'token' ? 'token exchange failed' : 'API error' + return createProviderHttpErrorFromResponse( + `GitHub Copilot ${description}: ${response.status} ${sanitize(response.statusText)}` + + (details.length ? ` - ${details.join('; ')}` : ''), + response, + `github_copilot_${phase}_http_error` + ) +} + +export async function getGitHubCopilotToken( + oauthToken: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + if (typeof oauthToken !== 'string' || !GITHUB_TOKEN_PATTERN.test(oauthToken)) { + throw new Error('No GitHub OAuth token available. Please sign in to GitHub Copilot.') + } + try { + const response = await fetch('https://api.github.com/copilot_internal/v2/token', { + headers: { + Authorization: `token ${oauthToken}`, + Accept: 'application/json', + 'User-Agent': GITHUB_COPILOT_CHAT_HEADERS['User-Agent'], + 'X-GitHub-Api-Version': '2025-04-01' + }, + ...(signal ? { signal } : {}) + }) + if (!response.ok) { + throw await createGitHubCopilotHttpError(response, 'token', [oauthToken]) + } + const result = CopilotTokenSchema.safeParse(await response.json().catch(() => null)) + if (!result.success) { + throw new Error('Invalid GitHub Copilot token response') + } + return result.data + } catch (error) { + signal?.throwIfAborted() + throw error + } } export class GitHubCopilotDeviceFlow { private config: DeviceFlowConfig private pollingInterval: NodeJS.Timeout | null = null - private oauthToken: string | null = null constructor(config: DeviceFlowConfig) { this.config = config @@ -73,83 +153,6 @@ export class GitHubCopilotDeviceFlow { } } - /** - * 获取 Copilot API token - * 使用OAuth token交换Copilot API token - */ - public async getCopilotToken(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - if (!this.oauthToken) { - throw new Error('No OAuth token available') - } - - // 使用OAuth token从GitHub API获取Copilot token - const tokenUrl = 'https://api.github.com/copilot_internal/v2/token' - - try { - const response = await fetch(tokenUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.oauthToken}`, - Accept: 'application/json', - 'User-Agent': 'DeepChat/1.0.0' - }, - ...(signal ? { signal } : {}) - }) - - if (!response.ok) { - const errorText = await response.text().catch(() => '') - throw new Error( - `Failed to get Copilot token: ${response.status} ${response.statusText} - ${errorText}` - ) - } - - const data = (await response.json()) as { token: string; expires_at: number } - return data.token - } catch (error) { - signal?.throwIfAborted() - console.error('[GitHub Copilot][DeviceFlow] Failed to get Copilot token:', error) - throw error - } - } - - /** - * 检查是否已经有有效的认证状态 - */ - public async checkExistingAuth(externalToken?: string): Promise { - try { - // 如果提供了外部 token,使用它 - if (externalToken) { - this.oauthToken = externalToken - - // 尝试获取 API token 来验证认证状态 - try { - await this.getCopilotToken() - return this.oauthToken - } catch { - this.oauthToken = null - return null - } - } - - // 检查内部存储的 token - if (this.oauthToken) { - // 尝试获取 API token 来验证认证状态 - try { - await this.getCopilotToken() - return this.oauthToken! - } catch { - this.oauthToken = null - } - } - - return null - } catch (error) { - console.warn('[GitHub Copilot][DeviceFlow] Error checking existing auth:', error) - return null - } - } - /** * Step 1: Request device code */ @@ -594,13 +597,10 @@ export class GitHubCopilotDeviceFlow { // GitHub Copilot Device Flow configuration export function createGitHubCopilotDeviceFlow(clientIdOverride?: string): GitHubCopilotDeviceFlow { - const clientId = clientIdOverride?.trim() || import.meta.env.VITE_GITHUB_CLIENT_ID - - if (!clientId) { - throw new Error( - 'GitHub Client ID is required. Please enter it in the Copilot settings input or set VITE_GITHUB_CLIENT_ID in .env.' - ) - } + const clientId = + clientIdOverride?.trim() || + import.meta.env.VITE_GITHUB_CLIENT_ID?.trim() || + GITHUB_COPILOT_DEVICE_FLOW_CLIENT_ID const config: DeviceFlowConfig = { clientId, @@ -624,7 +624,10 @@ let globalDeviceFlowClientId: string | null = null export function getGlobalGitHubCopilotDeviceFlow( clientIdOverride?: string ): GitHubCopilotDeviceFlow { - const effectiveClientId = clientIdOverride?.trim() || import.meta.env.VITE_GITHUB_CLIENT_ID + const effectiveClientId = + clientIdOverride?.trim() || + import.meta.env.VITE_GITHUB_CLIENT_ID?.trim() || + GITHUB_COPILOT_DEVICE_FLOW_CLIENT_ID if (!globalDeviceFlowInstance || globalDeviceFlowClientId !== effectiveClientId) { globalDeviceFlowInstance?.dispose() diff --git a/src/main/provider/auth/githubCopilotOAuth.ts b/src/main/provider/auth/githubCopilotOAuth.ts index 5660ade5c5..957fa59fa3 100644 --- a/src/main/provider/auth/githubCopilotOAuth.ts +++ b/src/main/provider/auth/githubCopilotOAuth.ts @@ -191,25 +191,6 @@ export class GitHubCopilotOAuth { } } - /** - * Validate access token - */ - async validateToken(token: string): Promise { - try { - const response = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': 'DeepChat/1.0.0' - } - }) - - return response.ok - } catch (error) { - console.error('Token validation failed:', error) - return false - } - } - /** * Close authorization window */ diff --git a/src/main/provider/auth/index.ts b/src/main/provider/auth/index.ts index c87db90994..99ba15a011 100644 --- a/src/main/provider/auth/index.ts +++ b/src/main/provider/auth/index.ts @@ -4,12 +4,13 @@ import { BrowserWindow } from 'electron' import * as http from 'http' import { URL } from 'url' import { createGitHubCopilotOAuth } from './githubCopilotOAuth' -import { getGlobalGitHubCopilotDeviceFlow } from './githubCopilotDeviceFlow' +import { getGitHubCopilotToken, getGlobalGitHubCopilotDeviceFlow } from './githubCopilotDeviceFlow' import { getGlobalOpenAICodexAuth, initializeGlobalOpenAICodexAuth } from './openaiCodex' import { getGlobalXaiGrokAuth, initializeGlobalXaiGrokAuth } from './xaiGrok' import type { OpenAICodexAuthStatus } from '@shared/types/openai-codex' import type { XaiGrokAuthStatus } from '@shared/types/xai-grok' import type { DeepchatEventPublisher } from '@shared/contracts/events' +import type { LLM_PROVIDER } from '@shared/types/provider' import type { OAuthConfig, OAuthServicePort } from '@shared/types/oauth' @@ -29,23 +30,17 @@ export class OAuthService implements OAuthServicePort { initializeGlobalXaiGrokAuth(publishEvent) } - /** - * Validate GitHub access token - */ - private async validateGitHubAccessToken(token: string): Promise { - try { - const response = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': 'DeepChat/1.0.0' - } - }) - - return response.ok - } catch (error) { - console.error('Token validation failed:', error) - return false + private saveGitHubCopilotCredential(provider: LLM_PROVIDER, apiKey: string): void { + const current = this.providerSettings.getProviderById(provider.id) + if ( + !current || + current.apiType !== provider.apiType || + current.apiKey !== provider.apiKey || + (current.copilotClientId?.trim() || '') !== (provider.copilotClientId?.trim() || '') + ) { + throw new Error('GitHub Copilot configuration changed during sign-in. Please try again.') } + this.providerSettings.setProviderById(provider.id, { ...current, apiKey }) } /** @@ -54,39 +49,20 @@ export class OAuthService implements OAuthServicePort { async startGitHubCopilotDeviceFlowLogin(providerId: string): Promise { try { const provider = this.providerSettings.getProviderById(providerId) - const githubDeviceFlow = getGlobalGitHubCopilotDeviceFlow(provider?.copilotClientId) - - // 首先检查现有认证状态 - if (provider && provider.apiKey) { - const existingToken = await githubDeviceFlow.checkExistingAuth(provider.apiKey) - if (existingToken) { - return true - } - } + if (!provider) throw new Error(`Provider ${providerId} not found`) + const githubDeviceFlow = getGlobalGitHubCopilotDeviceFlow(provider.copilotClientId) // 开始Device Flow登录 const accessToken = await githubDeviceFlow.startDeviceFlow() - // Validate token - logger.info('Validating access token...') - const isValid = await this.validateGitHubAccessToken(accessToken) - if (!isValid) { - throw new Error('Obtained access token is invalid') - } - - // 保存访问令牌到provider配置 - if (provider) { - provider.apiKey = accessToken - this.providerSettings.setProviderById(providerId, provider) - logger.info('[GitHub Copilot] Device Flow login completed successfully') - } else { - throw new Error(`Provider ${providerId} not found`) - } + await getGitHubCopilotToken(accessToken) + this.saveGitHubCopilotCredential(provider, accessToken) + logger.info('[GitHub Copilot] Device Flow login completed successfully') return true } catch (error) { console.error('[GitHub Copilot] Device Flow login failed:', error) - return false + throw error } } @@ -141,7 +117,8 @@ export class OAuthService implements OAuthServicePort { // 使用专门的GitHub Copilot OAuth实现 logger.info('[GitHub Copilot][OAuth] Creating GitHub OAuth instance...') const provider = this.providerSettings.getProviderById(providerId) - const githubOAuth = createGitHubCopilotOAuth(provider?.copilotClientId) + if (!provider) throw new Error(`Provider ${providerId} not found`) + const githubOAuth = createGitHubCopilotOAuth(provider.copilotClientId) // 开始OAuth登录 logger.info('[GitHub Copilot][OAuth] Starting OAuth login flow...') @@ -169,30 +146,9 @@ export class OAuthService implements OAuthServicePort { logger.info('[GitHub Copilot][OAuth] Access token received successfully') } - // Validate token - logger.info('[GitHub Copilot][OAuth] Validating access token...') - const isValid = await githubOAuth.validateToken(accessToken) - logger.info('[GitHub Copilot][OAuth] Token validation result:', isValid) - - if (!isValid) { - console.error('[GitHub Copilot][OAuth] Token validation failed - token is invalid') - throw new Error('获取的访问令牌无效') - } - - // 保存访问令牌到provider配置 - logger.info('[GitHub Copilot][OAuth] Saving access token to provider configuration...') - if (provider) { - provider.apiKey = accessToken - this.providerSettings.setProviderById(providerId, provider) - logger.info( - '[GitHub Copilot][OAuth] Access token saved successfully to provider:', - providerId - ) - logger.info('[GitHub Copilot][OAuth] Traditional OAuth login completed successfully') - } else { - console.error('[GitHub Copilot][OAuth] Provider not found:', providerId) - throw new Error(`Provider ${providerId} not found`) - } + await getGitHubCopilotToken(accessToken) + this.saveGitHubCopilotCredential(provider, accessToken) + logger.info('[GitHub Copilot][OAuth] Traditional OAuth login completed successfully') return true } catch (error) { @@ -208,7 +164,7 @@ export class OAuthService implements OAuthServicePort { if (error instanceof Error && error.stack) { console.error('[GitHub Copilot][OAuth][ERROR] Stack trace:', error.stack) } - return false + throw error } } diff --git a/src/main/provider/providers/githubCopilotProvider.ts b/src/main/provider/providers/githubCopilotProvider.ts index ca7220a6b3..137a716da4 100644 --- a/src/main/provider/providers/githubCopilotProvider.ts +++ b/src/main/provider/providers/githubCopilotProvider.ts @@ -17,31 +17,16 @@ import { } from '../baseProvider' import { normalizeToolInputSchema } from '../aiSdk/toolMapper' import type { ProviderLocalePort } from '../ports' -import { HttpsProxyAgent } from 'https-proxy-agent' import { - getGlobalGitHubCopilotDeviceFlow, - GitHubCopilotDeviceFlow + createGitHubCopilotHttpError, + getGitHubCopilotToken, + GITHUB_COPILOT_CHAT_HEADERS } from '../../provider/auth/githubCopilotDeviceFlow' -import { createProviderHttpErrorFromResponse, ProviderHttpError } from '../providerFailure' - -// 扩展RequestInit类型以支持agent属性 -interface RequestInitWithAgent extends RequestInit { - agent?: HttpsProxyAgent -} -import { proxyConfig } from '../../platform/proxy' - -interface CopilotTokenResponse { - token: string - expires_at: number - refresh_in?: number -} export class GithubCopilotProvider extends BaseLLMProvider { private copilotToken: string | null = null private tokenExpiresAt: number = 0 private baseApiUrl = 'https://api.githubcopilot.com' - private tokenUrl = 'https://api.github.com/copilot_internal/v2/token' - private deviceFlow: GitHubCopilotDeviceFlow | null = null constructor( provider: LLM_PROVIDER, @@ -56,15 +41,6 @@ export class GithubCopilotProvider extends BaseLLMProvider { if (this.provider.enable) { try { this.isInitialized = true - this.deviceFlow = getGlobalGitHubCopilotDeviceFlow(this.provider.copilotClientId) - - // 检查现有认证状态 - if (this.provider.apiKey) { - const existingToken = await this.deviceFlow.checkExistingAuth(this.provider.apiKey) - if (!existingToken) { - this.provider.apiKey = '' - } - } await this.fetchModels() await this.autoEnableModelsIfNeeded() @@ -85,124 +61,28 @@ export class GithubCopilotProvider extends BaseLLMProvider { } public override updateConfig(provider: LLM_PROVIDER): void { - const newDeviceFlow = getGlobalGitHubCopilotDeviceFlow(provider.copilotClientId) - super.updateConfig(provider) this.copilotToken = null this.tokenExpiresAt = 0 - this.deviceFlow = newDeviceFlow - } - - private createChatHttpError(response: Response): ProviderHttpError { - const code = 'github_copilot_chat_http_error' - if (response.status !== 403) { - return createProviderHttpErrorFromResponse( - `GitHub Copilot API error: ${response.status} ${response.statusText}`, - response, - code - ) - } - - return createProviderHttpErrorFromResponse( - `GitHub Copilot 访问被拒绝 (403)。\n\n可能的原因:\n` + - `1. GitHub Copilot 订阅已过期或未激活\n` + - `2. 需要重新认证以获取正确的访问权限\n` + - `3. API访问策略已更新,需要使用最新的认证方式\n` + - `4. 您的账户可能没有访问此API的权限\n\n` + - `建议解决方案:\n` + - `- 访问 https://github.com/settings/copilot 检查订阅状态\n` + - `- 在DeepChat设置中重新进行 GitHub Copilot 登录\n` + - `- 确保您的 GitHub 账户有有效的 Copilot 订阅\n` + - `- 如果是企业账户,请联系管理员确认访问权限`, - response, - code - ) } private async getCopilotToken(signal?: AbortSignal): Promise { signal?.throwIfAborted() - // 优先使用设备流获取 token - if (this.deviceFlow) { - try { - return await this.deviceFlow.getCopilotToken(signal) - } catch (error) { - signal?.throwIfAborted() - console.warn( - '[GitHub Copilot] Device flow failed, falling back to provider API key:', - error - ) - } - } - - // 检查token是否过期 if (this.copilotToken && Date.now() < this.tokenExpiresAt) { return this.copilotToken } - if (!this.provider.apiKey) { - throw new Error('No GitHub OAuth token available. Please use device flow authentication.') - } - - // 获取新的token - const headers: Record = { - Authorization: `Bearer ${this.provider.apiKey}`, - Accept: 'application/json', - 'User-Agent': 'DeepChat/1.0.0' - } - - const requestOptions: RequestInitWithAgent = { - method: 'GET', - headers, - ...(signal ? { signal } : {}) - } - - // 添加代理支持 - const proxyUrl = proxyConfig.getProxyUrl() - if (proxyUrl) { - const agent = new HttpsProxyAgent(proxyUrl) - requestOptions.agent = agent + const provider = this.provider + const data = await getGitHubCopilotToken(provider.apiKey, signal) + signal?.throwIfAborted() + if (this.provider.apiKey !== provider.apiKey) { + throw new Error('GitHub Copilot configuration changed during token exchange. Please retry.') } - - try { - const response = await fetch(this.tokenUrl, requestOptions) - - if (!response.ok) { - let errorMessage = `Failed to get Copilot token: ${response.status} ${response.statusText}` - - // 提供更具体的错误信息和解决建议 - if (response.status === 404) { - errorMessage = `GitHub Copilot 访问被拒绝 (404)。请检查: -1. 您的 GitHub 账户是否有有效的 GitHub Copilot 订阅 -2. OAuth token 权限不足 - 需要 'read:org' 权限访问 Copilot API -3. 请重新进行 OAuth 登录以获取正确的权限范围 -4. 访问 https://github.com/features/copilot 检查订阅状态` - } else if (response.status === 401) { - errorMessage = `GitHub OAuth token 无效或已过期 (401)。请重新登录授权并确保获取了正确的权限范围。` - } else if (response.status === 403) { - errorMessage = `GitHub Copilot 访问被禁止 (403)。请检查: -1. 您的 GitHub Copilot 订阅是否有效且处于活跃状态 -2. 是否达到了 API 使用限制 -3. OAuth token 是否包含 'read:org' 权限范围 -4. 如果是组织账户,请确保组织已启用 Copilot 并且您有访问权限` - } - - throw createProviderHttpErrorFromResponse( - errorMessage, - response, - 'github_copilot_token_http_error' - ) - } - - const data: CopilotTokenResponse = await response.json() + if (this.provider === provider) { this.copilotToken = data.token - this.tokenExpiresAt = data.expires_at * 1000 // 转换为毫秒 - - return this.copilotToken - } catch (error) { - signal?.throwIfAborted() - console.error('[GitHub Copilot] Error getting Copilot token:', error) - throw error + this.tokenExpiresAt = data.expires_at * 1000 } + return data.token } protected async fetchProviderModels(): Promise { @@ -485,9 +365,7 @@ export class GithubCopilotProvider extends BaseLLMProvider { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'text/event-stream', - 'User-Agent': 'DeepChat/1.0.0', - 'Editor-Version': 'DeepChat/1.0.0', - 'Copilot-Integration-Id': 'vscode-chat' + ...GITHUB_COPILOT_CHAT_HEADERS } // 添加详细的请求日志 @@ -499,7 +377,7 @@ export class GithubCopilotProvider extends BaseLLMProvider { ` Request Body: { messages: ${formattedMessages.length}, model: "${modelId}", temperature: ${temperature}, max_tokens: ${_maxTokens} }` ) - const requestOptions: RequestInitWithAgent = { + const requestOptions: RequestInit = { method: 'POST', headers, body: JSON.stringify(requestBody), @@ -512,13 +390,6 @@ export class GithubCopilotProvider extends BaseLLMProvider { body: requestBody }) - // 添加代理支持 - const proxyUrl = proxyConfig.getProxyUrl() - if (proxyUrl) { - const agent = new HttpsProxyAgent(proxyUrl) - requestOptions.agent = agent - } - const response = await this.fetchProvider( `${this.baseApiUrl}/chat/completions`, requestOptions @@ -529,7 +400,7 @@ export class GithubCopilotProvider extends BaseLLMProvider { logger.info(` OK: ${response.ok}`) if (!response.ok) { - throw this.createChatHttpError(response) + throw await createGitHubCopilotHttpError(response, 'chat', [token, this.provider.apiKey]) } if (!response.body) { @@ -644,9 +515,7 @@ export class GithubCopilotProvider extends BaseLLMProvider { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', - 'User-Agent': 'DeepChat/1.0.0', - 'Editor-Version': 'DeepChat/1.0.0', - 'Copilot-Integration-Id': 'vscode-chat' + ...GITHUB_COPILOT_CHAT_HEADERS } // 添加详细的请求日志 @@ -658,20 +527,13 @@ export class GithubCopilotProvider extends BaseLLMProvider { ` Request Body: { messages: ${formattedMessages.length}, model: "${modelId}", temperature: ${temperature}, max_tokens: ${_maxTokens} }` ) - const requestOptions: RequestInitWithAgent = { + const requestOptions: RequestInit = { method: 'POST', headers, body: JSON.stringify(requestBody), ...(signal ? { signal } : {}) } - // 添加代理支持 - const proxyUrl = proxyConfig.getProxyUrl() - if (proxyUrl) { - const agent = new HttpsProxyAgent(proxyUrl) - requestOptions.agent = agent - } - const response = await this.fetchProvider( `${this.baseApiUrl}/chat/completions`, requestOptions @@ -682,7 +544,7 @@ export class GithubCopilotProvider extends BaseLLMProvider { logger.info(` OK: ${response.ok}`) if (!response.ok) { - throw this.createChatHttpError(response) + throw await createGitHubCopilotHttpError(response, 'chat', [token, this.provider.apiKey]) } const data = await response.json() @@ -754,36 +616,12 @@ export class GithubCopilotProvider extends BaseLLMProvider { async check(): Promise<{ isOk: boolean; errorMsg: string | null }> { try { - // Device flow may be active without apiKey; proceed to token retrieval await this.getCopilotToken() return { isOk: true, errorMsg: null } } catch (error) { - let errorMsg = error instanceof Error ? error.message : 'Unknown error' - - // 分析错误类型并提供更具体的建议 - if (errorMsg.includes('404')) { - errorMsg = `GitHub Copilot 访问被拒绝 (404)。请检查: -1. 您的 GitHub 账户是否有有效的 GitHub Copilot 订阅 -2. 访问 https://github.com/features/copilot 检查订阅状态 -3. 如果是组织账户,请确保组织已启用 Copilot 并且您有访问权限` - } else if (errorMsg.includes('401')) { - errorMsg = `GitHub OAuth token 无效或已过期 (401)。请重新登录授权并确保获取了正确的权限范围。` - } else if (errorMsg.includes('403')) { - errorMsg = `GitHub Copilot 访问被禁止 (403)。请检查: -1. 您的 GitHub Copilot 订阅是否有效且处于活跃状态 -2. 是否达到了 API 使用限制 -3. OAuth token 是否包含 'read:org' 权限范围 -4. 如果是组织账户,请确保组织已启用 Copilot 并且您有访问权限` - } else if (errorMsg.includes('fetch failed') || errorMsg.includes('network')) { - errorMsg = `网络连接失败。请检查: -1. 网络连接是否正常 -2. 代理设置是否正确 -3. 防火墙是否阻止了 GitHub API 访问` - } - return { isOk: false, - errorMsg + errorMsg: error instanceof Error ? error.message : 'Unknown error' } } } diff --git a/test/main/provider/githubCopilotDeviceFlow.test.ts b/test/main/provider/githubCopilotDeviceFlow.test.ts index ed82ff024f..35e194af51 100644 --- a/test/main/provider/githubCopilotDeviceFlow.test.ts +++ b/test/main/provider/githubCopilotDeviceFlow.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { GitHubCopilotDeviceFlow } from '@/provider/auth/githubCopilotDeviceFlow' +import { + disposeGlobalGitHubCopilotDeviceFlow, + getGitHubCopilotToken, + getGlobalGitHubCopilotDeviceFlow +} from '@/provider/auth/githubCopilotDeviceFlow' +import { extractProviderFailureMetadata } from '@/provider/providerFailure' vi.mock('electron', () => ({ BrowserWindow: vi.fn(function BrowserWindow() {}), @@ -8,16 +13,47 @@ vi.mock('electron', () => ({ shell: { openExternal: vi.fn() } })) -describe('GitHubCopilotDeviceFlow cancellation', () => { +describe('GitHub Copilot token exchange', () => { afterEach(() => { vi.unstubAllGlobals() + vi.unstubAllEnvs() + disposeGlobalGitHubCopilotDeviceFlow() + vi.restoreAllMocks() }) - const createAuthenticatedFlow = () => { - const flow = new GitHubCopilotDeviceFlow({ clientId: 'client', scope: 'read:user' }) - ;(flow as unknown as { oauthToken: string }).oauthToken = 'oauth-token' - return flow - } + it.each([ + [undefined, undefined, 'Iv1.b507a08c87ecfe98'], + [' ', ' ', 'Iv1.b507a08c87ecfe98'], + [undefined, ' env-client ', 'env-client'], + [' custom-client ', 'env-client', 'custom-client'] + ])( + 'requests a device code with the resolved client (%s, %s)', + async (override, environment, expected) => { + vi.stubEnv('VITE_GITHUB_CLIENT_ID', environment) + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + expires_in: 900, + interval: 5 + }) + ) + vi.stubGlobal('fetch', fetchMock) + const flow = getGlobalGitHubCopilotDeviceFlow(override) + vi.spyOn(flow as any, 'showUserCodeAndOpenBrowser').mockResolvedValue(undefined) + vi.spyOn(flow as any, 'pollForAccessToken').mockResolvedValue('oauth-token') + + await expect(flow.startDeviceFlow()).resolves.toBe('oauth-token') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://github.com/login/device/code', + expect.objectContaining({ + body: JSON.stringify({ client_id: expected, scope: 'read:user read:org' }) + }) + ) + } + ) it('forwards the exact caller signal to the Copilot token exchange', async () => { const fetchMock = vi.fn().mockImplementation((_url: string, options?: RequestInit) => { @@ -29,11 +65,10 @@ describe('GitHubCopilotDeviceFlow cancellation', () => { }) }) vi.stubGlobal('fetch', fetchMock) - const flow = createAuthenticatedFlow() const controller = new AbortController() const reason = { source: 'memory-caller' } - const token = flow.getCopilotToken(controller.signal) + const token = getGitHubCopilotToken('oauth-token', controller.signal) await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) controller.abort(reason) @@ -47,12 +82,99 @@ describe('GitHubCopilotDeviceFlow cancellation', () => { it('does not start a token exchange for a pre-aborted request', async () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - const flow = createAuthenticatedFlow() const controller = new AbortController() const reason = new DOMException('Already cancelled', 'AbortError') controller.abort(reason) - await expect(flow.getCopilotToken(controller.signal)).rejects.toBe(reason) + await expect(getGitHubCopilotToken('oauth-token', controller.signal)).rejects.toBe(reason) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('exchanges the current OAuth credential using the GitHub token protocol', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ token: 'session-token', expires_at: 2_000_000_000 })) + vi.stubGlobal('fetch', fetchMock) + + await expect(getGitHubCopilotToken('oauth-token')).resolves.toEqual({ + token: 'session-token', + expires_at: 2_000_000_000 + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.github.com/copilot_internal/v2/token', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'token oauth-token', + 'X-GitHub-Api-Version': '2025-04-01' + }) + }) + ) + }) + + it.each([ + {}, + { token: ' ', expires_at: 2_000_000_000 }, + { token: 'session\r\nsecret', expires_at: 2_000_000_000 }, + { token: 'session-token', expires_at: '2000000000' } + ])('rejects malformed successful token responses: %j', async (body) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(body))) + + await expect(getGitHubCopilotToken('oauth-token')).rejects.toThrow( + 'Invalid GitHub Copilot token response' + ) + }) + + it('rejects invalid OAuth credentials before they can reach HTTP errors or logs', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect(getGitHubCopilotToken('oauth\r\nsecret')).rejects.toThrow( + 'No GitHub OAuth token available' + ) expect(fetchMock).not.toHaveBeenCalled() }) + + it('preserves token failure details and retry headers without exposing credentials', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json( + { + error_details: { + code: 'not_authorized', + message: 'Denied oauth-token; Bearer session-secret; access_token="another-secret"' + }, + token: 'private-response-field' + }, + { + status: 403, + statusText: 'Forbidden', + headers: { 'x-github-request-id': 'ABCD:1234', 'retry-after': '3' } + } + ) + ) + ) + let failure: unknown + try { + await getGitHubCopilotToken('oauth-token') + } catch (error) { + failure = error + } + + expect(String(failure)).toContain('token exchange failed: 403 Forbidden') + expect(String(failure)).toContain('code=not_authorized; request_id=ABCD:1234') + for (const secret of [ + 'oauth-token', + 'session-secret', + 'another-secret', + 'private-response-field' + ]) { + expect(String(failure)).not.toContain(secret) + } + expect(extractProviderFailureMetadata(failure)).toEqual({ + statusCode: 403, + code: 'github_copilot_token_http_error', + retryHeaders: { 'retry-after': '3' } + }) + }) }) diff --git a/test/main/provider/githubCopilotOAuthService.test.ts b/test/main/provider/githubCopilotOAuthService.test.ts new file mode 100644 index 0000000000..de0d55168f --- /dev/null +++ b/test/main/provider/githubCopilotOAuthService.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OAuthService } from '@/provider/auth' +import type { LLM_PROVIDER } from '@shared/types/provider' + +const mocks = vi.hoisted(() => ({ + startDeviceFlow: vi.fn(), + startLogin: vi.fn(), + exchangeCodeForToken: vi.fn() +})) + +vi.mock('@/provider/auth/githubCopilotDeviceFlow', async (importOriginal) => ({ + ...(await importOriginal()), + getGlobalGitHubCopilotDeviceFlow: () => ({ startDeviceFlow: mocks.startDeviceFlow }) +})) +vi.mock('@/provider/auth/githubCopilotOAuth', () => ({ + createGitHubCopilotOAuth: () => ({ + startLogin: mocks.startLogin, + exchangeCodeForToken: mocks.exchangeCodeForToken + }) +})) +vi.mock('@/provider/auth/openaiCodex', () => ({ + initializeGlobalOpenAICodexAuth: vi.fn() +})) +vi.mock('@/provider/auth/xaiGrok', () => ({ + initializeGlobalXaiGrokAuth: vi.fn() +})) + +describe('GitHub Copilot login validation', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.resetAllMocks() + }) + + const setup = () => { + const provider: LLM_PROVIDER = { + id: 'github-copilot', + name: 'GitHub Copilot', + apiType: 'github-copilot', + apiKey: 'saved-oauth-token', + baseUrl: 'https://api.githubcopilot.com', + enable: true + } + mocks.startDeviceFlow.mockResolvedValue('new-oauth-token') + mocks.startLogin.mockResolvedValue('auth-code') + mocks.exchangeCodeForToken.mockResolvedValue('new-oauth-token') + const setProviderById = vi.fn() + const getProviderById = vi.fn((_providerId: string): LLM_PROVIDER | undefined => ({ + ...provider + })) + const service = new OAuthService({ getProviderById, setProviderById }, vi.fn()) + return { provider, service, setProviderById, getProviderById } + } + + for (const method of ['startGitHubCopilotDeviceFlowLogin', 'startGitHubCopilotLogin'] as const) { + it(`${method} saves credentials only after Copilot accepts them`, async () => { + const { provider, service, setProviderById } = setup() + const fetchMock = vi.fn(async (_url: string) => { + expect(setProviderById).not.toHaveBeenCalled() + return Response.json({ token: 'copilot-session', expires_at: 2_000_000_000 }) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(service[method](provider.id)).resolves.toBe(true) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0][0]).toBe('https://api.github.com/copilot_internal/v2/token') + expect(setProviderById).toHaveBeenCalledWith(provider.id, { + ...provider, + apiKey: 'new-oauth-token' + }) + expect(provider.apiKey).toBe('saved-oauth-token') + }) + + it(`${method} surfaces Copilot rejection without replacing saved credentials`, async () => { + const { provider, service, setProviderById } = setup() + const fetchMock = vi.fn().mockResolvedValue( + Response.json( + { error_details: { message: 'Copilot access denied', code: 'not_authorized' } }, + { + status: 403, + statusText: 'Forbidden', + headers: { 'x-github-request-id': 'ABCD:1234' } + } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(service[method](provider.id)).rejects.toThrow( + 'GitHub Copilot token exchange failed: 403 Forbidden - Copilot access denied; code=not_authorized; request_id=ABCD:1234' + ) + expect(fetchMock).toHaveBeenCalledOnce() + expect(setProviderById).not.toHaveBeenCalled() + expect(provider.apiKey).toBe('saved-oauth-token') + }) + + it.each(['credentials', 'client', 'protocol', 'removal'] as const)( + `${method} rejects stale authentication after a provider change: %s`, + async (change) => { + const { provider, service, setProviderById, getProviderById } = setup() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (change === 'credentials') provider.apiKey = 'newer-oauth-token' + if (change === 'client') provider.copilotClientId = 'new-client' + if (change === 'protocol') provider.apiType = 'openai' + if (change === 'removal') getProviderById.mockReturnValue(undefined) + return Response.json({ token: 'copilot-session', expires_at: 2_000_000_000 }) + }) + ) + + await expect(service[method](provider.id)).rejects.toThrow( + 'GitHub Copilot configuration changed during sign-in' + ) + expect(setProviderById).not.toHaveBeenCalled() + } + ) + + it(`${method} preserves unrelated settings changed during authentication`, async () => { + const { provider, service, setProviderById } = setup() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + provider.name = 'Renamed Copilot' + provider.enable = false + provider.customHeaders = { 'X-Custom': 'current' } + return Response.json({ token: 'copilot-session', expires_at: 2_000_000_000 }) + }) + ) + + await expect(service[method](provider.id)).resolves.toBe(true) + expect(setProviderById).toHaveBeenCalledWith(provider.id, { + ...provider, + apiKey: 'new-oauth-token' + }) + }) + } +}) diff --git a/test/main/provider/githubCopilotProvider.test.ts b/test/main/provider/githubCopilotProvider.test.ts index d073dae890..8b575b2853 100644 --- a/test/main/provider/githubCopilotProvider.test.ts +++ b/test/main/provider/githubCopilotProvider.test.ts @@ -3,26 +3,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { GithubCopilotProvider } from '../../../src/main/provider/providers/githubCopilotProvider' import { extractProviderFailureMetadata } from '@/provider/providerFailure' -import { getGlobalGitHubCopilotDeviceFlow } from '../../../src/main/provider/auth/githubCopilotDeviceFlow' +import { getGitHubCopilotToken } from '../../../src/main/provider/auth/githubCopilotDeviceFlow' -vi.mock('../../../src/main/platform/proxy', () => ({ - proxyConfig: { - getProxyUrl: vi.fn().mockReturnValue(null) - } -})) - -vi.mock('../../../src/main/provider/auth/githubCopilotDeviceFlow', () => ({ - getGlobalGitHubCopilotDeviceFlow: vi.fn(() => ({ - getCopilotToken: vi.fn(), - checkExistingAuth: vi.fn() - })), - GitHubCopilotDeviceFlow: vi.fn() +vi.mock('../../../src/main/provider/auth/githubCopilotDeviceFlow', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../../src/main/provider/auth/githubCopilotDeviceFlow') + >()), + getGitHubCopilotToken: vi.fn() })) describe('GithubCopilotProvider request timeout', () => { afterEach(() => { vi.useRealTimers() vi.unstubAllGlobals() + vi.resetAllMocks() }) it('does not promote catalog capabilities into provider facts', async () => { @@ -143,35 +137,32 @@ describe('GithubCopilotProvider request timeout', () => { ) }) - it('does not fall back to the provider API key after Device Flow is aborted', async () => { + it('does not retry a cancelled token exchange', async () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - const deviceFlow = { - getCopilotToken: vi.fn( - (signal?: AbortSignal) => - new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) - }) - ) - } + vi.mocked(getGitHubCopilotToken).mockImplementationOnce( + (_oauthToken, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) const provider = Object.create(GithubCopilotProvider.prototype) as any provider.provider = { id: 'github-copilot', name: 'GitHub Copilot', apiKey: 'fallback-oauth-token' } - provider.deviceFlow = deviceFlow provider.copilotToken = null provider.tokenExpiresAt = 0 const controller = new AbortController() const reason = new DOMException('Memory request aborted', 'AbortError') const token = provider.getCopilotToken(controller.signal) - await vi.waitFor(() => expect(deviceFlow.getCopilotToken).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(getGitHubCopilotToken).toHaveBeenCalledOnce()) controller.abort(reason) await expect(token).rejects.toBe(reason) - expect(deviceFlow.getCopilotToken).toHaveBeenCalledWith(controller.signal) + expect(getGitHubCopilotToken).toHaveBeenCalledWith('fallback-oauth-token', controller.signal) expect(fetchMock).not.toHaveBeenCalled() }) @@ -321,6 +312,60 @@ describe('GithubCopilotProvider request timeout', () => { ]) }) + it.each([true, false])( + 'uses the session token and Copilot client metadata (stream=%s)', + async (stream) => { + const fetchMock = vi + .fn() + .mockResolvedValue( + stream + ? new Response('data: [DONE]\n\n') + : Response.json({ choices: [{ message: { content: 'hello' } }] }) + ) + vi.stubGlobal('fetch', fetchMock) + vi.mocked(getGitHubCopilotToken).mockResolvedValueOnce({ + token: 'session-token', + expires_at: Date.now() / 1000 + 3600 + }) + const provider = Object.create(GithubCopilotProvider.prototype) as any + provider.provider = { + id: 'github-copilot', + apiKey: 'oauth-token', + baseUrl: 'https://api.githubcopilot.com' + } + provider.baseApiUrl = 'https://api.githubcopilot.com' + provider.providerSettings = { getModelConfig: vi.fn().mockReturnValue({}) } + + if (stream) { + for await (const _event of provider.coreStream( + [{ role: 'user', content: 'hello' }], + 'gpt-4o', + {}, + 0.7, + 1024, + [] + )) { + // Consume the stream to exercise the complete request lifecycle. + } + } else { + await provider.completions([{ role: 'user', content: 'hello' }], 'gpt-4o') + } + + expect(getGitHubCopilotToken).toHaveBeenCalledWith('oauth-token', undefined) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.githubcopilot.com/chat/completions', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer session-token', + 'Editor-Version': 'vscode/1.115.0', + 'Editor-Plugin-Version': 'copilot-chat/0.44.0', + 'Copilot-Integration-Id': 'vscode-chat' + }) + }) + ) + } + ) + it('refreshes cached auth state when provider config changes', () => { const provider = Object.create(GithubCopilotProvider.prototype) as GithubCopilotProvider & { provider: Record @@ -329,7 +374,6 @@ describe('GithubCopilotProvider request timeout', () => { customModels: unknown[] copilotToken: string | null tokenExpiresAt: number - deviceFlow: unknown } provider.provider = { @@ -345,10 +389,6 @@ describe('GithubCopilotProvider request timeout', () => { provider.customModels = [] provider.copilotToken = 'cached-token' provider.tokenExpiresAt = 123 - provider.deviceFlow = { id: 'old-flow' } - - vi.mocked(getGlobalGitHubCopilotDeviceFlow).mockReturnValueOnce({ id: 'new-flow' } as any) - provider.updateConfig({ id: 'github-copilot', name: 'GitHub Copilot', @@ -358,9 +398,7 @@ describe('GithubCopilotProvider request timeout', () => { expect(provider.copilotToken).toBeNull() expect(provider.tokenExpiresAt).toBe(0) - expect(provider.deviceFlow).toEqual({ id: 'new-flow' }) expect(provider.provider.copilotClientId).toBe('new-client') - expect(getGlobalGitHubCopilotDeviceFlow).toHaveBeenCalledWith('new-client') }) it('preserves assistant tool calls and empty reasoning_content when formatting messages', () => { @@ -397,48 +435,107 @@ describe('GithubCopilotProvider request timeout', () => { ]) }) - it('preserves cached auth state when acquiring a new device flow fails', () => { - const provider = Object.create(GithubCopilotProvider.prototype) as GithubCopilotProvider & { - provider: Record - providerSettings: Pick - models: unknown[] - customModels: unknown[] - copilotToken: string | null - tokenExpiresAt: number - deviceFlow: unknown - } + it('reuses a valid session and refreshes it after expiry', async () => { + vi.useFakeTimers() + const provider = Object.create(GithubCopilotProvider.prototype) as any + provider.provider = { id: 'github-copilot', apiKey: 'oauth-token' } + vi.mocked(getGitHubCopilotToken) + .mockResolvedValueOnce({ token: 'first-session', expires_at: Date.now() / 1000 + 60 }) + .mockResolvedValueOnce({ token: 'second-session', expires_at: Date.now() / 1000 + 120 }) + + await expect(provider.check()).resolves.toEqual({ isOk: true, errorMsg: null }) + await expect(provider.check()).resolves.toEqual({ isOk: true, errorMsg: null }) + expect(getGitHubCopilotToken).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(60_000) + await expect(provider.check()).resolves.toEqual({ isOk: true, errorMsg: null }) + expect(getGitHubCopilotToken).toHaveBeenCalledTimes(2) + }) - provider.provider = { - id: 'github-copilot', - name: 'GitHub Copilot', - copilotClientId: 'old-client' - } + it('preserves saved credentials and the original error when token exchange fails', async () => { + const provider = Object.create(GithubCopilotProvider.prototype) as any + provider.provider = { id: 'github-copilot', apiKey: 'oauth-token', enable: true } + provider.fetchModels = vi.fn().mockResolvedValue([]) + provider.autoEnableModelsIfNeeded = vi.fn().mockResolvedValue(undefined) + const message = 'GitHub Copilot token exchange failed: 403 Forbidden - request_id=ABCD:1234' + vi.mocked(getGitHubCopilotToken).mockRejectedValueOnce(new Error(message)) + + await provider.init() + await expect(provider.check()).resolves.toEqual({ isOk: false, errorMsg: message }) + expect(provider.provider.apiKey).toBe('oauth-token') + expect(getGitHubCopilotToken).toHaveBeenCalledOnce() + }) + + it('keeps the request valid when unrelated provider settings change during exchange', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ choices: [{ message: { content: 'hello' } }] })) + vi.stubGlobal('fetch', fetchMock) + const provider = Object.create(GithubCopilotProvider.prototype) as any + provider.provider = { id: 'github-copilot', apiKey: 'same-account', name: 'Copilot' } + provider.baseApiUrl = 'https://api.githubcopilot.com' provider.providerSettings = { getProviderModels: vi.fn(() => []), - getCustomModels: vi.fn(() => []) + getCustomModels: vi.fn(() => []), + getModelConfig: vi.fn(() => ({})) } - provider.models = [] - provider.customModels = [] - provider.copilotToken = 'cached-token' - provider.tokenExpiresAt = 123 - provider.deviceFlow = { id: 'old-flow' } - - vi.mocked(getGlobalGitHubCopilotDeviceFlow).mockImplementationOnce(() => { - throw new Error('device flow init failed') + vi.mocked(getGitHubCopilotToken).mockImplementationOnce(async () => { + provider.updateConfig({ ...provider.provider, name: 'Renamed Copilot' }) + return { token: 'same-account-session', expires_at: Date.now() / 1000 + 3600 } }) - expect(() => - provider.updateConfig({ - id: 'github-copilot', - name: 'GitHub Copilot', - copilotClientId: 'new-client', - enable: true - } as any) - ).toThrow('device flow init failed') - - expect(provider.copilotToken).toBe('cached-token') - expect(provider.tokenExpiresAt).toBe(123) - expect(provider.deviceFlow).toEqual({ id: 'old-flow' }) - expect(provider.provider.copilotClientId).toBe('old-client') + await expect( + provider.completions([{ role: 'user', content: 'hello' }], 'gpt-4o') + ).resolves.toEqual({ + content: 'hello' + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.githubcopilot.com/chat/completions', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer same-account-session' }) + }) + ) }) + + it.each([true, false])( + 'rejects the original request when accounts change during exchange (stream=%s)', + async (stream) => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const provider = Object.create(GithubCopilotProvider.prototype) as any + provider.provider = { id: 'github-copilot', apiKey: 'old-account' } + provider.baseApiUrl = 'https://api.githubcopilot.com' + provider.providerSettings = { + getProviderModels: vi.fn(() => []), + getCustomModels: vi.fn(() => []), + getModelConfig: vi.fn(() => ({})) + } + let finishExchange!: (value: { token: string; expires_at: number }) => void + vi.mocked(getGitHubCopilotToken).mockImplementationOnce(() => { + return new Promise((resolve) => { + finishExchange = resolve + }) + }) + const oldRequest = stream + ? provider + .coreStream([{ role: 'user', content: 'hello' }], 'gpt-4o', {}, 0.7, 1024, []) + .next() + : provider.completions([{ role: 'user', content: 'hello' }], 'gpt-4o') + const rejection = expect(oldRequest).rejects.toThrow( + 'GitHub Copilot configuration changed during token exchange' + ) + provider.updateConfig({ id: 'github-copilot', apiKey: 'new-account' }) + finishExchange({ token: 'old-session', expires_at: Date.now() / 1000 + 3600 }) + await rejection + expect(fetchMock).not.toHaveBeenCalled() + expect(getGitHubCopilotToken).toHaveBeenCalledOnce() + vi.mocked(getGitHubCopilotToken).mockResolvedValueOnce({ + token: 'new-session', + expires_at: Date.now() / 1000 + 3600 + }) + + await expect(provider.check()).resolves.toEqual({ isOk: true, errorMsg: null }) + expect(getGitHubCopilotToken).toHaveBeenLastCalledWith('new-account', undefined) + } + ) }) diff --git a/test/renderer/components/GitHubCopilotOAuth.test.ts b/test/renderer/components/GitHubCopilotOAuth.test.ts index f07dac1abb..2a195887fc 100644 --- a/test/renderer/components/GitHubCopilotOAuth.test.ts +++ b/test/renderer/components/GitHubCopilotOAuth.test.ts @@ -116,4 +116,24 @@ describe('GitHubCopilotOAuth', () => { expect(oauthClient.startGitHubCopilotLogin).toHaveBeenCalledWith('github-copilot') expect(wrapper.emitted('auth-success')).toHaveLength(2) }) + + it.each([ + ['startGitHubCopilotDeviceFlowLogin', 0], + ['startGitHubCopilotLogin', 1] + ] as const)( + 'shows a Copilot rejection from %s without emitting login success', + async (method, buttonIndex) => { + const { wrapper, oauthClient } = await setup() + const message = 'GitHub Copilot token exchange failed: 403 Forbidden - request_id=ABCD:1234' + oauthClient[method].mockRejectedValueOnce(new Error(message)) + + await wrapper.findAll('button')[buttonIndex].trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain(message) + expect(wrapper.emitted('auth-error')).toEqual([[message]]) + expect(wrapper.emitted('auth-success')).toBeUndefined() + wrapper.unmount() + } + ) })