diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5d1a9e91..da13d16f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed GitHub Copilot device-code login polling to respect OAuth slow-down intervals, wait before the first token poll, and include a clearer clock-drift hint in WSL/VM environments when repeated slow-downs lead to timeout. + ## [0.57.1] - 2026-03-07 ### Fixed diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 1b0fe623..dcacb43d 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -20,6 +20,9 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; +const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2; +const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4; + type DeviceCodeResponse = { device_code: string; user_code: string; @@ -103,10 +106,10 @@ async function startDeviceFlow(domain: string): Promise { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/json", + "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "GitHubCopilotChat/0.35.0", }, - body: JSON.stringify({ + body: new URLSearchParams({ client_id: CLIENT_ID, scope: "read:user", }), @@ -174,20 +177,26 @@ async function pollForGitHubAccessToken( const urls = getUrls(domain); const deadline = Date.now() + expiresIn * 1000; let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); + let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER; + let slowDownResponses = 0; while (Date.now() < deadline) { if (signal?.aborted) { throw new Error("Login cancelled"); } + const remainingMs = deadline - Date.now(); + const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs); + await abortableSleep(waitMs, signal); + const raw = await fetchJson(urls.accessTokenUrl, { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/json", + "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "GitHubCopilotChat/0.35.0", }, - body: JSON.stringify({ + body: new URLSearchParams({ client_id: CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code", @@ -199,22 +208,28 @@ async function pollForGitHubAccessToken( } if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { - const err = (raw as DeviceTokenErrorResponse).error; - if (err === "authorization_pending") { - await abortableSleep(intervalMs, signal); + const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse; + if (error === "authorization_pending") { continue; } - if (err === "slow_down") { - intervalMs += 5000; - await abortableSleep(intervalMs, signal); + if (error === "slow_down") { + slowDownResponses += 1; + intervalMs = + typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000); + intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER; continue; } - throw new Error(`Device flow failed: ${err}`); + const descriptionSuffix = description ? `: ${description}` : ""; + throw new Error(`Device flow failed: ${error}${descriptionSuffix}`); } + } - await abortableSleep(intervalMs, signal); + if (slowDownResponses > 0) { + throw new Error( + "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.", + ); } throw new Error("Device flow timed out"); diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts new file mode 100644 index 00000000..0367892c --- /dev/null +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot.js"; + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + }, + }); +} + +function getUrl(input: unknown): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + if (input instanceof Request) { + return input.url; + } + throw new Error(`Unsupported fetch input: ${String(input)}`); +} + +describe("GitHub Copilot OAuth device flow", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("waits before the first poll and increases the safety margin after slow_down", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-03-09T00:00:00Z"); + vi.setSystemTime(startTime); + + const accessTokenPollTimes: number[] = []; + const accessTokenResponses = [ + jsonResponse({ error: "authorization_pending", error_description: "pending" }), + jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), + jsonResponse({ access_token: "ghu_refresh_token" }), + ]; + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }); + expect(String(init?.body)).toContain("client_id="); + expect(String(init?.body)).toContain("scope=read%3Auser"); + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 5, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + accessTokenPollTimes.push(Date.now()); + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }); + expect(String(init?.body)).toContain("client_id="); + expect(String(init?.body)).toContain("device_code=device-code"); + expect(String(init?.body)).toContain("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"); + const response = accessTokenResponses.shift(); + if (!response) { + throw new Error("Unexpected extra access token poll"); + } + return response; + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const loginPromise = loginGitHubCopilot({ + onAuth: () => {}, + onPrompt: async () => "", + onProgress: () => {}, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(accessTokenPollTimes).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(5999); + expect(accessTokenPollTimes).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1); + expect(accessTokenPollTimes).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(5999); + expect(accessTokenPollTimes).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + expect(accessTokenPollTimes).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(13999); + expect(accessTokenPollTimes).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(1); + await loginPromise; + + expect(accessTokenPollTimes).toEqual([ + startTime.getTime() + 6000, + startTime.getTime() + 12000, + startTime.getTime() + 26000, + ]); + }); + + it("uses the remaining lifetime for a final poll before timing out after repeated slow_down responses", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-03-09T00:00:00Z"); + vi.setSystemTime(startTime); + + const accessTokenPollTimes: number[] = []; + const accessTokenResponses = [ + jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }), + jsonResponse({ error: "slow_down", error_description: "still too fast", interval: 15 }), + jsonResponse({ error: "authorization_pending", error_description: "pending" }), + ]; + + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 5, + expires_in: 25, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + accessTokenPollTimes.push(Date.now()); + const response = accessTokenResponses.shift(); + if (!response) { + throw new Error("Unexpected extra access token poll"); + } + return response; + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const loginPromise = loginGitHubCopilot({ + onAuth: () => {}, + onPrompt: async () => "", + }); + const rejection = expect(loginPromise).rejects.toThrow( + /Device flow timed out after one or more slow_down responses/, + ); + + await vi.advanceTimersByTimeAsync(6000); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000]); + + await vi.advanceTimersByTimeAsync(14000); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + + await vi.advanceTimersByTimeAsync(4999); + expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]); + + await vi.advanceTimersByTimeAsync(1); + await rejection; + + expect(accessTokenPollTimes).toEqual([ + startTime.getTime() + 6000, + startTime.getTime() + 20000, + startTime.getTime() + 25000, + ]); + }); +});