diff --git a/src/agent/infra/llm/providers/github-copilot.ts b/src/agent/infra/llm/providers/github-copilot.ts new file mode 100644 index 000000000..520ed4c2c --- /dev/null +++ b/src/agent/infra/llm/providers/github-copilot.ts @@ -0,0 +1,42 @@ +import {createOpenAICompatible} from '@ai-sdk/openai-compatible' + +import type {GeneratorFactoryConfig, ProviderModule} from './types.js' + +import {COPILOT_API_BASE_URL, COPILOT_REQUEST_HEADERS} from '../../../../shared/constants/copilot.js' +import {AiSdkContentGenerator} from '../generators/ai-sdk-content-generator.js' + +export function isCopilotClaudeModel(model: string): boolean { + return model.startsWith('claude-') +} + +export const githubCopilotProvider: ProviderModule = { + authType: 'api-key', + category: 'popular', + createGenerator(config: GeneratorFactoryConfig) { + const baseUrl = config.baseUrl || COPILOT_API_BASE_URL + const apiKey = config.apiKey || '' + const headers = { + ...config.headers, + ...COPILOT_REQUEST_HEADERS, + } + + const provider = createOpenAICompatible({ + apiKey, + baseURL: baseUrl, + headers, + name: 'github-copilot', + }) + + return new AiSdkContentGenerator({ + charsPerToken: isCopilotClaudeModel(config.model) ? 3.5 : undefined, + model: provider.chatModel(config.model), + }) + }, + defaultModel: 'claude-sonnet-4.6', + description: 'All models via GitHub Copilot subscription', + envVars: [], + id: 'github-copilot', + name: 'GitHub Copilot', + priority: 8, + providerType: 'openai', +} diff --git a/src/agent/infra/llm/providers/index.ts b/src/agent/infra/llm/providers/index.ts index 059afb107..80fae1091 100644 --- a/src/agent/infra/llm/providers/index.ts +++ b/src/agent/infra/llm/providers/index.ts @@ -14,6 +14,7 @@ import {byteroverProvider} from './byterover.js' import {cerebrasProvider} from './cerebras.js' import {cohereProvider} from './cohere.js' import {deepinfraProvider} from './deepinfra.js' +import {githubCopilotProvider} from './github-copilot.js' import {glmProvider} from './glm.js' import {googleProvider} from './google.js' import {groqProvider} from './groq.js' @@ -38,6 +39,7 @@ const PROVIDER_MODULES: Readonly> = { cerebras: cerebrasProvider, cohere: cohereProvider, deepinfra: deepinfraProvider, + 'github-copilot': githubCopilotProvider, glm: glmProvider, google: googleProvider, groq: groqProvider, diff --git a/src/oclif/commands/providers/connect.ts b/src/oclif/commands/providers/connect.ts index 0f654015d..c97a203e3 100644 --- a/src/oclif/commands/providers/connect.ts +++ b/src/oclif/commands/providers/connect.ts @@ -159,6 +159,8 @@ export default class ProviderConnect extends Command { throw new Error(`Provider "${providerId}" does not support OAuth. Use --api-key instead.`) } + // --code is only valid for code-paste providers (e.g., Anthropic). + // Browser-callback providers like OpenAI handle the code exchange automatically. if (code && provider.oauthCallbackMode !== 'code-paste') { throw new Error( `Provider "${providerId}" uses browser-based OAuth and does not accept --code.\n` + @@ -185,10 +187,22 @@ export default class ProviderConnect extends Command { throw new Error(startResponse.error ?? 'Failed to start OAuth flow') } - onProgress?.(`\nOpen this URL to authenticate:\n ${startResponse.authUrl}\n`) + // Always print auth URL (user's machine may not support browser launch) + if (startResponse.callbackMode === 'device') { + onProgress?.(`\nOpen this URL and enter the code below:`) + onProgress?.(` ${startResponse.verificationUri ?? startResponse.authUrl}`) + onProgress?.(`\n Code: ${startResponse.userCode}\n`) + onProgress?.('Waiting for authorization...') + } else { + onProgress?.(`\nOpen this URL to authenticate:\n ${startResponse.authUrl}\n`) + } + + // 3. Handle based on callback mode + if (startResponse.callbackMode === 'auto' || startResponse.callbackMode === 'device') { + if (startResponse.callbackMode === 'auto') { + onProgress?.('Waiting for authentication in browser...') + } - if (startResponse.callbackMode === 'auto') { - onProgress?.('Waiting for authentication in browser...') const awaitResponse = await client.requestWithAck( ProviderEvents.AWAIT_OAUTH_CALLBACK, {providerId}, @@ -201,6 +215,7 @@ export default class ProviderConnect extends Command { return {providerName: provider.name, showInstructions: false} } + // code-paste mode: print instructions and exit onProgress?.('Copy the authorization code from the browser and run:') onProgress?.(` brv providers connect ${providerId} --oauth --code `) return {providerName: provider.name, showInstructions: true} diff --git a/src/server/core/domain/entities/provider-registry.ts b/src/server/core/domain/entities/provider-registry.ts index 5992dbb64..b7799c615 100644 --- a/src/server/core/domain/entities/provider-registry.ts +++ b/src/server/core/domain/entities/provider-registry.ts @@ -23,8 +23,8 @@ export interface OAuthModeConfig { * OAuth configuration for a provider. */ export interface ProviderOAuthConfig { - /** How the callback is received: local server ('auto') or user pastes code ('code-paste') */ - readonly callbackMode: 'auto' | 'code-paste' + /** How the callback is received: local server ('auto'), user pastes code ('code-paste'), or device flow polling ('device') */ + readonly callbackMode: 'auto' | 'code-paste' | 'device' /** Port for local callback server (auto mode only) */ readonly callbackPort?: number /** OAuth client ID */ @@ -144,6 +144,26 @@ export const PROVIDER_REGISTRY: Readonly> = { name: 'DeepInfra', priority: 10, }, + 'github-copilot': { + baseUrl: 'https://api.githubcopilot.com', + category: 'popular', + defaultModel: 'claude-sonnet-4.6', + description: 'All models via GitHub Copilot subscription', + headers: {}, + id: 'github-copilot', + modelsEndpoint: '/models', + name: 'GitHub Copilot', + oauth: { + callbackMode: 'device', + clientId: 'Iv1.b507a08c87ecfe98', + modes: [{authUrl: 'https://github.com/login/device', id: 'default', label: 'Sign in with GitHub'}], + redirectUri: '', + scopes: 'read:user', + tokenContentType: 'json', + tokenUrl: '', + }, + priority: 8, + }, glm: { apiKeyUrl: 'https://open.z.ai', baseUrl: 'https://api.z.ai/api/paas/v4', @@ -375,7 +395,7 @@ export function providerRequiresApiKey(id: string, authMethod?: 'api-key' | 'oau if (!provider) return false // Internal providers (byterover) don't need API keys. // OpenAI Compatible has optional API key (handled in provider-command). - if (id === 'byterover' || id === 'openai-compatible') return false + if (id === 'byterover' || id === 'openai-compatible' || id === 'github-copilot') return false return true } diff --git a/src/server/infra/http/provider-model-fetcher-registry.ts b/src/server/infra/http/provider-model-fetcher-registry.ts index f49c769c7..b79296dfc 100644 --- a/src/server/infra/http/provider-model-fetcher-registry.ts +++ b/src/server/infra/http/provider-model-fetcher-registry.ts @@ -12,6 +12,7 @@ import {FileProviderConfigStore} from '../storage/file-provider-config-store.js' import { AnthropicModelFetcher, ChatBasedModelFetcher, + CopilotModelFetcher, GoogleModelFetcher, OpenAICompatibleModelFetcher, OpenAIModelFetcher, @@ -75,6 +76,12 @@ export async function getModelFetcher(providerId: string): Promise { + const forceRefresh = options?.forceRefresh ?? false + if (!forceRefresh && this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) { + return this.cache.models + } + + const response = await axios.get(`${COPILOT_API_BASE_URL}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + ...COPILOT_REQUEST_HEADERS, + }, + httpAgent: ProxyConfig.getProxyAgent(), + httpsAgent: ProxyConfig.getProxyAgent(), + proxy: false, + timeout: 30_000, + }) + + const responseData = response.data + const modelList: Array> = Array.isArray(responseData) + ? responseData + : (responseData.data ?? responseData.models ?? []) + + const models: ProviderModelInfo[] = modelList.map((model) => { + const id = String(model.id ?? model.name ?? '') + return { + contextLength: typeof model.context_length === 'number' ? model.context_length : 200_000, + id, + isFree: false, + name: typeof model.name === 'string' ? model.name : id, + pricing: {inputPerM: 0, outputPerM: 0}, + provider: 'GitHub Copilot', + } + }) + + models.sort((a, b) => a.id.localeCompare(b.id)) + this.cache = {models, timestamp: Date.now()} + return models + } + + async validateApiKey(apiKey: string): Promise<{error?: string; isValid: boolean}> { + try { + await axios.get(`${COPILOT_API_BASE_URL}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + ...COPILOT_REQUEST_HEADERS, + }, + httpAgent: ProxyConfig.getProxyAgent(), + httpsAgent: ProxyConfig.getProxyAgent(), + proxy: false, + timeout: 15_000, + }) + return {isValid: true} + } catch (error) { + if (isAxiosError(error)) { + if (error.response?.status === 401) { + return {error: 'Invalid or expired Copilot token', isValid: false} + } + + if (error.response?.status === 403) { + return {error: 'Copilot token does not have required permissions', isValid: false} + } + + return {error: `API error: ${error.response?.statusText ?? error.message}`, isValid: false} + } + + return {error: error instanceof Error ? error.message : 'Unknown error', isValid: false} + } + } +} + // ============================================================================ // OpenRouter Model Fetcher (wraps existing client) // ============================================================================ diff --git a/src/server/infra/provider-oauth/device-flow.ts b/src/server/infra/provider-oauth/device-flow.ts new file mode 100644 index 000000000..a1b49a6f4 --- /dev/null +++ b/src/server/infra/provider-oauth/device-flow.ts @@ -0,0 +1,178 @@ +/* eslint-disable camelcase */ +import axios, {isAxiosError} from 'axios' + +import { + COPILOT_TOKEN_URL, + DEVICE_FLOW_INTERVAL_BUFFER, + GITHUB_API_VERSION, + GITHUB_DEVICE_CODE_URL, + GITHUB_OAUTH_TOKEN_URL, +} from '../../../shared/constants/copilot.js' +import {extractOAuthErrorFields, ProviderTokenExchangeError} from './errors.js' + +export type DeviceCodeResponse = { + deviceCode: string + expiresIn: number + interval: number + userCode: string + verificationUri: string +} + +export type RequestDeviceCodeParams = { + clientId: string + scope: string +} + +export type PollForAccessTokenParams = { + clientId: string + deviceCode: string + expiresIn: number + interval: number + intervalBuffer?: number + signal?: AbortSignal + /** Seconds added to interval on slow_down response (default 5 per RFC). Injectable for testing. */ + slowDownIncrement?: number +} + +export type CopilotTokenResponse = { + expiresAt: number + token: string +} + +type DeviceCodeApiResponse = { + device_code: string + expires_in: number + interval: number + user_code: string + verification_uri: string +} + +type OAuthTokenApiResponse = { + access_token?: string + error?: string + error_description?: string +} + +type CopilotTokenApiResponse = { + expires_at: number + token: string +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +export async function requestDeviceCode(params: RequestDeviceCodeParams): Promise { + const body = new URLSearchParams({ + client_id: params.clientId, + scope: params.scope, + }).toString() + + const response = await axios.post(GITHUB_DEVICE_CODE_URL, body, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + + const {data} = response + return { + deviceCode: data.device_code, + expiresIn: data.expires_in, + interval: data.interval, + userCode: data.user_code, + verificationUri: data.verification_uri, + } +} + +export async function pollForAccessToken(params: PollForAccessTokenParams): Promise { + if (params.signal?.aborted) { + throw new Error('Device flow cancelled') + } + + const buffer = params.intervalBuffer ?? DEVICE_FLOW_INTERVAL_BUFFER + let currentInterval = params.interval + const deadline = Date.now() + params.expiresIn * 1000 + + /* eslint-disable no-await-in-loop */ + while (Date.now() < deadline) { + if (params.signal?.aborted) { + throw new Error('Device flow cancelled') + } + + const body = new URLSearchParams({ + client_id: params.clientId, + device_code: params.deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString() + + const response = await axios.post(GITHUB_OAUTH_TOKEN_URL, body, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + + const {data} = response + + if (data.access_token) { + return data.access_token + } + + if (data.error === 'authorization_pending') { + await delay((currentInterval + buffer) * 1000) + continue + } + + if (data.error === 'slow_down') { + currentInterval += params.slowDownIncrement ?? 5 + await delay((currentInterval + buffer) * 1000) + continue + } + + if (data.error === 'expired_token') { + throw new Error('Device code expired') + } + + if (data.error === 'access_denied') { + throw new Error('Authorization denied by user') + } + + throw new Error(data.error_description ?? data.error ?? 'Unknown error during device flow') + } + /* eslint-enable no-await-in-loop */ + + throw new Error('Device code expired') +} + +export async function exchangeForCopilotToken(githubToken: string): Promise { + let response: {data: CopilotTokenApiResponse} + try { + response = await axios.get(COPILOT_TOKEN_URL, { + headers: { + Accept: 'application/json', + Authorization: `token ${githubToken}`, + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }, + }) + } catch (error) { + if (isAxiosError(error)) { + const data: unknown = error.response?.data + const errorFields = extractOAuthErrorFields(data) + throw new ProviderTokenExchangeError({ + errorCode: errorFields.error ?? error.code, + message: errorFields.error_description ?? `Copilot token exchange failed: ${error.message}`, + statusCode: error.response?.status, + }) + } + + throw error + } + + return { + expiresAt: response.data.expires_at, + token: response.data.token, + } +} diff --git a/src/server/infra/provider-oauth/index.ts b/src/server/infra/provider-oauth/index.ts index 03b0e4dfd..ace806171 100644 --- a/src/server/infra/provider-oauth/index.ts +++ b/src/server/infra/provider-oauth/index.ts @@ -1,4 +1,5 @@ export * from './callback-server.js' +export * from './device-flow.js' export * from './errors.js' export * from './jwt-utils.js' export * from './pkce-service.js' diff --git a/src/server/infra/provider-oauth/token-refresh-manager.ts b/src/server/infra/provider-oauth/token-refresh-manager.ts index 5cf844167..07ede9ee3 100644 --- a/src/server/infra/provider-oauth/token-refresh-manager.ts +++ b/src/server/infra/provider-oauth/token-refresh-manager.ts @@ -3,11 +3,13 @@ import type {IProviderKeychainStore} from '../../core/interfaces/i-provider-keyc import type {IProviderOAuthTokenStore} from '../../core/interfaces/i-provider-oauth-token-store.js' import type {ITokenRefreshManager} from '../../core/interfaces/i-token-refresh-manager.js' import type {ITransportServer} from '../../core/interfaces/transport/i-transport-server.js' +import type {CopilotTokenResponse} from './device-flow.js' import type {ProviderTokenResponse, RefreshTokenExchangeParams, TokenRequestContentType} from './types.js' import {getProviderById} from '../../core/domain/entities/provider-registry.js' import {TransportDaemonEventNames} from '../../core/domain/transport/schemas.js' import {processLog} from '../../utils/process-logger.js' +import {exchangeForCopilotToken as defaultExchangeForCopilotToken} from './device-flow.js' import {isPermanentOAuthError} from './errors.js' import {exchangeRefreshToken as defaultExchangeRefreshToken} from './refresh-token-exchange.js' import {computeExpiresAt} from './types.js' @@ -18,6 +20,7 @@ export {type ITokenRefreshManager} from '../../core/interfaces/i-token-refresh-m export const REFRESH_THRESHOLD_MS = 5 * 60 * 1000 export interface TokenRefreshManagerDeps { + exchangeForCopilotToken?: (githubToken: string) => Promise exchangeRefreshToken?: (params: RefreshTokenExchangeParams) => Promise providerConfigStore: IProviderConfigStore providerKeychainStore: IProviderKeychainStore @@ -34,12 +37,14 @@ export interface TokenRefreshManagerDeps { */ export class TokenRefreshManager implements ITokenRefreshManager { private readonly deps: TokenRefreshManagerDeps + private readonly exchangeForCopilotToken: (githubToken: string) => Promise private readonly exchangeRefreshToken: (params: RefreshTokenExchangeParams) => Promise /** Per-provider mutex to serialize concurrent refresh attempts */ private readonly pendingRefreshes = new Map>() constructor(deps: TokenRefreshManagerDeps) { this.deps = deps + this.exchangeForCopilotToken = deps.exchangeForCopilotToken ?? defaultExchangeForCopilotToken this.exchangeRefreshToken = deps.exchangeRefreshToken ?? defaultExchangeRefreshToken } @@ -58,6 +63,24 @@ export class TokenRefreshManager implements ITokenRefreshManager { return promise } + private async doCopilotRefresh(providerId: string, githubToken: string): Promise { + try { + const copilotToken = await this.exchangeForCopilotToken(githubToken) + + await this.deps.providerKeychainStore.setApiKey(providerId, copilotToken.token) + + await this.deps.providerOAuthTokenStore.set(providerId, { + expiresAt: new Date(copilotToken.expiresAt * 1000).toISOString(), + refreshToken: githubToken, + }) + + this.deps.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {}) + return true + } catch (error) { + return this.handleRefreshError(providerId, error) + } + } + private async doRefresh(providerId: string): Promise { // 1. Check if provider is OAuth-connected const config = await this.deps.providerConfigStore.read() @@ -86,6 +109,11 @@ export class TokenRefreshManager implements ITokenRefreshManager { } const oauthConfig = providerDef.oauth + + if (oauthConfig.callbackMode === 'device') { + return this.doCopilotRefresh(providerId, tokenRecord.refreshToken) + } + const contentType: TokenRequestContentType = oauthConfig.tokenContentType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json' @@ -111,22 +139,26 @@ export class TokenRefreshManager implements ITokenRefreshManager { this.deps.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {}) return true } catch (error) { - // 7. Permanent failure (token revoked, client invalid): disconnect provider, clean up - if (isPermanentOAuthError(error)) { - await this.deps.providerConfigStore.disconnectProvider(providerId).catch(() => {}) - await this.deps.providerOAuthTokenStore.delete(providerId).catch(() => {}) - await this.deps.providerKeychainStore.deleteApiKey(providerId).catch(() => {}) - this.deps.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {}) - return false - } - - // Transient errors (network timeout, 5xx): keep credentials intact. - // Return true so the caller uses the existing access token from the keychain, - // which may still be valid until it actually expires. - processLog( - `[TokenRefreshManager] Transient refresh error for ${providerId}: ${error instanceof Error ? error.message : String(error)}`, - ) - return true + return this.handleRefreshError(providerId, error) } } + + private async handleRefreshError(providerId: string, error: unknown): Promise { + // 7. Permanent failure (token revoked, client invalid): disconnect provider, clean up + if (isPermanentOAuthError(error)) { + await this.deps.providerConfigStore.disconnectProvider(providerId).catch(() => {}) + await this.deps.providerOAuthTokenStore.delete(providerId).catch(() => {}) + await this.deps.providerKeychainStore.deleteApiKey(providerId).catch(() => {}) + this.deps.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {}) + return false + } + + // Transient errors (network timeout, 5xx): keep credentials intact. + // Return true so the caller uses the existing access token from the keychain, + // which may still be valid until it actually expires. + processLog( + `[TokenRefreshManager] Transient refresh error for ${providerId}: ${error instanceof Error ? error.message : String(error)}`, + ) + return true + } } diff --git a/src/server/infra/provider/provider-config-resolver.ts b/src/server/infra/provider/provider-config-resolver.ts index 6790b00b9..d04e6b856 100644 --- a/src/server/infra/provider/provider-config-resolver.ts +++ b/src/server/infra/provider/provider-config-resolver.ts @@ -12,6 +12,7 @@ import type {IProviderOAuthTokenStore} from '../../core/interfaces/i-provider-oa import type {ITokenRefreshManager} from '../../core/interfaces/i-token-refresh-manager.js' import type {IAuthStateStore} from '../../core/interfaces/state/i-auth-state-store.js' +import {COPILOT_API_BASE_URL, COPILOT_REQUEST_HEADERS} from '../../../shared/constants/copilot.js' import {CHATGPT_OAUTH_BASE_URL, CHATGPT_OAUTH_ORIGINATOR} from '../../../shared/constants/oauth.js' import {getProviderById, providerRequiresApiKey} from '../../core/domain/entities/provider-registry.js' import {type ProviderConfigResponse} from '../../core/domain/transport/schemas.js' @@ -131,6 +132,42 @@ export async function resolveProviderConfig( } switch (activeProvider) { + case 'github-copilot': { + const providerConfig = config.providers[activeProvider] + if (!providerConfig) { + return {activeModel, activeProvider, maxInputTokens} + } + + const {authMethod} = providerConfig + + if (authMethod === 'oauth' && tokenRefreshManager) { + try { + const refreshed = await tokenRefreshManager.refreshIfNeeded(activeProvider) + if (!refreshed) { + return {activeModel, activeProvider, authMethod, maxInputTokens, providerKeyMissing: true} + } + + apiKey = (await providerKeychainStore.getApiKey(activeProvider)) ?? apiKey + } catch { + return {activeModel, activeProvider, authMethod, maxInputTokens, providerKeyMissing: true} + } + } + + return { + activeModel, + activeProvider, + authMethod, + maxInputTokens, + provider: activeProvider, + providerApiKey: apiKey || undefined, + providerBaseUrl: COPILOT_API_BASE_URL, + providerHeaders: { + ...COPILOT_REQUEST_HEADERS, + }, + providerKeyMissing: !apiKey, + } + } + case 'openai-compatible': { return { activeModel, diff --git a/src/server/infra/transport/handlers/provider-handler.ts b/src/server/infra/transport/handlers/provider-handler.ts index 6bcd83d21..6ae96a4cf 100644 --- a/src/server/infra/transport/handlers/provider-handler.ts +++ b/src/server/infra/transport/handlers/provider-handler.ts @@ -1,10 +1,17 @@ import type {ProviderDTO} from '../../../../shared/transport/types/dto.js' +import type {ProviderOAuthConfig} from '../../../core/domain/entities/provider-registry.js' import type {IProviderConfigStore} from '../../../core/interfaces/i-provider-config-store.js' import type {IProviderKeychainStore} from '../../../core/interfaces/i-provider-keychain-store.js' import type {IProviderOAuthTokenStore} from '../../../core/interfaces/i-provider-oauth-token-store.js' import type {IBrowserLauncher} from '../../../core/interfaces/services/i-browser-launcher.js' import type {IAuthStateStore} from '../../../core/interfaces/state/i-auth-state-store.js' import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' +import type { + CopilotTokenResponse, + DeviceCodeResponse, + PollForAccessTokenParams, + RequestDeviceCodeParams, +} from '../../provider-oauth/device-flow.js' import type { PkceParameters, ProviderTokenResponse, @@ -42,6 +49,11 @@ import {TransportDaemonEventNames} from '../../../core/domain/transport/schemas. import {getErrorMessage} from '../../../utils/error-helpers.js' import {processLog} from '../../../utils/process-logger.js' import {validateApiKey as validateApiKeyViaFetcher} from '../../http/provider-model-fetcher-registry.js' +import { + exchangeForCopilotToken as defaultExchangeForCopilotToken, + pollForAccessToken as defaultPollForAccessToken, + requestDeviceCode as defaultRequestDeviceCode, +} from '../../provider-oauth/device-flow.js' import { computeExpiresAt, exchangeCodeForTokens as defaultExchangeCodeForTokens, @@ -55,8 +67,11 @@ type OAuthFlowState = { awaitInProgress?: boolean callbackServer?: ProviderCallbackServer clientId: string - codeVerifier: string - state: string + codeVerifier?: string + deviceCode?: string + expiresIn?: number + interval?: number + state?: string } export interface ProviderHandlerDeps { @@ -66,11 +81,17 @@ export interface ProviderHandlerDeps { createCallbackServer?: (options: {callbackPath?: string; port: number}) => ProviderCallbackServer /** Token exchange function (injectable for testing) */ exchangeCodeForTokens?: (params: TokenExchangeParams) => Promise + /** Copilot token exchange function (injectable for testing) */ + exchangeForCopilotToken?: (githubToken: string) => Promise /** PKCE generator function (injectable for testing) */ generatePkce?: () => PkceParameters + /** Device flow polling function (injectable for testing) */ + pollForAccessToken?: (params: PollForAccessTokenParams) => Promise providerConfigStore: IProviderConfigStore providerKeychainStore: IProviderKeychainStore providerOAuthTokenStore: IProviderOAuthTokenStore + /** Device code request function (injectable for testing) */ + requestDeviceCode?: (params: RequestDeviceCodeParams) => Promise transport: ITransportServer } @@ -83,11 +104,14 @@ export class ProviderHandler { private readonly browserLauncher: IBrowserLauncher private readonly createCallbackServer: (options: {callbackPath?: string; port: number}) => ProviderCallbackServer private readonly exchangeCodeForTokens: (params: TokenExchangeParams) => Promise + private readonly exchangeForCopilotToken: (githubToken: string) => Promise private readonly generatePkce: () => PkceParameters private readonly oauthFlows = new Map() + private readonly pollForAccessToken: (params: PollForAccessTokenParams) => Promise private readonly providerConfigStore: IProviderConfigStore private readonly providerKeychainStore: IProviderKeychainStore private readonly providerOAuthTokenStore: IProviderOAuthTokenStore + private readonly requestDeviceCode: (params: RequestDeviceCodeParams) => Promise private readonly transport: ITransportServer constructor(deps: ProviderHandlerDeps) { @@ -95,10 +119,13 @@ export class ProviderHandler { this.browserLauncher = deps.browserLauncher this.createCallbackServer = deps.createCallbackServer ?? ((options) => new ProviderCallbackServer(options)) this.exchangeCodeForTokens = deps.exchangeCodeForTokens ?? defaultExchangeCodeForTokens + this.exchangeForCopilotToken = deps.exchangeForCopilotToken ?? defaultExchangeForCopilotToken this.generatePkce = deps.generatePkce ?? defaultGeneratePkce + this.pollForAccessToken = deps.pollForAccessToken ?? defaultPollForAccessToken this.providerConfigStore = deps.providerConfigStore this.providerKeychainStore = deps.providerKeychainStore this.providerOAuthTokenStore = deps.providerOAuthTokenStore + this.requestDeviceCode = deps.requestDeviceCode ?? defaultRequestDeviceCode this.transport = deps.transport } @@ -120,6 +147,89 @@ export class ProviderHandler { }) } + private async awaitCallbackFlow( + providerId: string, + flow: OAuthFlowState, + ): Promise { + const callbackResult = await flow.callbackServer!.waitForCallback(flow.state!) + + const providerDef = getProviderById(providerId) + if (!providerDef?.oauth) { + return {error: 'Provider does not support OAuth', success: false} + } + + const oauthConfig = providerDef.oauth + const contentType: TokenRequestContentType = + oauthConfig.tokenContentType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json' + + const tokens = await this.exchangeCodeForTokens({ + clientId: oauthConfig.clientId, + code: callbackResult.code, + codeVerifier: flow.codeVerifier!, + contentType, + redirectUri: oauthConfig.redirectUri, + tokenUrl: oauthConfig.tokenUrl, + }) + + // Parse JWT id_token for account ID + const oauthAccountId = tokens.id_token ? parseAccountIdFromIdToken(tokens.id_token) : undefined + + // Store access token as the "API key" in keychain + await this.providerKeychainStore.setApiKey(providerId, tokens.access_token) + + // Store refresh token + expiry in encrypted OAuth token store + if (tokens.refresh_token) { + const expiresAt = tokens.expires_in ? computeExpiresAt(tokens.expires_in) : computeExpiresAt(3600) // 1-hour default when provider omits expires_in + await this.providerOAuthTokenStore.set(providerId, { + expiresAt, + refreshToken: tokens.refresh_token, + }) + } + + // Connect provider — secrets stored in keychain + encrypted token store, not config + // OAuth providers may define their own default model (e.g., Codex for OpenAI OAuth) + const defaultModel = oauthConfig.defaultModel ?? providerDef.defaultModel + await this.providerConfigStore.connectProvider(providerId, { + activeModel: defaultModel, + authMethod: 'oauth', + oauthAccountId, + }) + + this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {}) + return {success: true} + } + + private async awaitDeviceFlow( + providerId: string, + flow: OAuthFlowState, + ): Promise { + const githubToken = await this.pollForAccessToken({ + clientId: getProviderById(providerId)!.oauth!.clientId, + deviceCode: flow.deviceCode!, + expiresIn: flow.expiresIn!, + interval: flow.interval!, + }) + + const copilotToken = await this.exchangeForCopilotToken(githubToken) + + await this.providerKeychainStore.setApiKey(providerId, copilotToken.token) + + const expiresAt = new Date(copilotToken.expiresAt * 1000).toISOString() + await this.providerOAuthTokenStore.set(providerId, { + expiresAt, + refreshToken: githubToken, + }) + + const providerDef = getProviderById(providerId) + await this.providerConfigStore.connectProvider(providerId, { + activeModel: providerDef?.defaultModel, + authMethod: 'oauth', + }) + + this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {}) + return {success: true} + } + private cleanupFlowsForClient(clientId: string): void { for (const [providerId, flow] of this.oauthFlows.entries()) { if (flow.clientId === clientId) { @@ -139,7 +249,12 @@ export class ProviderHandler { ProviderEvents.AWAIT_OAUTH_CALLBACK, async (data) => { const flow = this.oauthFlows.get(data.providerId) - if (!flow?.callbackServer) { + if (!flow) { + return {error: 'No active OAuth flow for this provider', success: false} + } + + const isDeviceFlow = Boolean(flow.deviceCode) + if (!isDeviceFlow && !flow.callbackServer) { return {error: 'No active OAuth flow for this provider', success: false} } @@ -150,56 +265,11 @@ export class ProviderHandler { flow.awaitInProgress = true try { - // Block until callback or timeout (5 min default in ProviderCallbackServer) - const callbackResult = await flow.callbackServer.waitForCallback(flow.state) - - // Exchange code for tokens - const providerDef = getProviderById(data.providerId) - if (!providerDef?.oauth) { - return {error: 'Provider does not support OAuth', success: false} + if (isDeviceFlow) { + return await this.awaitDeviceFlow(data.providerId, flow) } - const oauthConfig = providerDef.oauth - const contentType: TokenRequestContentType = - oauthConfig.tokenContentType === 'form' ? 'application/x-www-form-urlencoded' : 'application/json' - - const tokens = await this.exchangeCodeForTokens({ - clientId: oauthConfig.clientId, - code: callbackResult.code, - codeVerifier: flow.codeVerifier, - contentType, - redirectUri: oauthConfig.redirectUri, - tokenUrl: oauthConfig.tokenUrl, - }) - - // Parse JWT id_token for account ID - const oauthAccountId = tokens.id_token ? parseAccountIdFromIdToken(tokens.id_token) : undefined - - // Store access token as the "API key" in keychain - await this.providerKeychainStore.setApiKey(data.providerId, tokens.access_token) - - // Store refresh token + expiry in encrypted OAuth token store - if (tokens.refresh_token) { - const expiresAt = tokens.expires_in ? computeExpiresAt(tokens.expires_in) : computeExpiresAt(3600) // 1-hour default when provider omits expires_in - await this.providerOAuthTokenStore.set(data.providerId, { - expiresAt, - refreshToken: tokens.refresh_token, - }) - } - - // Connect provider — secrets stored in keychain + encrypted token store, not config - // OAuth providers may define their own default model (e.g., Codex for OpenAI OAuth) - const defaultModel = oauthConfig.defaultModel ?? providerDef.defaultModel - await this.providerConfigStore.connectProvider(data.providerId, { - activeModel: defaultModel, - authMethod: 'oauth', - oauthAccountId, - }) - - // Broadcast update - this.transport.broadcast(TransportDaemonEventNames.PROVIDER_UPDATED, {}) - - return {success: true} + return await this.awaitCallbackFlow(data.providerId, flow) } catch (error) { if (error instanceof ProviderCallbackTimeoutError) { return {error: 'Authentication timed out. Please try again.', success: false} @@ -367,6 +437,10 @@ export class ProviderHandler { this.oauthFlows.delete(data.providerId) + if (oauthConfig.callbackMode === 'device') { + return await this.startDeviceFlow(data.providerId, oauthConfig, clientId) + } + // Generate PKCE parameters const pkce = this.generatePkce() @@ -434,7 +508,6 @@ export class ProviderHandler { }, ) } - /* eslint-enable camelcase */ private setupSubmitOAuthCode(): void { this.transport.onRequest( @@ -443,6 +516,7 @@ export class ProviderHandler { async () => ({error: 'Code submission is not yet supported for this provider', success: false}), ) } + /* eslint-enable camelcase */ private setupValidateApiKey(): void { this.transport.onRequest( @@ -457,4 +531,36 @@ export class ProviderHandler { }, ) } + + private async startDeviceFlow( + providerId: string, + oauthConfig: ProviderOAuthConfig, + clientId: string, + ): Promise { + const deviceCodeResponse = await this.requestDeviceCode({ + clientId: oauthConfig.clientId, + scope: oauthConfig.scopes, + }) + + this.oauthFlows.set(providerId, { + clientId, + deviceCode: deviceCodeResponse.deviceCode, + expiresIn: deviceCodeResponse.expiresIn, + interval: deviceCodeResponse.interval, + }) + + try { + await this.browserLauncher.open(deviceCodeResponse.verificationUri) + } catch { + processLog(`[ProviderHandler] Browser launch failed for device flow — user can navigate manually`) + } + + return { + authUrl: deviceCodeResponse.verificationUri, + callbackMode: 'device', + success: true, + userCode: deviceCodeResponse.userCode, + verificationUri: deviceCodeResponse.verificationUri, + } + } } diff --git a/src/shared/constants/copilot.ts b/src/shared/constants/copilot.ts new file mode 100644 index 000000000..8de7ac7be --- /dev/null +++ b/src/shared/constants/copilot.ts @@ -0,0 +1,45 @@ +/** GitHub Copilot CLI App client ID (public — used for device flow, no client secret). */ +export const COPILOT_GITHUB_CLIENT_ID = 'Iv1.b507a08c87ecfe98' + +/** GitHub device code request endpoint. */ +export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code' + +/** GitHub OAuth token endpoint (used for device flow polling). */ +export const GITHUB_OAUTH_TOKEN_URL = 'https://github.com/login/oauth/access_token' + +/** GitHub Copilot internal token exchange endpoint. */ +export const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token' + +/** Copilot API base URL (serves OpenAI-compatible chat completions for all models). */ +export const COPILOT_API_BASE_URL = 'https://api.githubcopilot.com' + +/** OAuth scope required for Copilot access. */ +export const COPILOT_OAUTH_SCOPE = 'read:user' + +/** GitHub device flow verification URI (where users enter their code). */ +export const GITHUB_DEVICE_VERIFICATION_URL = 'https://github.com/login/device' + +/** Default polling interval for device flow (seconds). */ +export const DEVICE_FLOW_DEFAULT_INTERVAL = 5 + +/** Extra seconds to add to polling interval as a safety margin. */ +export const DEVICE_FLOW_INTERVAL_BUFFER = 3 + +/** Copilot models endpoint for listing available models. */ +export const COPILOT_MODELS_ENDPOINT = '/models' + +export const GITHUB_API_VERSION = '2025-04-01' + +/** Editor version header value sent with Copilot API requests. */ +export const COPILOT_EDITOR_VERSION = 'vscode/1.99.0' + +export const COPILOT_EDITOR_PLUGIN_VERSION = 'copilot-chat/0.26.7' + +export const COPILOT_REQUEST_HEADERS = { + 'Copilot-Integration-Id': 'vscode-chat', + 'Editor-Plugin-Version': COPILOT_EDITOR_PLUGIN_VERSION, + 'Editor-Version': COPILOT_EDITOR_VERSION, + 'openai-intent': 'conversation-panel', + 'User-Agent': `GitHubCopilotChat/${COPILOT_EDITOR_PLUGIN_VERSION.split('/')[1]}`, + 'x-github-api-version': GITHUB_API_VERSION, +} as const diff --git a/src/shared/transport/events/provider-events.ts b/src/shared/transport/events/provider-events.ts index 384cae038..004c689ee 100644 --- a/src/shared/transport/events/provider-events.ts +++ b/src/shared/transport/events/provider-events.ts @@ -80,9 +80,13 @@ export interface ProviderStartOAuthRequest { export interface ProviderStartOAuthResponse { authUrl: string - callbackMode: 'auto' | 'code-paste' + callbackMode: 'auto' | 'code-paste' | 'device' error?: string success: boolean + /** Device flow only: the user code to enter at verificationUri */ + userCode?: string + /** Device flow only: URL where the user enters the code (e.g. github.com/login/device) */ + verificationUri?: string } export interface ProviderAwaitOAuthCallbackRequest { diff --git a/src/shared/transport/types/dto.ts b/src/shared/transport/types/dto.ts index 743ce9177..d19b4bcec 100644 --- a/src/shared/transport/types/dto.ts +++ b/src/shared/transport/types/dto.ts @@ -86,7 +86,7 @@ export interface ProviderDTO { isConnected: boolean isCurrent: boolean name: string - oauthCallbackMode?: 'auto' | 'code-paste' + oauthCallbackMode?: 'auto' | 'code-paste' | 'device' oauthLabel?: string requiresApiKey: boolean supportsOAuth: boolean diff --git a/src/tui/features/provider/components/oauth-dialog.tsx b/src/tui/features/provider/components/oauth-dialog.tsx index b57de7079..663d2759a 100644 --- a/src/tui/features/provider/components/oauth-dialog.tsx +++ b/src/tui/features/provider/components/oauth-dialog.tsx @@ -33,6 +33,7 @@ export const OAuthDialog: React.FC = ({ const {theme: {colors}} = useTheme() const [step, setStep] = useState('starting') const [authUrl, setAuthUrl] = useState(null) + const [userCode, setUserCode] = useState(null) const [error, setError] = useState(null) const mounted = useRef(true) const flowStarted = useRef(false) @@ -60,6 +61,10 @@ export const OAuthDialog: React.FC = ({ flowStarted.current = true setAuthUrl(startResult.authUrl) + if (startResult.callbackMode === 'device') { + setUserCode(startResult.userCode ?? null) + } + setStep('waiting') const callbackResult = await awaitCallbackMutation.mutateAsync({providerId: provider.id}) @@ -147,12 +152,25 @@ export const OAuthDialog: React.FC = ({ case 'waiting': { return ( - Opening browser for authentication... - {authUrl && ( + {userCode ? ( - If the browser did not open, visit this URL: + Open this URL and enter the code below: {authUrl} + + Code: + {userCode} + + ) : ( + <> + Opening browser for authentication... + {authUrl && ( + + If the browser did not open, visit this URL: + {authUrl} + + )} + )} Waiting for authorization... (press Esc to cancel) diff --git a/test/commands/providers/connect.test.ts b/test/commands/providers/connect.test.ts index 98d714aff..1638ee013 100644 --- a/test/commands/providers/connect.test.ts +++ b/test/commands/providers/connect.test.ts @@ -623,6 +623,97 @@ describe('Provider Connect Command', () => { expect(loggedMessages.some((m) => m.includes('OAuth callback timed out'))).to.be.true }) + // ==================== Device Flow ==================== + + const deviceFlowProvider = { + id: 'github-copilot', + isConnected: false, + name: 'GitHub Copilot', + oauthCallbackMode: 'device', + requiresApiKey: false, + supportsOAuth: true, + } + + it('should display user code and verification URI for device flow', async () => { + const requestStub = mockClient.requestWithAck as sinon.SinonStub + requestStub.onFirstCall().resolves({providers: [deviceFlowProvider]}) + requestStub.onSecondCall().resolves({ + authUrl: 'https://github.com/login/device', + callbackMode: 'device', + success: true, + userCode: 'ABCD-1234', + verificationUri: 'https://github.com/login/device', + }) + requestStub.onThirdCall().resolves({success: true}) + + await createCommand('github-copilot', '--oauth').run() + + expect(loggedMessages.some((m) => m.includes('ABCD-1234'))).to.be.true + expect(loggedMessages.some((m) => m.includes('https://github.com/login/device'))).to.be.true + expect(loggedMessages.some((m) => m.includes('Connected to GitHub Copilot via OAuth'))).to.be.true + }) + + it('should await callback for device flow (like auto mode)', async () => { + const requestStub = mockClient.requestWithAck as sinon.SinonStub + requestStub.onFirstCall().resolves({providers: [deviceFlowProvider]}) + requestStub.onSecondCall().resolves({ + authUrl: 'https://github.com/login/device', + callbackMode: 'device', + success: true, + userCode: 'WXYZ-5678', + verificationUri: 'https://github.com/login/device', + }) + requestStub.onThirdCall().resolves({success: true}) + + await createCommand('github-copilot', '--oauth').run() + + expect(requestStub.thirdCall.args[0]).to.equal('provider:awaitOAuthCallback') + expect(requestStub.thirdCall.args[2]).to.deep.equal({timeout: 300_000}) + }) + + it('should handle device flow AWAIT_OAUTH_CALLBACK failure', async () => { + const requestStub = mockClient.requestWithAck as sinon.SinonStub + requestStub.onFirstCall().resolves({providers: [deviceFlowProvider]}) + requestStub.onSecondCall().resolves({ + authUrl: 'https://github.com/login/device', + callbackMode: 'device', + success: true, + userCode: 'FAIL-0000', + verificationUri: 'https://github.com/login/device', + }) + requestStub.onThirdCall().resolves({error: 'Device flow timed out', success: false}) + + await createCommand('github-copilot', '--oauth').run() + + expect(loggedMessages.some((m) => m.includes('Device flow timed out'))).to.be.true + }) + + it('should not print code-paste instructions for device flow', async () => { + const requestStub = mockClient.requestWithAck as sinon.SinonStub + requestStub.onFirstCall().resolves({providers: [deviceFlowProvider]}) + requestStub.onSecondCall().resolves({ + authUrl: 'https://github.com/login/device', + callbackMode: 'device', + success: true, + userCode: 'ABCD-1234', + verificationUri: 'https://github.com/login/device', + }) + requestStub.onThirdCall().resolves({success: true}) + + await createCommand('github-copilot', '--oauth').run() + + expect(loggedMessages.some((m) => m.includes('--code'))).to.be.false + expect(loggedMessages.some((m) => m.includes('Copy the authorization code'))).to.be.false + }) + + it('should error when --code is used with a device flow provider', async () => { + ;(mockClient.requestWithAck as sinon.SinonStub).resolves({providers: [deviceFlowProvider]}) + + await createCommand('github-copilot', '--oauth', '--code', 'some-code').run() + + expect(loggedMessages.some((m) => m.includes('does not accept --code'))).to.be.true + }) + it('should error when --oauth and --api-key are both provided', async () => { await createCommand('openai', '--oauth', '--api-key', 'sk-test').run() diff --git a/test/unit/core/domain/entities/provider-registry.test.ts b/test/unit/core/domain/entities/provider-registry.test.ts index 9749c9a25..294d1315a 100644 --- a/test/unit/core/domain/entities/provider-registry.test.ts +++ b/test/unit/core/domain/entities/provider-registry.test.ts @@ -119,4 +119,42 @@ describe('Provider Registry', () => { } }) }) + + describe('GitHub Copilot provider', () => { + it('should be in the registry', () => { + expect(PROVIDER_REGISTRY).to.have.property('github-copilot') + }) + + it('should have correct fields', () => { + const copilot = getProviderById('github-copilot') + expect(copilot).to.not.be.undefined + expect(copilot?.id).to.equal('github-copilot') + expect(copilot?.name).to.equal('GitHub Copilot') + expect(copilot?.baseUrl).to.equal('https://api.githubcopilot.com') + expect(copilot?.category).to.equal('popular') + expect(copilot?.defaultModel).to.equal('claude-sonnet-4.6') + expect(copilot?.priority).to.equal(8) + }) + + it('providerRequiresApiKey should return false', () => { + expect(providerRequiresApiKey('github-copilot')).to.be.false + }) + + it('should have oauth config with callbackMode device', () => { + const copilot = getProviderById('github-copilot') + expect(copilot?.oauth).to.not.be.undefined + expect(copilot?.oauth?.callbackMode).to.equal('device') + }) + + it('should have correct oauth clientId', () => { + const copilot = getProviderById('github-copilot') + expect(copilot?.oauth?.clientId).to.equal('Iv1.b507a08c87ecfe98') + }) + + it('should have oauth modes with GitHub device auth URL', () => { + const copilot = getProviderById('github-copilot') + expect(copilot?.oauth?.modes).to.have.length.greaterThanOrEqual(1) + expect(copilot?.oauth?.modes[0].authUrl).to.include('github.com/login/device') + }) + }) }) diff --git a/test/unit/infra/http/copilot-model-fetcher.test.ts b/test/unit/infra/http/copilot-model-fetcher.test.ts new file mode 100644 index 000000000..6110dd61b --- /dev/null +++ b/test/unit/infra/http/copilot-model-fetcher.test.ts @@ -0,0 +1,173 @@ +/* eslint-disable camelcase */ +import axios from 'axios' +import {expect} from 'chai' +import nock from 'nock' +import {restore, stub} from 'sinon' + +import type {ProviderModelInfo} from '../../../../src/server/core/interfaces/i-provider-model-fetcher.js' + +import {CopilotModelFetcher} from '../../../../src/server/infra/http/provider-model-fetchers.js' +import {ProxyConfig} from '../../../../src/server/infra/http/proxy-config.js' + +const SAMPLE_COPILOT_RESPONSE = { + data: [ + {context_length: 200_000, id: 'claude-sonnet-4', name: 'Claude Sonnet 4'}, + {context_length: 128_000, id: 'gpt-4o', name: 'GPT-4o'}, + {context_length: 200_000, id: 'claude-opus-4', name: 'Claude Opus 4'}, + ], +} + +describe('CopilotModelFetcher', () => { + beforeEach(() => { + stub(ProxyConfig, 'getProxyAgent').returns(undefined as never) + nock.cleanAll() + }) + + afterEach(() => { + restore() + nock.cleanAll() + }) + + describe('fetchModels', () => { + it('should fetch and parse models from Copilot API', async () => { + nock('https://api.githubcopilot.com') + .get('/models') + .matchHeader('authorization', 'Bearer ghu_test-token') + .matchHeader('copilot-integration-id', 'vscode-chat') + .matchHeader('editor-version', 'vscode/1.99.0') + .reply(200, SAMPLE_COPILOT_RESPONSE) + + const fetcher = new CopilotModelFetcher() + const models = await fetcher.fetchModels('ghu_test-token') + + expect(models).to.have.length(3) + const ids = models.map((m: ProviderModelInfo) => m.id) + expect(ids).to.include('claude-sonnet-4') + expect(ids).to.include('gpt-4o') + expect(ids).to.include('claude-opus-4') + }) + + it('should map model fields correctly', async () => { + nock('https://api.githubcopilot.com').get('/models').reply(200, SAMPLE_COPILOT_RESPONSE) + + const fetcher = new CopilotModelFetcher() + const models = await fetcher.fetchModels('token') + const sonnet = models.find((m: ProviderModelInfo) => m.id === 'claude-sonnet-4') + + expect(sonnet).to.deep.equal({ + contextLength: 200_000, + id: 'claude-sonnet-4', + isFree: false, + name: 'Claude Sonnet 4', + pricing: {inputPerM: 0, outputPerM: 0}, + provider: 'GitHub Copilot', + }) + }) + + it('should sort models by ID', async () => { + nock('https://api.githubcopilot.com').get('/models').reply(200, SAMPLE_COPILOT_RESPONSE) + + const fetcher = new CopilotModelFetcher() + const models = await fetcher.fetchModels('token') + const ids = models.map((m: ProviderModelInfo) => m.id) + + expect(ids).to.deep.equal([...ids].sort()) + }) + + it('should return cached models on second call without hitting API again', async () => { + nock('https://api.githubcopilot.com').get('/models').once().reply(200, SAMPLE_COPILOT_RESPONSE) + + const fetcher = new CopilotModelFetcher() + const first = await fetcher.fetchModels('token') + const second = await fetcher.fetchModels('token') + + expect(first).to.equal(second) + expect(nock.isDone()).to.be.true + }) + + it('should bypass cache when forceRefresh is true', async () => { + nock('https://api.githubcopilot.com').get('/models').twice().reply(200, SAMPLE_COPILOT_RESPONSE) + + const fetcher = new CopilotModelFetcher() + await fetcher.fetchModels('token') + await fetcher.fetchModels('token', {forceRefresh: true}) + + expect(nock.isDone()).to.be.true + }) + + it('should handle top-level array response', async () => { + const arrayResponse = [ + {id: 'claude-sonnet-4', name: 'Claude Sonnet 4'}, + {id: 'gpt-4o', name: 'GPT-4o'}, + ] + nock('https://api.githubcopilot.com').get('/models').reply(200, arrayResponse) + + const fetcher = new CopilotModelFetcher() + const models = await fetcher.fetchModels('token') + + expect(models).to.have.length(2) + }) + + it('should use 200_000 as default context length when not provided', async () => { + nock('https://api.githubcopilot.com') + .get('/models') + .reply(200, {data: [{id: 'some-model', name: 'Some Model'}]}) + + const fetcher = new CopilotModelFetcher() + const models = await fetcher.fetchModels('token') + + expect(models[0].contextLength).to.equal(200_000) + }) + }) + + describe('validateApiKey', () => { + it('should return isValid true when API responds successfully', async () => { + nock('https://api.githubcopilot.com').get('/models').reply(200, SAMPLE_COPILOT_RESPONSE) + + const fetcher = new CopilotModelFetcher() + const result = await fetcher.validateApiKey('valid-token') + + expect(result.isValid).to.be.true + expect(result.error).to.be.undefined + }) + + it('should return isValid false with error message for 401', async () => { + nock('https://api.githubcopilot.com').get('/models').reply(401, {message: 'Unauthorized'}) + + const fetcher = new CopilotModelFetcher() + const result = await fetcher.validateApiKey('bad-token') + + expect(result.isValid).to.be.false + expect(result.error).to.equal('Invalid or expired Copilot token') + }) + + it('should return isValid false with error message for 403', async () => { + nock('https://api.githubcopilot.com').get('/models').reply(403, {message: 'Forbidden'}) + + const fetcher = new CopilotModelFetcher() + const result = await fetcher.validateApiKey('no-perms-token') + + expect(result.isValid).to.be.false + expect(result.error).to.equal('Copilot token does not have required permissions') + }) + + it('should return isValid false for other API errors', async () => { + nock('https://api.githubcopilot.com').get('/models').reply(500, 'Internal Server Error') + + const fetcher = new CopilotModelFetcher() + const result = await fetcher.validateApiKey('token') + + expect(result.isValid).to.be.false + }) + + it('should return isValid false with error message for non-Axios errors', async () => { + stub(axios, 'get').rejects(new TypeError('Cannot read properties of undefined')) + + const fetcher = new CopilotModelFetcher() + const result = await fetcher.validateApiKey('token') + + expect(result.isValid).to.be.false + expect(result.error).to.equal('Cannot read properties of undefined') + }) + }) +}) diff --git a/test/unit/infra/llm/providers/github-copilot.test.ts b/test/unit/infra/llm/providers/github-copilot.test.ts new file mode 100644 index 000000000..7e3fdaa9d --- /dev/null +++ b/test/unit/infra/llm/providers/github-copilot.test.ts @@ -0,0 +1,137 @@ +import {expect} from 'chai' + +import {AiSdkContentGenerator} from '../../../../../src/agent/infra/llm/generators/ai-sdk-content-generator.js' +import {githubCopilotProvider, isCopilotClaudeModel} from '../../../../../src/agent/infra/llm/providers/github-copilot.js' +import {getProviderModule} from '../../../../../src/agent/infra/llm/providers/index.js' + +const BASE_FACTORY_CONFIG = { + maxTokens: 1024, + temperature: 0.7, +} + +describe('GitHub Copilot Provider', () => { + describe('module metadata', () => { + it('should have correct id', () => { + expect(githubCopilotProvider.id).to.equal('github-copilot') + }) + + it('should have correct providerType', () => { + expect(githubCopilotProvider.providerType).to.equal('openai') + }) + + it('should be registered in provider modules', () => { + const module = getProviderModule('github-copilot') + expect(module).to.equal(githubCopilotProvider) + }) + + it('should have correct default model', () => { + expect(githubCopilotProvider.defaultModel).to.equal('claude-sonnet-4.6') + }) + + it('should have popular category', () => { + expect(githubCopilotProvider.category).to.equal('popular') + }) + + it('should have empty envVars (OAuth only)', () => { + expect(githubCopilotProvider.envVars).to.deep.equal([]) + }) + }) + + describe('createGenerator', () => { + it('should create AiSdkContentGenerator for Claude models', () => { + const generator = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + model: 'claude-sonnet-4', + }) + expect(generator).to.be.instanceOf(AiSdkContentGenerator) + }) + + it('should create AiSdkContentGenerator for GPT models', () => { + const generator = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + model: 'gpt-4.1', + }) + expect(generator).to.be.instanceOf(AiSdkContentGenerator) + }) + + it('should create AiSdkContentGenerator for Gemini models', () => { + const generator = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + model: 'gemini-2.5-pro', + }) + expect(generator).to.be.instanceOf(AiSdkContentGenerator) + }) + + it('should create AiSdkContentGenerator for o-series models', () => { + const generator = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + model: 'o3', + }) + expect(generator).to.be.instanceOf(AiSdkContentGenerator) + }) + + it('should use Copilot base URL by default', () => { + const generator = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + model: 'claude-sonnet-4', + }) + expect(generator).to.be.instanceOf(AiSdkContentGenerator) + }) + + it('should use custom base URL when provided in config', () => { + const generator = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + baseUrl: 'https://custom.api.example.com', + model: 'claude-sonnet-4', + }) + expect(generator).to.be.instanceOf(AiSdkContentGenerator) + }) + + it('should use OpenAI-compatible format for all models including Claude', () => { + const claudeGen = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + model: 'claude-sonnet-4', + }) + const gptGen = githubCopilotProvider.createGenerator({ + ...BASE_FACTORY_CONFIG, + apiKey: 'test-token', + model: 'gpt-4.1', + }) + expect(claudeGen).to.be.instanceOf(AiSdkContentGenerator) + expect(gptGen).to.be.instanceOf(AiSdkContentGenerator) + }) + }) + + describe('isCopilotClaudeModel()', () => { + it('should return true for claude-sonnet-4', () => { + expect(isCopilotClaudeModel('claude-sonnet-4')).to.be.true + }) + + it('should return true for claude-opus-4.5', () => { + expect(isCopilotClaudeModel('claude-opus-4.5')).to.be.true + }) + + it('should return true for claude-haiku-4.5', () => { + expect(isCopilotClaudeModel('claude-haiku-4.5')).to.be.true + }) + + it('should return false for gpt-4.1', () => { + expect(isCopilotClaudeModel('gpt-4.1')).to.be.false + }) + + it('should return false for gemini-2.5-pro', () => { + expect(isCopilotClaudeModel('gemini-2.5-pro')).to.be.false + }) + + it('should return false for o3', () => { + expect(isCopilotClaudeModel('o3')).to.be.false + }) + }) +}) diff --git a/test/unit/infra/provider-oauth/device-flow.test.ts b/test/unit/infra/provider-oauth/device-flow.test.ts new file mode 100644 index 000000000..65f78540e --- /dev/null +++ b/test/unit/infra/provider-oauth/device-flow.test.ts @@ -0,0 +1,283 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' +import nock from 'nock' +import {restore} from 'sinon' + +import { + exchangeForCopilotToken, + pollForAccessToken, + requestDeviceCode, +} from '../../../../src/server/infra/provider-oauth/device-flow.js' +import {ProviderTokenExchangeError} from '../../../../src/server/infra/provider-oauth/errors.js' +import { + COPILOT_TOKEN_URL, + GITHUB_DEVICE_CODE_URL, + GITHUB_OAUTH_TOKEN_URL, +} from '../../../../src/shared/constants/copilot.js' + +const GITHUB_BASE = new URL(GITHUB_DEVICE_CODE_URL).origin +const GITHUB_API_BASE = new URL(COPILOT_TOKEN_URL).origin +const DEVICE_CODE_PATH = new URL(GITHUB_DEVICE_CODE_URL).pathname +const OAUTH_TOKEN_PATH = new URL(GITHUB_OAUTH_TOKEN_URL).pathname +const COPILOT_TOKEN_PATH = new URL(COPILOT_TOKEN_URL).pathname + +describe('device-flow', () => { + afterEach(() => { + nock.cleanAll() + restore() + }) + + describe('requestDeviceCode()', () => { + it('successfully requests device code and returns camelCase response', async () => { + nock(GITHUB_BASE) + .post(DEVICE_CODE_PATH) + .reply(200, { + device_code: 'dev-code-abc', + expires_in: 900, + interval: 5, + user_code: 'USER-CODE', + verification_uri: 'https://github.com/login/device', + }) + + const result = await requestDeviceCode({clientId: 'test-client', scope: 'read:user'}) + + expect(result.deviceCode).to.equal('dev-code-abc') + expect(result.userCode).to.equal('USER-CODE') + expect(result.verificationUri).to.equal('https://github.com/login/device') + expect(result.interval).to.equal(5) + expect(result.expiresIn).to.equal(900) + }) + + it('throws on HTTP error (422)', async () => { + nock(GITHUB_BASE).post(DEVICE_CODE_PATH).reply(422, {message: 'Unprocessable Entity'}) + + try { + await requestDeviceCode({clientId: 'bad-client', scope: 'read:user'}) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + } + }) + + it('sends correct headers and body', async () => { + let capturedBody: string | undefined + + nock(GITHUB_BASE) + .post(DEVICE_CODE_PATH, (body: string) => { + capturedBody = body + return true + }) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/x-www-form-urlencoded') + .reply(200, { + device_code: 'dc', + expires_in: 900, + interval: 5, + user_code: 'UC', + verification_uri: 'https://github.com/login/device', + }) + + await requestDeviceCode({clientId: 'my-client', scope: 'read:user'}) + + const params = new URLSearchParams(capturedBody) + expect(params.get('client_id')).to.equal('my-client') + expect(params.get('scope')).to.equal('read:user') + }) + }) + + describe('pollForAccessToken()', () => { + it('returns access token after authorization_pending then success', async () => { + nock(GITHUB_BASE) + .post(OAUTH_TOKEN_PATH) + .reply(200, {error: 'authorization_pending'}) + .post(OAUTH_TOKEN_PATH) + .reply(200, {access_token: 'gho_test_token'}) + + const token = await pollForAccessToken({ + clientId: 'test-client', + deviceCode: 'dev-code-abc', + expiresIn: 900, + interval: 0, + intervalBuffer: 0, + }) + + expect(token).to.equal('gho_test_token') + }) + + it('handles slow_down by increasing interval', async () => { + nock(GITHUB_BASE) + .post(OAUTH_TOKEN_PATH) + .reply(200, {error: 'slow_down'}) + .post(OAUTH_TOKEN_PATH) + .reply(200, {access_token: 'gho_slow_token'}) + + await pollForAccessToken({ + clientId: 'test-client', + deviceCode: 'dev-code-abc', + expiresIn: 900, + interval: 0, + intervalBuffer: 0, + slowDownIncrement: 0, + }) + }) + + it('throws on expired_token', async () => { + nock(GITHUB_BASE).post(OAUTH_TOKEN_PATH).reply(200, {error: 'expired_token'}) + + try { + await pollForAccessToken({ + clientId: 'test-client', + deviceCode: 'dev-code-abc', + expiresIn: 900, + interval: 0, + intervalBuffer: 0, + }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + if (error instanceof Error) { + expect(error.message).to.equal('Device code expired') + } + } + }) + + it('throws on access_denied', async () => { + nock(GITHUB_BASE).post(OAUTH_TOKEN_PATH).reply(200, {error: 'access_denied'}) + + try { + await pollForAccessToken({ + clientId: 'test-client', + deviceCode: 'dev-code-abc', + expiresIn: 900, + interval: 0, + intervalBuffer: 0, + }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + if (error instanceof Error) { + expect(error.message).to.equal('Authorization denied by user') + } + } + }) + + it('throws when AbortSignal is aborted', async () => { + const controller = new AbortController() + controller.abort() + + try { + await pollForAccessToken({ + clientId: 'test-client', + deviceCode: 'dev-code-abc', + expiresIn: 900, + interval: 0, + signal: controller.signal, + }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + if (error instanceof Error) { + expect(error.message).to.equal('Device flow cancelled') + } + } + }) + + it('handles unknown errors with error_description', async () => { + nock(GITHUB_BASE) + .post(OAUTH_TOKEN_PATH) + .reply(200, {error: 'some_unknown_error', error_description: 'Something went wrong'}) + + try { + await pollForAccessToken({ + clientId: 'test-client', + deviceCode: 'dev-code-abc', + expiresIn: 900, + interval: 0, + intervalBuffer: 0, + }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + if (error instanceof Error) { + expect(error.message).to.equal('Something went wrong') + } + } + }) + }) + + describe('exchangeForCopilotToken()', () => { + it('successfully exchanges GitHub token for Copilot token', async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 1800 + + nock(GITHUB_API_BASE) + .get(COPILOT_TOKEN_PATH) + .reply(200, { + expires_at: expiresAt, + token: 'tid=copilot_token_xyz', + }) + + const result = await exchangeForCopilotToken('gho_github_token') + + expect(result.token).to.equal('tid=copilot_token_xyz') + expect(result.expiresAt).to.equal(expiresAt) + }) + + it('throws ProviderTokenExchangeError with status code on 401', async () => { + nock(GITHUB_API_BASE).get(COPILOT_TOKEN_PATH).reply(401, {message: 'Bad credentials'}) + + try { + await exchangeForCopilotToken('invalid_token') + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(ProviderTokenExchangeError) + if (error instanceof ProviderTokenExchangeError) { + expect(error.statusCode).to.equal(401) + } + } + }) + + it('throws ProviderTokenExchangeError with status code on 403', async () => { + nock(GITHUB_API_BASE).get(COPILOT_TOKEN_PATH).reply(403, {error: 'forbidden', error_description: 'No Copilot subscription'}) + + try { + await exchangeForCopilotToken('gho_no_subscription') + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(ProviderTokenExchangeError) + if (error instanceof ProviderTokenExchangeError) { + expect(error.statusCode).to.equal(403) + expect(error.message).to.equal('No Copilot subscription') + expect(error.errorCode).to.equal('forbidden') + } + } + }) + + it('re-throws non-axios errors unchanged', async () => { + nock(GITHUB_API_BASE).get(COPILOT_TOKEN_PATH).replyWithError('socket hang up') + + try { + await exchangeForCopilotToken('gho_token') + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(ProviderTokenExchangeError) + } + }) + + it('sends correct Authorization header format', async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 1800 + + nock(GITHUB_API_BASE) + .get(COPILOT_TOKEN_PATH) + .matchHeader('Authorization', 'token gho_my_token') + .matchHeader('Accept', 'application/json') + .matchHeader('X-GitHub-Api-Version', '2025-04-01') + .reply(200, { + expires_at: expiresAt, + token: 'tid=some_token', + }) + + const result = await exchangeForCopilotToken('gho_my_token') + expect(result.token).to.equal('tid=some_token') + }) + }) +}) diff --git a/test/unit/infra/provider-oauth/token-refresh-manager.test.ts b/test/unit/infra/provider-oauth/token-refresh-manager.test.ts index 933f81955..e14fff1c8 100644 --- a/test/unit/infra/provider-oauth/token-refresh-manager.test.ts +++ b/test/unit/infra/provider-oauth/token-refresh-manager.test.ts @@ -2,6 +2,7 @@ import {expect} from 'chai' import sinon, {restore, stub} from 'sinon' +import type {CopilotTokenResponse} from '../../../../src/server/infra/provider-oauth/device-flow.js' import type { ProviderTokenResponse, RefreshTokenExchangeParams, @@ -29,12 +30,19 @@ function oauthConfig(providerId: string): ProviderConfig { }) } +function copilotOAuthConfig(): ProviderConfig { + return ProviderConfig.createDefault().withProviderConnected('github-copilot', { + authMethod: 'oauth', + }) +} + describe('TokenRefreshManager', () => { let providerConfigStore: ReturnType let providerKeychainStore: ReturnType let providerOAuthTokenStore: ReturnType let transport: ReturnType let exchangeStub: sinon.SinonStub<[RefreshTokenExchangeParams], Promise> + let exchangeForCopilotTokenStub: sinon.SinonStub<[string], Promise> beforeEach(() => { providerConfigStore = createMockProviderConfigStore() @@ -42,6 +50,7 @@ describe('TokenRefreshManager', () => { providerOAuthTokenStore = createMockProviderOAuthTokenStore() transport = createMockTransportServer() exchangeStub = stub<[RefreshTokenExchangeParams], Promise>() + exchangeForCopilotTokenStub = stub<[string], Promise>() }) afterEach(() => { @@ -50,6 +59,7 @@ describe('TokenRefreshManager', () => { function createManager(): TokenRefreshManager { return new TokenRefreshManager({ + exchangeForCopilotToken: exchangeForCopilotTokenStub, exchangeRefreshToken: exchangeStub, providerConfigStore, providerKeychainStore, @@ -341,4 +351,164 @@ describe('TokenRefreshManager', () => { expect(exchangeStub.notCalled).to.be.true }) }) + + describe('Copilot token refresh (device flow)', () => { + it('should use exchangeForCopilotToken instead of standard refresh for github-copilot', async () => { + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + refreshToken: 'gho_github_access_token', + }) + exchangeForCopilotTokenStub.resolves({ + expiresAt: Math.floor(Date.now() / 1000) + 1800, + token: 'tid=copilot-session-token-new', + }) + + const manager = createManager() + const result = await manager.refreshIfNeeded('github-copilot') + + expect(result).to.be.true + expect(exchangeForCopilotTokenStub.calledOnce).to.be.true + expect(exchangeForCopilotTokenStub.firstCall.args[0]).to.equal('gho_github_access_token') + expect(exchangeStub.notCalled).to.be.true + }) + + it('should store new Copilot session token in keychain', async () => { + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + refreshToken: 'gho_github_access_token', + }) + exchangeForCopilotTokenStub.resolves({ + expiresAt: Math.floor(Date.now() / 1000) + 1800, + token: 'tid=copilot-session-token-new', + }) + + const manager = createManager() + await manager.refreshIfNeeded('github-copilot') + + expect(providerKeychainStore.setApiKey.calledWith('github-copilot', 'tid=copilot-session-token-new')).to.be.true + }) + + it('should update token store with new expiry while preserving GitHub token as refreshToken', async () => { + const copilotExpiresAt = Math.floor(Date.now() / 1000) + 1800 + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + refreshToken: 'gho_github_access_token', + }) + exchangeForCopilotTokenStub.resolves({ + expiresAt: copilotExpiresAt, + token: 'tid=copilot-session-token-new', + }) + + const manager = createManager() + await manager.refreshIfNeeded('github-copilot') + + expect(providerOAuthTokenStore.set.calledOnce).to.be.true + const [providerId, tokenRecord] = providerOAuthTokenStore.set.firstCall.args + expect(providerId).to.equal('github-copilot') + expect(tokenRecord.refreshToken).to.equal('gho_github_access_token') + expect(tokenRecord.expiresAt).to.equal(new Date(copilotExpiresAt * 1000).toISOString()) + }) + + it('should broadcast PROVIDER_UPDATED on successful Copilot refresh', async () => { + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + refreshToken: 'gho_github_access_token', + }) + exchangeForCopilotTokenStub.resolves({ + expiresAt: Math.floor(Date.now() / 1000) + 1800, + token: 'tid=copilot-session-token-new', + }) + + const manager = createManager() + await manager.refreshIfNeeded('github-copilot') + + expect(transport.broadcast.calledWith(TransportDaemonEventNames.PROVIDER_UPDATED, {})).to.be.true + }) + + it('should disconnect provider on permanent Copilot exchange failure', async () => { + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + refreshToken: 'gho_revoked_token', + }) + exchangeForCopilotTokenStub.rejects( + new ProviderTokenExchangeError({errorCode: 'invalid_grant', message: 'Bad credentials', statusCode: 401}), + ) + + const manager = createManager() + const result = await manager.refreshIfNeeded('github-copilot') + + expect(result).to.be.false + expect(providerConfigStore.disconnectProvider.calledWith('github-copilot')).to.be.true + expect(providerOAuthTokenStore.delete.calledWith('github-copilot')).to.be.true + expect(providerKeychainStore.deleteApiKey.calledWith('github-copilot')).to.be.true + }) + + it('should return true and keep credentials on transient Copilot exchange failure', async () => { + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + refreshToken: 'gho_github_access_token', + }) + exchangeForCopilotTokenStub.rejects(new Error('Network timeout')) + + const manager = createManager() + const result = await manager.refreshIfNeeded('github-copilot') + + expect(result).to.be.true + expect(providerConfigStore.disconnectProvider.notCalled).to.be.true + expect(providerOAuthTokenStore.delete.notCalled).to.be.true + expect(providerKeychainStore.deleteApiKey.notCalled).to.be.true + }) + + it('should skip refresh when Copilot token is not expiring (> 5 min)', async () => { + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + REFRESH_THRESHOLD_MS + 60_000).toISOString(), + refreshToken: 'gho_github_access_token', + }) + + const manager = createManager() + const result = await manager.refreshIfNeeded('github-copilot') + + expect(result).to.be.true + expect(exchangeForCopilotTokenStub.notCalled).to.be.true + expect(exchangeStub.notCalled).to.be.true + }) + + it('should serialize concurrent Copilot refresh calls', async () => { + providerConfigStore.read.resolves(copilotOAuthConfig()) + providerOAuthTokenStore.get.resolves({ + expiresAt: new Date(Date.now() + 60_000).toISOString(), + refreshToken: 'gho_github_access_token', + }) + exchangeForCopilotTokenStub.callsFake( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + expiresAt: Math.floor(Date.now() / 1000) + 1800, + token: 'tid=copilot-session-token-new', + }), + 10, + ) + }), + ) + + const manager = createManager() + const [r1, r2] = await Promise.all([ + manager.refreshIfNeeded('github-copilot'), + manager.refreshIfNeeded('github-copilot'), + ]) + + expect(r1).to.be.true + expect(r2).to.be.true + expect(exchangeForCopilotTokenStub.callCount).to.equal(1) + }) + }) }) diff --git a/test/unit/infra/provider/provider-config-resolver.test.ts b/test/unit/infra/provider/provider-config-resolver.test.ts index 66afc603e..e26b2674a 100644 --- a/test/unit/infra/provider/provider-config-resolver.test.ts +++ b/test/unit/infra/provider/provider-config-resolver.test.ts @@ -418,6 +418,86 @@ describe('provider-config-resolver', () => { expect(result.providerKeyMissing).to.be.true expect(result.providerApiKey).to.be.undefined }) + + // ==================== GitHub Copilot ==================== + + it('should resolve github-copilot with Copilot headers and base URL', async () => { + const {configStore, keychainStore} = createStubStores(sandbox) + configStore.read.resolves( + createProviderConfig('github-copilot', { + 'github-copilot': {activeModel: 'claude-sonnet-4', authMethod: 'oauth'}, + }), + ) + keychainStore.getApiKey.resolves('ghu_copilot-token') + + const result = await resolveProviderConfig({providerConfigStore: configStore, providerKeychainStore: keychainStore}) + + expect(result.activeProvider).to.equal('github-copilot') + expect(result.provider).to.equal('github-copilot') + expect(result.providerApiKey).to.equal('ghu_copilot-token') + expect(result.providerBaseUrl).to.equal('https://api.githubcopilot.com') + expect(result.providerHeaders).to.deep.equal({ + 'Copilot-Integration-Id': 'vscode-chat', + 'Editor-Plugin-Version': 'copilot-chat/0.26.7', + 'Editor-Version': 'vscode/1.99.0', + 'openai-intent': 'conversation-panel', + 'User-Agent': 'GitHubCopilotChat/0.26.7', + 'x-github-api-version': '2025-04-01', + }) + expect(result.providerKeyMissing).to.be.false + }) + + it('should return providerKeyMissing true for github-copilot when no API key available', async () => { + const {configStore, keychainStore} = createStubStores(sandbox) + configStore.read.resolves( + createProviderConfig('github-copilot', { + 'github-copilot': {activeModel: 'claude-sonnet-4', authMethod: 'oauth'}, + }), + ) + keychainStore.getApiKey.resolves() + + const result = await resolveProviderConfig({providerConfigStore: configStore, providerKeychainStore: keychainStore}) + + expect(result.providerKeyMissing).to.be.true + expect(result.providerApiKey).to.be.undefined + }) + + it('should attempt token refresh for OAuth-connected github-copilot', async () => { + const {configStore, keychainStore} = createStubStores(sandbox) + configStore.read.resolves( + createProviderConfig('github-copilot', { + 'github-copilot': {activeModel: 'claude-sonnet-4', authMethod: 'oauth'}, + }), + ) + keychainStore.getApiKey.resolves('refreshed-copilot-token') + + const refreshManager: ITokenRefreshManager = { + refreshIfNeeded: sandbox.stub().resolves(true), + } + + const result = await resolveProviderConfig({providerConfigStore: configStore, providerKeychainStore: keychainStore, tokenRefreshManager: refreshManager}) + + expect((refreshManager.refreshIfNeeded as sinon.SinonStub).calledWith('github-copilot')).to.be.true + expect(result.providerApiKey).to.equal('refreshed-copilot-token') + expect(result.providerKeyMissing).to.be.false + }) + + it('should return providerKeyMissing when github-copilot token refresh returns false', async () => { + const {configStore, keychainStore} = createStubStores(sandbox) + configStore.read.resolves( + createProviderConfig('github-copilot', { + 'github-copilot': {activeModel: 'claude-sonnet-4', authMethod: 'oauth'}, + }), + ) + + const refreshManager: ITokenRefreshManager = { + refreshIfNeeded: sandbox.stub().resolves(false), + } + + const result = await resolveProviderConfig({providerConfigStore: configStore, providerKeychainStore: keychainStore, tokenRefreshManager: refreshManager}) + + expect(result.providerKeyMissing).to.be.true + }) }) // ==================== loginRequired field ==================== diff --git a/test/unit/infra/transport/handlers/provider-handler.test.ts b/test/unit/infra/transport/handlers/provider-handler.test.ts index 112eaa1b3..9fa22ba03 100644 --- a/test/unit/infra/transport/handlers/provider-handler.test.ts +++ b/test/unit/infra/transport/handlers/provider-handler.test.ts @@ -5,6 +5,7 @@ import sinon, {restore, stub} from 'sinon' import type {IBrowserLauncher} from '../../../../../src/server/core/interfaces/services/i-browser-launcher.js' import type {IAuthStateStore} from '../../../../../src/server/core/interfaces/state/i-auth-state-store.js' import type {ProviderCallbackServer} from '../../../../../src/server/infra/provider-oauth/callback-server.js' +import type {CopilotTokenResponse, DeviceCodeResponse, PollForAccessTokenParams, RequestDeviceCodeParams} from '../../../../../src/server/infra/provider-oauth/device-flow.js' import type { PkceParameters, ProviderTokenResponse, @@ -66,6 +67,9 @@ describe('ProviderHandler', () => { let mockCallbackServer: sinon.SinonStubbedInstance let generatePkceStub: sinon.SinonStub<[], PkceParameters> let exchangeCodeStub: sinon.SinonStub<[TokenExchangeParams], Promise> + let requestDeviceCodeStub: sinon.SinonStub<[RequestDeviceCodeParams], Promise> + let pollForAccessTokenStub: sinon.SinonStub<[PollForAccessTokenParams], Promise> + let exchangeForCopilotTokenStub: sinon.SinonStub<[string], Promise> beforeEach(() => { authStateStore = createMockAuthStateStore(sinon) @@ -77,6 +81,18 @@ describe('ProviderHandler', () => { mockCallbackServer = createMockCallbackServer() generatePkceStub = stub<[], PkceParameters>().returns(TEST_PKCE) exchangeCodeStub = stub<[TokenExchangeParams], Promise>().resolves(TEST_TOKEN_RESPONSE) + requestDeviceCodeStub = stub<[RequestDeviceCodeParams], Promise>().resolves({ + deviceCode: 'test-device-code', + expiresIn: 900, + interval: 5, + userCode: 'TEST-CODE', + verificationUri: 'https://github.com/login/device', + }) + pollForAccessTokenStub = stub<[PollForAccessTokenParams], Promise>().resolves('gho_test_github_token') + exchangeForCopilotTokenStub = stub<[string], Promise>().resolves({ + expiresAt: Math.floor(Date.now() / 1000) + 1800, + token: 'tid=copilot-session-token', + }) }) afterEach(() => { @@ -89,10 +105,13 @@ describe('ProviderHandler', () => { browserLauncher, createCallbackServer: () => mockCallbackServer as unknown as ProviderCallbackServer, exchangeCodeForTokens: exchangeCodeStub, + exchangeForCopilotToken: exchangeForCopilotTokenStub, generatePkce: generatePkceStub, + pollForAccessToken: pollForAccessTokenStub, providerConfigStore, providerKeychainStore, providerOAuthTokenStore, + requestDeviceCode: requestDeviceCodeStub, transport, }) handler.setup() @@ -859,4 +878,263 @@ describe('ProviderHandler', () => { }) }) }) + + // ==================== OAuth: START_OAUTH (device flow) ==================== + + describe('provider:startOAuth (device flow)', () => { + it('should return device flow response for github-copilot', async () => { + createHandler() + + const handler = transport._handlers.get(ProviderEvents.START_OAUTH) + const result = await handler!({providerId: 'github-copilot'}, 'client-1') + + expect(result.success).to.be.true + expect(result.callbackMode).to.equal('device') + expect(result.userCode).to.equal('TEST-CODE') + expect(result.verificationUri).to.equal('https://github.com/login/device') + }) + + it('should not start callback server for device flow', async () => { + createHandler() + + const handler = transport._handlers.get(ProviderEvents.START_OAUTH) + await handler!({providerId: 'github-copilot'}, 'client-1') + + expect(mockCallbackServer.start.notCalled).to.be.true + }) + + it('should not generate PKCE for device flow', async () => { + createHandler() + + const handler = transport._handlers.get(ProviderEvents.START_OAUTH) + await handler!({providerId: 'github-copilot'}, 'client-1') + + expect(generatePkceStub.notCalled).to.be.true + }) + + it('should open browser to verification URI', async () => { + createHandler() + + const handler = transport._handlers.get(ProviderEvents.START_OAUTH) + await handler!({providerId: 'github-copilot'}, 'client-1') + + expect(browserLauncher.open.calledOnce).to.be.true + expect(browserLauncher.open.firstCall.args[0]).to.equal('https://github.com/login/device') + }) + + it('should return userCode and verificationUri in response', async () => { + createHandler() + + const handler = transport._handlers.get(ProviderEvents.START_OAUTH) + const result = await handler!({providerId: 'github-copilot'}, 'client-1') + + expect(result.userCode).to.equal('TEST-CODE') + expect(result.verificationUri).to.equal('https://github.com/login/device') + expect(result.authUrl).to.equal('https://github.com/login/device') + }) + + it('should handle device code request failure', async () => { + requestDeviceCodeStub.rejects(new Error('GitHub API unavailable')) + createHandler() + + const handler = transport._handlers.get(ProviderEvents.START_OAUTH) + const result = await handler!({providerId: 'github-copilot'}, 'client-1') + + expect(result.success).to.be.false + expect(result.error).to.include('GitHub API unavailable') + }) + + it('should call requestDeviceCode with correct clientId and scope', async () => { + createHandler() + + const handler = transport._handlers.get(ProviderEvents.START_OAUTH) + await handler!({providerId: 'github-copilot'}, 'client-1') + + expect(requestDeviceCodeStub.calledOnce).to.be.true + const params = requestDeviceCodeStub.firstCall.args[0] + expect(params.clientId).to.equal('Iv1.b507a08c87ecfe98') + expect(params.scope).to.equal('read:user') + }) + }) + + // ==================== OAuth: AWAIT_OAUTH_CALLBACK (device flow) ==================== + + async function startDeviceFlow(): Promise { + const startHandler = transport._handlers.get(ProviderEvents.START_OAUTH) + await startHandler!({providerId: 'github-copilot'}, 'client-1') + } + + describe('provider:awaitOAuthCallback (device flow)', () => { + it('should poll for access token and exchange for Copilot token', async () => { + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + const result = await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(result.success).to.be.true + expect(pollForAccessTokenStub.calledOnce).to.be.true + expect(exchangeForCopilotTokenStub.calledOnce).to.be.true + expect(exchangeForCopilotTokenStub.firstCall.args[0]).to.equal('gho_test_github_token') + }) + + it('should store Copilot token in keychain', async () => { + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(providerKeychainStore.setApiKey.calledWith('github-copilot', 'tid=copilot-session-token')).to.be.true + }) + + it('should store GitHub token as refresh token in OAuth token store', async () => { + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(providerOAuthTokenStore.set.calledOnce).to.be.true + const [providerId, tokenRecord] = providerOAuthTokenStore.set.firstCall.args + expect(providerId).to.equal('github-copilot') + expect(tokenRecord.refreshToken).to.equal('gho_test_github_token') + expect(tokenRecord.expiresAt).to.be.a('string') + }) + + it('should connect provider with authMethod oauth', async () => { + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(providerConfigStore.connectProvider.calledOnce).to.be.true + const connectArgs = providerConfigStore.connectProvider.firstCall.args + expect(connectArgs[0]).to.equal('github-copilot') + expect(connectArgs[1]).to.deep.include({authMethod: 'oauth'}) + }) + + it('should broadcast PROVIDER_UPDATED on success', async () => { + createHandler() + await startDeviceFlow() + + transport.broadcast.resetHistory() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(transport.broadcast.calledWith(TransportDaemonEventNames.PROVIDER_UPDATED, {})).to.be.true + }) + + it('should return error when polling fails', async () => { + pollForAccessTokenStub.rejects(new Error('Authorization denied by user')) + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + const result = await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(result.success).to.be.false + expect(result.error).to.include('Authorization denied by user') + }) + + it('should clean up flow state on success', async () => { + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + // Second await should fail — flow cleaned up + const result = await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + expect(result.success).to.be.false + }) + + it('should clean up flow state on failure', async () => { + pollForAccessTokenStub.rejects(new Error('Device code expired')) + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + const result = await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(result.success).to.be.false + + // Second await should also fail (flow cleaned up) + const result2 = await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + expect(result2.success).to.be.false + expect(result2.error).to.include('No active OAuth flow') + }) + + it('should return error when no active device flow exists', async () => { + createHandler() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + const result = await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(result.success).to.be.false + expect(result.error).to.include('No active OAuth flow') + }) + + it('should pass correct params to pollForAccessToken', async () => { + createHandler() + await startDeviceFlow() + + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + + const params = pollForAccessTokenStub.firstCall.args[0] + expect(params.deviceCode).to.equal('test-device-code') + expect(params.clientId).to.equal('Iv1.b507a08c87ecfe98') + expect(params.expiresIn).to.equal(900) + expect(params.interval).to.equal(5) + }) + }) + + // ==================== OAuth: CANCEL_OAUTH (device flow) ==================== + + describe('provider:cancelOAuth (device flow)', () => { + it('should delete device flow state', async () => { + createHandler() + + const startHandler = transport._handlers.get(ProviderEvents.START_OAUTH) + await startHandler!({providerId: 'github-copilot'}, 'client-1') + + const cancelHandler = transport._handlers.get(ProviderEvents.CANCEL_OAUTH) + const result = await cancelHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(result).to.deep.equal({success: true}) + + // Flow should be gone — await should fail + const awaitHandler = transport._handlers.get(ProviderEvents.AWAIT_OAUTH_CALLBACK) + const awaitResult = await awaitHandler!({providerId: 'github-copilot'}, 'client-1') + expect(awaitResult.success).to.be.false + }) + + it('should return success when no active flow exists', async () => { + createHandler() + + const cancelHandler = transport._handlers.get(ProviderEvents.CANCEL_OAUTH) + const result = await cancelHandler!({providerId: 'github-copilot'}, 'client-1') + + expect(result).to.deep.equal({success: true}) + }) + }) + + // ==================== List: device flow fields ==================== + + describe('provider:list (device flow fields)', () => { + it('should include supportsOAuth and callbackMode device for github-copilot', async () => { + providerConfigStore.read.resolves(ProviderConfig.createDefault()) + createHandler() + + const handler = transport._handlers.get(ProviderEvents.LIST) + const result = await handler!(undefined, 'client-1') + + const copilot = result.providers.find((p: {id: string}) => p.id === 'github-copilot') + expect(copilot?.supportsOAuth).to.be.true + expect(copilot?.oauthCallbackMode).to.equal('device') + }) + }) })