From 96875c8e96ab2a405eb217540f7028ee1a756b52 Mon Sep 17 00:00:00 2001 From: Sohayb Date: Thu, 8 Jan 2026 23:49:43 +0100 Subject: [PATCH] feat: Add GitHub Copilot integration with device code authentication - Implemented authentication flow for GitHub Copilot using device code. - Created configuration for Copilot API endpoints and client ID. - Developed execution module to handle API calls to Copilot for chat completions. - Added metadata for Copilot engine integration. --- src/cli/commands/auth.command.ts | 3 +- src/infra/engines/core/registry.ts | 2 + src/infra/engines/providers/copilot/auth.ts | 370 ++++++++++++++++++ src/infra/engines/providers/copilot/config.ts | 42 ++ .../providers/copilot/execution/index.ts | 6 + .../providers/copilot/execution/runner.ts | 283 ++++++++++++++ src/infra/engines/providers/copilot/index.ts | 22 ++ .../engines/providers/copilot/metadata.ts | 13 + 8 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 src/infra/engines/providers/copilot/auth.ts create mode 100644 src/infra/engines/providers/copilot/config.ts create mode 100644 src/infra/engines/providers/copilot/execution/index.ts create mode 100644 src/infra/engines/providers/copilot/execution/runner.ts create mode 100644 src/infra/engines/providers/copilot/index.ts create mode 100644 src/infra/engines/providers/copilot/metadata.ts diff --git a/src/cli/commands/auth.command.ts b/src/cli/commands/auth.command.ts index 96d3d137..979688db 100644 --- a/src/cli/commands/auth.command.ts +++ b/src/cli/commands/auth.command.ts @@ -115,7 +115,8 @@ export async function handleLogin(providerId: string): Promise { return; } - await engine.auth.ensureAuth(); + // Pass true to force interactive login (TUI has already destroyed the renderer) + await engine.auth.ensureAuth(true); console.log(`${engine.metadata.name} authentication successful.`); } diff --git a/src/infra/engines/core/registry.ts b/src/infra/engines/core/registry.ts index b84e94a0..6898fa5d 100644 --- a/src/infra/engines/core/registry.ts +++ b/src/infra/engines/core/registry.ts @@ -13,6 +13,7 @@ import ccrEngine from '../providers/ccr/index.js'; import opencodeEngine from '../providers/opencode/index.js'; import auggieEngine from '../providers/auggie/index.js'; import mistralEngine from '../providers/mistral/index.js'; +import copilotEngine from '../providers/copilot/index.js'; /** * Engine Registry - Singleton that manages all available engines @@ -35,6 +36,7 @@ class EngineRegistry { const engineModules = [ opencodeEngine, claudeEngine, + copilotEngine, codexEngine, cursorEngine, mistralEngine, diff --git a/src/infra/engines/providers/copilot/auth.ts b/src/infra/engines/providers/copilot/auth.ts new file mode 100644 index 00000000..e25af8ba --- /dev/null +++ b/src/infra/engines/providers/copilot/auth.ts @@ -0,0 +1,370 @@ +/** + * GitHub Copilot Authentication via Device Code Flow + * + * This implements the same authentication mechanism used by VS Code and IntelliJ Copilot plugins. + * The flow is: + * 1. Request a device code from GitHub + * 2. User enters code at github.com/login/device + * 3. Poll GitHub for OAuth token (gho_xxx) + * 4. Exchange OAuth token for Copilot token (ghu_xxx) + * 5. Use Copilot token for API calls + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; + +import { + COPILOT_CLIENT_ID, + GITHUB_DEVICE_CODE_URL, + GITHUB_OAUTH_TOKEN_URL, + COPILOT_TOKEN_URL, + OAUTH_SCOPE, + ENV, +} from './config.js'; +import { metadata } from './metadata.js'; + +/** Directory for storing Copilot credentials */ +const CONFIG_DIR = join(homedir(), '.codemachine', 'copilot'); +const TOKEN_PATH = join(CONFIG_DIR, 'token.json'); + +/** Response from GitHub's device code endpoint */ +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +/** Response from GitHub's OAuth token endpoint */ +interface OAuthTokenResponse { + access_token?: string; + token_type?: string; + scope?: string; + error?: string; + error_description?: string; +} + +/** Response from Copilot's token exchange endpoint */ +interface CopilotTokenResponse { + token: string; + expires_at: number; +} + +/** Stored token data */ +interface StoredTokens { + github_token: string; + copilot_token: string; + expires_at: number; +} + +/** + * Request a device code from GitHub + * User will need to enter this code at the verification URL + */ +async function requestDeviceCode(): Promise { + const response = await fetch(GITHUB_DEVICE_CODE_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: COPILOT_CLIENT_ID, + scope: OAUTH_SCOPE, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get device code: ${error}`); + } + + return response.json(); +} + +/** + * Poll GitHub for OAuth token after user authorizes + * Returns the OAuth token (gho_xxx) when user completes authorization + */ +async function pollForOAuthToken(deviceCode: string, interval: number): Promise { + let pollInterval = interval; + + while (true) { + await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000)); + + const response = await fetch(GITHUB_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: COPILOT_CLIENT_ID, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }); + + const data: OAuthTokenResponse = await response.json(); + + if (data.error === 'authorization_pending') { + // User hasn't authorized yet, keep polling + continue; + } + + if (data.error === 'slow_down') { + // Rate limited, increase interval + pollInterval += 5; + continue; + } + + if (data.error) { + throw new Error(data.error_description || data.error); + } + + if (data.access_token) { + return data.access_token; + } + + throw new Error('Unexpected response from GitHub OAuth'); + } +} + +/** + * Exchange GitHub OAuth token for Copilot API token + * The Copilot token (ghu_xxx) is what's used for actual API calls + */ +async function exchangeForCopilotToken(githubToken: string): Promise { + const response = await fetch(COPILOT_TOKEN_URL, { + method: 'GET', + headers: { + Authorization: `token ${githubToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get Copilot token: ${response.status} ${error}`); + } + + return response.json(); +} + +/** + * Read stored tokens from disk + */ +function readStoredTokens(): StoredTokens | null { + if (!existsSync(TOKEN_PATH)) { + return null; + } + + try { + const data = readFileSync(TOKEN_PATH, 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } +} + +/** + * Save tokens to disk + */ +function saveTokens(tokens: StoredTokens): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2)); +} + +/** + * Check if stored Copilot token is expired + * Includes 5 minute buffer for safety + */ +function isTokenExpired(expiresAt: number): boolean { + const now = Math.floor(Date.now() / 1000); + const buffer = 5 * 60; // 5 minutes + return now > expiresAt - buffer; +} + +/** + * Refresh the Copilot token using stored GitHub OAuth token + */ +async function refreshCopilotToken(githubToken: string): Promise { + return exchangeForCopilotToken(githubToken); +} + +/** + * Get valid Copilot token, refreshing if needed + */ +export function getToken(): string | undefined { + // Check environment variable first + if (process.env[ENV.COPILOT_TOKEN]) { + return process.env[ENV.COPILOT_TOKEN]; + } + + const stored = readStoredTokens(); + if (!stored) { + return undefined; + } + + return stored.copilot_token; +} + +/** + * Check if user is authenticated with Copilot + */ +export async function isAuthenticated(): Promise { + const stored = readStoredTokens(); + if (!stored) { + return false; + } + + // Check if token is expired + if (isTokenExpired(stored.expires_at)) { + // Try to refresh + try { + const newToken = await refreshCopilotToken(stored.github_token); + saveTokens({ + ...stored, + copilot_token: newToken.token, + expires_at: newToken.expires_at, + }); + return true; + } catch { + // Refresh failed, need to re-authenticate + return false; + } + } + + return true; +} + +/** + * Open URL in default browser (cross-platform) + */ +async function openBrowser(url: string): Promise { + const platform = process.platform; + try { + if (platform === 'darwin') { + Bun.spawn(['open', url], { stdio: ['ignore', 'ignore', 'ignore'] }); + } else if (platform === 'win32') { + Bun.spawn(['cmd', '/c', 'start', '', url], { stdio: ['ignore', 'ignore', 'ignore'] }); + } else { + // Linux and others + Bun.spawn(['xdg-open', url], { stdio: ['ignore', 'ignore', 'ignore'] }); + } + } catch { + // Silently fail - user can manually open the URL + } +} + +/** + * Write directly to stdout for better terminal compatibility + * After TUI destruction, console.log may not be visible due to terminal state + */ +function write(text: string): void { + process.stdout.write(text); +} + +/** + * Run the device code flow authentication + * Shows the user a code to enter at GitHub's website + */ +export async function authenticate(): Promise { + // Use direct stdout writes for better terminal compatibility after TUI destruction + write('\n'); + write('============================================================\n'); + write(' GitHub Copilot Authentication\n'); + write('============================================================\n\n'); + + // Request device code + const deviceCodeResponse = await requestDeviceCode(); + + // Try to open browser automatically + await openBrowser(deviceCodeResponse.verification_uri); + + // Display instructions with prominent device code box + write(' A browser window should open automatically.\n'); + write(' If not, please open this URL:\n\n'); + write(` ${deviceCodeResponse.verification_uri}\n\n`); + write(' +---------------------------------------+\n'); + write(` | Enter code: ${deviceCodeResponse.user_code.padEnd(22)}|\n`); + write(' +---------------------------------------+\n\n'); + write(' Waiting for authorization...\n\n'); + + // Poll for OAuth token + const githubToken = await pollForOAuthToken( + deviceCodeResponse.device_code, + deviceCodeResponse.interval + ); + + write(' Authorization received! Getting Copilot access...\n\n'); + + // Exchange for Copilot token + const copilotToken = await exchangeForCopilotToken(githubToken); + + // Save tokens + saveTokens({ + github_token: githubToken, + copilot_token: copilotToken.token, + expires_at: copilotToken.expires_at, + }); + + write(' Authentication successful!\n'); + write('============================================================\n\n'); + + return copilotToken.token; +} + +/** + * Ensure user is authenticated. + * + * When called with forceLogin=true (from TUI auth menu), it will run the + * device code flow. Otherwise, it just checks authentication status. + * + * This separation is important because the TUI must destroy its renderer + * before console.log output becomes visible to the user. + */ +export async function ensureAuth(forceLogin = false): Promise { + if (await isAuthenticated()) { + return true; + } + + // Only attempt interactive login if explicitly requested (from TUI auth menu) + if (forceLogin) { + try { + await authenticate(); + return true; + } catch (error) { + process.stderr.write(`\n Authentication failed: ${error instanceof Error ? error.message : error}\n`); + return false; + } + } + + // Not authenticated and not forcing login - caller should handle this + return false; +} + +/** + * Clear stored authentication credentials + */ +export async function clearAuth(): Promise { + try { + if (existsSync(TOKEN_PATH)) { + unlinkSync(TOKEN_PATH); + } + process.stdout.write(`\n${metadata.name} authentication cleared.\n`); + } catch (error) { + process.stderr.write(`Failed to clear auth: ${error instanceof Error ? error.message : error}\n`); + } +} + +/** + * Get next auth menu action based on current state + */ +export async function nextAuthMenuAction(): Promise<'login' | 'logout'> { + return (await isAuthenticated()) ? 'logout' : 'login'; +} diff --git a/src/infra/engines/providers/copilot/config.ts b/src/infra/engines/providers/copilot/config.ts new file mode 100644 index 00000000..f5132a16 --- /dev/null +++ b/src/infra/engines/providers/copilot/config.ts @@ -0,0 +1,42 @@ +/** + * GitHub Copilot API Configuration + * + * These endpoints and client ID are based on the official Copilot IDE plugins. + * The device code flow is the same authentication mechanism used by VS Code and IntelliJ. + */ + +/** GitHub OAuth App Client ID (official Copilot client) */ +export const COPILOT_CLIENT_ID = 'Iv1.b507a08c87ecfe98'; + +/** GitHub device code endpoint */ +export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code'; + +/** GitHub OAuth token endpoint */ +export const GITHUB_OAUTH_TOKEN_URL = 'https://github.com/login/oauth/access_token'; + +/** Copilot token exchange endpoint */ +export const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token'; + +/** Copilot chat completions API */ +export const COPILOT_API_URL = 'https://api.githubcopilot.com/chat/completions'; + +/** OAuth scope needed for Copilot */ +export const OAUTH_SCOPE = 'read:user'; + +/** Available models via Copilot API */ +export const AVAILABLE_MODELS = [ + 'gpt-4o', + 'gpt-4o-mini', + 'o1', + 'o1-mini', + 'o3-mini', + 'claude-3.5-sonnet', +] as const; + +export type CopilotModel = (typeof AVAILABLE_MODELS)[number]; + +/** Environment variable names */ +export const ENV = { + COPILOT_TOKEN: 'COPILOT_TOKEN', + GITHUB_TOKEN: 'GITHUB_TOKEN', +} as const; diff --git a/src/infra/engines/providers/copilot/execution/index.ts b/src/infra/engines/providers/copilot/execution/index.ts new file mode 100644 index 00000000..7bea2ca5 --- /dev/null +++ b/src/infra/engines/providers/copilot/execution/index.ts @@ -0,0 +1,6 @@ +/** + * Copilot Execution Module + */ + +export { runCopilot } from './runner.js'; +export type { RunCopilotOptions } from './runner.js'; diff --git a/src/infra/engines/providers/copilot/execution/runner.ts b/src/infra/engines/providers/copilot/execution/runner.ts new file mode 100644 index 00000000..8be706f8 --- /dev/null +++ b/src/infra/engines/providers/copilot/execution/runner.ts @@ -0,0 +1,283 @@ +/** + * GitHub Copilot API Runner + * + * Makes API calls to the Copilot chat completions endpoint using streaming. + * This is an API-based runner (no CLI dependency). + */ + +import type { EngineRunOptions, EngineRunResult, ParsedTelemetry } from '../../../core/types.js'; +import { getToken, isAuthenticated, clearAuth } from '../auth.js'; +import { COPILOT_API_URL } from '../config.js'; +import { metadata } from '../metadata.js'; +import { + formatStatus, + formatCommand, + formatResult, + formatMessage, +} from '../../../../../shared/formatters/outputMarkers.js'; +import { logger } from '../../../../../shared/logging/index.js'; + +/** Extended options specific to Copilot runner */ +export interface RunCopilotOptions extends EngineRunOptions { + systemPrompt?: string; +} + +/** SSE data chunk from Copilot API */ +interface StreamChunk { + id?: string; + object?: string; + created?: number; + model?: string; + choices?: Array<{ + index: number; + delta?: { + role?: string; + content?: string; + }; + finish_reason?: string | null; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; +} + +/** + * Parse SSE data from the stream + */ +function parseSSELine(line: string): StreamChunk | null { + if (!line.startsWith('data: ')) { + return null; + } + + const data = line.slice(6).trim(); + + if (data === '[DONE]') { + return null; + } + + try { + return JSON.parse(data); + } catch { + return null; + } +} + +/** + * Run a prompt against the Copilot API + */ +export async function runCopilot(options: RunCopilotOptions): Promise { + const { + prompt, + workingDir, + model = 'gpt-4o', + onData, + onErrorData, + onTelemetry, + abortSignal, + systemPrompt, + } = options; + + if (!prompt) { + throw new Error('runCopilot requires a prompt.'); + } + + if (!workingDir) { + throw new Error('runCopilot requires a working directory.'); + } + + // Check authentication - don't auto-prompt since TUI needs to handle that + const isAuthed = await isAuthenticated(); + if (!isAuthed) { + throw new Error( + 'GitHub Copilot authentication required.\n' + + 'Please login first: Press "a" in the main menu to open Auth settings, then select "GitHub Copilot".' + ); + } + + const token = getToken(); + if (!token) { + throw new Error('Copilot token not available after authentication.'); + } + + logger.debug( + `Copilot runner - prompt length: ${prompt.length}, lines: ${prompt.split('\n').length}, model: ${model}` + ); + + // Emit status + onData?.(formatStatus('Copilot is analyzing your request...') + '\n'); + + const startTime = Date.now(); + let inputTokens = 0; + let outputTokens = 0; + let fullOutput = ''; + + // Build messages + const messages: Array<{ role: string; content: string }> = []; + + // Add system prompt if provided + const effectiveSystemPrompt = + systemPrompt || + `You are an expert software engineer. You are working in the directory: ${workingDir}. Be concise and helpful.`; + + messages.push({ role: 'system', content: effectiveSystemPrompt }); + messages.push({ role: 'user', content: prompt }); + + try { + const response = await fetch(COPILOT_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Editor-Version': 'codemachine/1.0.0', + 'Copilot-Integration-Id': 'codemachine', + Accept: 'text/event-stream', + }, + body: JSON.stringify({ + model, + messages, + stream: true, + max_tokens: 16384, + n: 1, + temperature: 0, + }), + signal: abortSignal, + }); + + if (!response.ok) { + const errorText = await response.text(); + + // Handle authentication errors + if (response.status === 401) { + await clearAuth(); + throw new Error( + 'Copilot session expired. Please re-authenticate by running authentication again.' + ); + } + + // Handle rate limiting + if (response.status === 429) { + throw new Error('Copilot API rate limit exceeded. Please try again later.'); + } + + // Handle Copilot-specific errors + if (response.status === 403) { + throw new Error( + 'Access denied to Copilot API. Please ensure you have an active Copilot subscription.' + ); + } + + throw new Error(`Copilot API error ${response.status}: ${errorText}`); + } + + // Process the streaming response + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) { + throw new Error('Failed to get response reader'); + } + + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + // Process complete lines + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) { + continue; + } + + const parsed = parseSSELine(line); + + if (!parsed) { + continue; + } + + // Extract content from delta + const content = parsed.choices?.[0]?.delta?.content; + if (content) { + fullOutput += content; + // Format as message and emit + onData?.(formatMessage(content) + '\n'); + } + + // Extract usage info if present + if (parsed.usage) { + inputTokens = parsed.usage.prompt_tokens || 0; + outputTokens = parsed.usage.completion_tokens || 0; + } + } + } + + // Process any remaining buffer + if (buffer.trim()) { + const parsed = parseSSELine(buffer); + if (parsed?.choices?.[0]?.delta?.content) { + const content = parsed.choices[0].delta.content; + fullOutput += content; + onData?.(formatMessage(content) + '\n'); + } + if (parsed?.usage) { + inputTokens = parsed.usage.prompt_tokens || 0; + outputTokens = parsed.usage.completion_tokens || 0; + } + } + + // Calculate duration + const durationMs = Date.now() - startTime; + + // Emit telemetry + if (onTelemetry) { + const telemetry: ParsedTelemetry = { + tokensIn: inputTokens, + tokensOut: outputTokens, + cached: 0, + cost: 0, // Copilot is subscription-based, no per-token cost + duration: durationMs, + }; + onTelemetry(telemetry); + } + + logger.debug( + `Copilot completed - tokens: ${inputTokens}in/${outputTokens}out, duration: ${durationMs}ms` + ); + + // Emit completion status + const tokenSummary = `Tokens: ${inputTokens}in/${outputTokens}out`; + onData?.(formatCommand(tokenSummary, 'success') + '\n'); + + return { + stdout: fullOutput, + stderr: '', + }; + } catch (error) { + const err = error as Error; + + // Check for abort + if (err.name === 'AbortError') { + logger.debug('Copilot request was aborted'); + return { stdout: fullOutput, stderr: 'Request was cancelled' }; + } + + // Log and re-throw other errors + logger.error('Copilot API error', { error: err.message }); + + const errorMsg = err.message || 'Unknown Copilot error'; + onErrorData?.(formatResult(errorMsg, true) + '\n'); + + throw error; + } +} diff --git a/src/infra/engines/providers/copilot/index.ts b/src/infra/engines/providers/copilot/index.ts new file mode 100644 index 00000000..34a55b99 --- /dev/null +++ b/src/infra/engines/providers/copilot/index.ts @@ -0,0 +1,22 @@ +/** + * GitHub Copilot Engine + * + * Provides GitHub Copilot integration with CodeMachine's engine runtime. + * Uses device code flow for authentication (same as IDE plugins). + */ + +import type { EngineModule } from '../../core/base.js'; +import { metadata } from './metadata.js'; +import * as auth from './auth.js'; +import { runCopilot } from './execution/index.js'; + +export * from './auth.js'; +export * from './config.js'; +export * from './execution/index.js'; +export { metadata }; + +export default { + metadata, + auth, + run: runCopilot, +} satisfies EngineModule; diff --git a/src/infra/engines/providers/copilot/metadata.ts b/src/infra/engines/providers/copilot/metadata.ts new file mode 100644 index 00000000..32306ccf --- /dev/null +++ b/src/infra/engines/providers/copilot/metadata.ts @@ -0,0 +1,13 @@ +import type { EngineMetadata } from '../../core/base.js'; + +export const metadata: EngineMetadata = { + id: 'copilot', + name: 'GitHub Copilot', + description: 'Use Copilot via GitHub device code authentication', + cliCommand: 'copilot', + cliBinary: '', // No CLI binary - uses direct API + installCommand: 'No installation needed - authenticate with your GitHub account', + defaultModel: 'gpt-4o', + order: 2, + experimental: false, +};