import { randomUUID } from 'crypto' import { startDeviceFlow, pollDeviceFlow } from '../../services/hermes/copilot-device-flow' import { saveEnvValue, readConfigYaml, writeConfigYaml } from '../../services/config-helpers' import { invalidateAllCaches, resolveCopilotOAuthTokenWithSource, type CopilotTokenSource, } from '../../services/hermes/copilot-models' import { getActiveEnvPath } from '../../services/hermes/hermes-profile' import { readAppConfig, writeAppConfig } from '../../services/app-config' import { readFile } from 'fs/promises' import { logger } from '../../services/logger' const POLL_MAX_DURATION_MS = 15 * 60 * 1000 // 15 minutes hard ceiling const SESSION_GC_GRACE_MS = 60 * 1000 interface CopilotLoginSession { id: string deviceCode: string userCode: string verificationUrl: string expiresIn: number interval: number status: 'pending' | 'approved' | 'denied' | 'expired' | 'error' error?: string createdAt: number } const sessions = new Map() function cleanupSessions(): void { const now = Date.now() sessions.forEach((s, id) => { if (now - s.createdAt > POLL_MAX_DURATION_MS + SESSION_GC_GRACE_MS) { sessions.delete(id) } }) } async function persistToken(token: string): Promise { // 与 disable 对称:只动 ~/.hermes/.env,apps.json 是 VS Code 的文件不要碰。 // 同时把 enabled 置 true —— device flow 完成后用户已显式同意启用 Copilot。 // NOTE: 故意不写 process.env.COPILOT_GITHUB_TOKEN —— 否则该值会跨 profile 持续覆盖 // resolveCopilotOAuthTokenWithSource 的 .env 读取,导致切到别的 profile 仍解析到当前 // profile 的 token。invalidateAllCaches() + .env 文件本身已能保证下次解析读到新 token。 await saveEnvValue('COPILOT_GITHUB_TOKEN', token) await writeAppConfig({ copilotEnabled: true }) invalidateAllCaches() } async function readEnvContent(): Promise { try { return await readFile(getActiveEnvPath(), 'utf-8') } catch { return '' } } async function loginWorker(session: CopilotLoginSession): Promise { const startTime = Date.now() let interval = Math.max(1, session.interval) * 1000 while (Date.now() - startTime < POLL_MAX_DURATION_MS) { await new Promise((resolve) => setTimeout(resolve, interval)) if (session.status !== 'pending') return const result = await pollDeviceFlow(session.deviceCode) if (result.kind === 'success') { try { await persistToken(result.access_token) session.status = 'approved' logger.info('Copilot OAuth login successful') } catch (err: any) { logger.error(err, 'Copilot OAuth: failed to persist token') session.status = 'error' session.error = err?.message ?? 'failed to persist token' } return } if (result.kind === 'pending') continue if (result.kind === 'slow_down') { interval += 5000 continue } if (result.kind === 'denied') { session.status = 'denied' return } if (result.kind === 'expired') { session.status = 'expired' return } logger.error('Copilot OAuth poll error: %s %s', result.error, result.description ?? '') session.status = 'error' session.error = result.description ?? result.error return } session.status = 'expired' } export async function start(ctx: any): Promise { cleanupSessions() try { const data = await startDeviceFlow() const sessionId = randomUUID() const session: CopilotLoginSession = { id: sessionId, deviceCode: data.device_code, userCode: data.user_code, verificationUrl: data.verification_uri, expiresIn: data.expires_in, interval: data.interval, status: 'pending', createdAt: Date.now(), } sessions.set(sessionId, session) loginWorker(session).catch((err) => { logger.error(err, 'Copilot login worker error') session.status = 'error' session.error = err?.message ?? String(err) }) ctx.body = { session_id: sessionId, user_code: data.user_code, verification_url: data.verification_uri, expires_in: data.expires_in, interval: data.interval, } } catch (err: any) { logger.error(err, 'Copilot OAuth start failed') if (err?.name === 'TimeoutError' || err?.name === 'AbortError') { ctx.status = 504 ctx.body = { error: 'GitHub timeout' } return } ctx.status = 502 ctx.body = { error: err?.message ?? 'GitHub OAuth start failed' } } } export async function poll(ctx: any): Promise { const session = sessions.get(ctx.params.sessionId) if (!session) { ctx.status = 404 ctx.body = { error: 'Session not found' } return } ctx.body = { status: session.status, error: session.error || null } } /** * Reports current token resolution and whether Copilot is opt-in enabled. * Frontend Add Provider flow uses this to decide whether to show the * "token detected, click Add" confirmation or kick off device flow. * * Side effect: invalidates the model list cache so a subsequent listing * picks up gh-cli logout / VS Code sign-out without server restart. */ export async function checkToken(ctx: any): Promise { invalidateAllCaches() const env = await readEnvContent() const { token, source } = await resolveCopilotOAuthTokenWithSource(env) const cfg = await readAppConfig() ctx.body = { has_token: Boolean(token), source: source as CopilotTokenSource, enabled: cfg.copilotEnabled === true, } } export async function enable(ctx: any): Promise { await writeAppConfig({ copilotEnabled: true }) invalidateAllCaches() ctx.body = { ok: true } } /** * "Soft delete" Copilot from the web-ui provider list. * - Always: copilotEnabled = false (hides provider regardless of token source). * - source='env' → also clear ~/.hermes/.env COPILOT_GITHUB_TOKEN * (this token belongs to the hermes ecosystem). * - source='gh-cli' → leave gh CLI alone (user's terminal sessions). * - source='apps-json' → leave VS Code Copilot plugin alone. * The user can re-add Copilot any time via "Add Provider". */ export async function disable(ctx: any): Promise { const env = await readEnvContent() const { source } = await resolveCopilotOAuthTokenWithSource(env) // 步骤 1:先清掉默认模型(最容易失败的一步:写 yaml 可能失败)。 // 不能 swallow —— 否则会出现 "list 已隐藏 copilot 但 default 仍是 copilot" 的中间态。 let clearedDefault = false try { const cfg = await readConfigYaml() const modelSection = cfg.model if (typeof modelSection === 'object' && modelSection !== null) { const provider = String(modelSection.provider || '').trim().toLowerCase() if (provider === 'copilot') { cfg.model = {} await writeConfigYaml(cfg) clearedDefault = true } } } catch (err: any) { logger.error(err, 'Copilot disable failed: cannot clear default model') ctx.status = 500 ctx.body = { error: `failed to clear default model: ${err?.message ?? 'unknown error'}` } return } // 步骤 2:清 .env(仅当 source='env')。失败也不能让 enabled flag 偷偷置 false。 try { if (source === 'env') { await saveEnvValue('COPILOT_GITHUB_TOKEN', '') delete process.env.COPILOT_GITHUB_TOKEN } } catch (err: any) { logger.error(err, 'Copilot disable failed: cannot clear .env') ctx.status = 500 ctx.body = { error: `failed to clear .env: ${err?.message ?? 'unknown error'}` } return } // 步骤 3:最后翻 enabled flag。前两步成功才执行。 try { await writeAppConfig({ copilotEnabled: false }) invalidateAllCaches() } catch (err: any) { logger.error(err, 'Copilot disable failed: cannot persist enabled flag') ctx.status = 500 ctx.body = { error: `failed to persist enabled flag: ${err?.message ?? 'unknown error'}` } return } ctx.body = { ok: true, cleared_env: source === 'env', cleared_default: clearedDefault } }