import { intro, note, outro, spinner } from "@clack/prompts"; import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime"; import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation"; import { resolveExpiresAtMsFromDurationMs, nonNegativeSecondsToSafeMilliseconds, positiveSecondsToSafeMilliseconds, resolveTimerTimeoutMs, } from "openclaw/plugin-sdk/number-runtime"; import { applyAuthProfileConfig, ensureAuthProfileStore, upsertAuthProfileWithLock, } from "openclaw/plugin-sdk/provider-auth"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; const CLIENT_ID = "Iv1.b507a08c87ecfe98"; const DEVICE_CODE_URL = "https://github.com/login/device/code"; const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; const GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device"; const GITHUB_AUTH_SSRF_POLICY: SsrFPolicy = { hostnameAllowlist: ["github.com"] }; type DeviceCodeResponse = { deviceCode: string; userCode: string; verificationUri: string; expiresInMs: number; expiresAt: number; intervalMs: number; }; type DeviceTokenResponse = | { access_token: string; token_type: string; scope?: string; } | { error: string; error_description?: string; error_uri?: string; }; const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied"); const GITHUB_DEVICE_EXPIRED = Symbol("github-device-expired"); type UpsertAuthProfileParams = Parameters[0]; class GitHubDeviceFlowError extends Error { readonly kind: symbol; constructor(kind: symbol, message: string) { super(message); this.kind = kind; this.name = "GitHubDeviceFlowError"; } } let githubDeviceFlowFetchGuard = fetchWithSsrFGuard; export function setGitHubCopilotDeviceFlowFetchGuardForTesting( impl: typeof fetchWithSsrFGuard | null, ): void { githubDeviceFlowFetchGuard = impl ?? fetchWithSsrFGuard; } async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise { const updated = await upsertAuthProfileWithLock(params); if (!updated) { throw new Error( "Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.", ); } } function isGitHubDeviceAccessDeniedError(err: unknown): boolean { return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_ACCESS_DENIED; } function isGitHubDeviceExpiredError(err: unknown): boolean { return err instanceof GitHubDeviceFlowError && err.kind === GITHUB_DEVICE_EXPIRED; } function parseJsonResponse(value: unknown): Record { if (!value || typeof value !== "object") { throw new Error("Unexpected response from GitHub"); } return value as Record; } function parseDeviceCodeResponse( value: Record, issuedAt: number, ): DeviceCodeResponse { const expiresInMs = positiveSecondsToSafeMilliseconds(value.expires_in); const intervalMs = nonNegativeSecondsToSafeMilliseconds(value.interval); const expiresAt = expiresInMs === undefined ? undefined : resolveExpiresAtMsFromDurationMs(expiresInMs, { nowMs: issuedAt }); if ( typeof value.device_code !== "string" || !value.device_code || typeof value.user_code !== "string" || !value.user_code || typeof value.verification_uri !== "string" || !value.verification_uri || expiresInMs === undefined || expiresAt === undefined || intervalMs === undefined ) { throw new Error("GitHub device code response missing fields"); } return { deviceCode: value.device_code, userCode: value.user_code, verificationUri: value.verification_uri, expiresInMs, expiresAt, intervalMs, }; } async function postGitHubDeviceFlowForm(params: { url: string; body: URLSearchParams; failureLabel: string; }): Promise> { const { response, release } = await githubDeviceFlowFetchGuard({ url: params.url, init: { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, body: params.body, }, requireHttps: true, policy: GITHUB_AUTH_SSRF_POLICY, auditContext: "github-copilot-device-flow", }); try { if (!response.ok) { throw new Error(`${params.failureLabel}: HTTP ${response.status}`); } return parseJsonResponse(await response.json()); } finally { await release(); } } async function requestDeviceCode(params: { scope: string }): Promise { const body = new URLSearchParams({ client_id: CLIENT_ID, scope: params.scope, }); const json = await postGitHubDeviceFlowForm({ url: DEVICE_CODE_URL, body, failureLabel: "GitHub device code failed", }); // Anchor expiry to when GitHub issued the code, before UI prompts or browser launch. return parseDeviceCodeResponse(json, Date.now()); } async function pollForAccessToken(params: { deviceCode: string; intervalMs: number; expiresAt: number; }): Promise { const bodyBase = new URLSearchParams({ client_id: CLIENT_ID, device_code: params.deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code", }); while (Date.now() < params.expiresAt) { const json = (await postGitHubDeviceFlowForm({ url: ACCESS_TOKEN_URL, body: bodyBase, failureLabel: "GitHub device token failed", })) as DeviceTokenResponse; if ("access_token" in json && typeof json.access_token === "string") { return json.access_token; } const err = "error" in json ? json.error : "unknown"; if (err === "authorization_pending") { await sleepGitHubDevicePollDelay(params.intervalMs, params.expiresAt); continue; } if (err === "slow_down") { await sleepGitHubDevicePollDelay(params.intervalMs + 2000, params.expiresAt); continue; } if (err === "expired_token") { throw new GitHubDeviceFlowError( GITHUB_DEVICE_EXPIRED, "GitHub device code expired; run login again", ); } if (err === "access_denied") { throw new GitHubDeviceFlowError(GITHUB_DEVICE_ACCESS_DENIED, "GitHub login cancelled"); } throw new Error(`GitHub device flow error: ${err}`); } throw new GitHubDeviceFlowError( GITHUB_DEVICE_EXPIRED, "GitHub device code expired; run login again", ); } async function sleepGitHubDevicePollDelay(delayMs: number, expiresAt: number): Promise { const requestedDelayMs = Math.max(1, Math.floor(delayMs)); const targetAt = Math.min(Date.now() + requestedDelayMs, expiresAt); while (Date.now() < targetAt) { const remainingMs = Math.max(1, targetAt - Date.now()); const safeDelayMs = resolveTimerTimeoutMs(remainingMs, 1); await new Promise((resolve) => { setTimeout(resolve, Math.min(safeDelayMs, remainingMs)); }); } } function normalizeGitHubDeviceVerificationUrl(raw: string): string { let parsed: URL; try { parsed = new URL(raw); } catch { throw new Error("GitHub device flow returned an invalid verification URL"); } if ( parsed.protocol !== "https:" || parsed.hostname !== "github.com" || parsed.pathname !== "/login/device" || parsed.username || parsed.password ) { throw new Error("GitHub device flow returned an unexpected verification URL"); } return GITHUB_DEVICE_VERIFICATION_URL; } function normalizeGitHubDeviceUserCode(raw: string): string { const userCode = raw.trim(); if (!userCode || userCode.length > 64) { throw new Error("GitHub device flow returned an invalid user code"); } return userCode; } export type GitHubCopilotDeviceFlowResult = | { status: "authorized"; accessToken: string } | { status: "access_denied" } | { status: "expired" }; export type GitHubCopilotDeviceFlowIO = { showCode(args: { verificationUrl: string; userCode: string; expiresInMs: number }): Promise; openUrl?: (url: string) => Promise; }; export async function runGitHubCopilotDeviceFlow( io: GitHubCopilotDeviceFlowIO, ): Promise { const device = await requestDeviceCode({ scope: "read:user" }); const verificationUrl = normalizeGitHubDeviceVerificationUrl(device.verificationUri); const userCode = normalizeGitHubDeviceUserCode(device.userCode); await io.showCode({ verificationUrl, userCode, expiresInMs: device.expiresInMs, }); try { await io.openUrl?.(verificationUrl); } catch { // The code and URL have already been shown. Browser launch is best-effort. } try { const accessToken = await pollForAccessToken({ deviceCode: device.deviceCode, intervalMs: Math.max(1000, device.intervalMs), expiresAt: device.expiresAt, }); return { status: "authorized", accessToken }; } catch (err) { if (isGitHubDeviceAccessDeniedError(err)) { return { status: "access_denied" }; } if (isGitHubDeviceExpiredError(err)) { return { status: "expired" }; } throw err; } } export async function githubCopilotLoginCommand( opts: { profileId?: string; yes?: boolean; agentDir?: string }, runtime: RuntimeEnv, ) { if (!process.stdin.isTTY) { throw new Error("github-copilot login requires an interactive TTY."); } intro(stylePromptTitle("GitHub Copilot login")); const profileId = opts.profileId?.trim() || "github-copilot:github"; const store = ensureAuthProfileStore(opts.agentDir, { allowKeychainPrompt: false, }); if (store.profiles[profileId] && !opts.yes) { note( `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, stylePromptTitle("Existing credentials"), ); } const spin = spinner(); spin.start("Requesting device code from GitHub..."); const device = await requestDeviceCode({ scope: "read:user" }); spin.stop("Device code ready"); note( [`Visit: ${device.verificationUri}`, `Code: ${device.userCode}`].join("\n"), stylePromptTitle("Authorize"), ); const intervalMs = Math.max(1000, device.intervalMs); const polling = spinner(); polling.start("Waiting for GitHub authorization..."); const accessToken = await pollForAccessToken({ deviceCode: device.deviceCode, intervalMs, expiresAt: device.expiresAt, }); polling.stop("GitHub access token acquired"); await upsertAuthProfileWithLockOrThrow({ profileId, credential: { type: "token", provider: "github-copilot", token: accessToken, }, agentDir: opts.agentDir, }); await updateConfig((cfg) => applyAuthProfileConfig(cfg, { provider: "github-copilot", profileId, mode: "token", }), ); logConfigUpdated(runtime); runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); outro("Done"); }