From 36bb723dfb85a672fc473f01195e15eb86733889 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Wed, 29 Apr 2026 16:45:31 -0700 Subject: [PATCH] fix(github-copilot): support GUI/RPC wizard auth flow (#73290) Merged via squash. Prepared head SHA: aea7d6650c802d3174cb3bf967b50fe804b57073 Co-authored-by: indierawk2k2 <18598712+indierawk2k2@users.noreply.github.com> Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com> Reviewed-by: @shanselman --- CHANGELOG.md | 1 + extensions/github-copilot/index.test.ts | 50 ++++-- extensions/github-copilot/index.ts | 63 +++++--- extensions/github-copilot/login.ts | 114 +++++++++++++- scripts/check-no-raw-channel-fetch.mjs | 4 +- .../test-helpers/provider-auth-contract.ts | 144 +++++++++++++++++- 6 files changed, 331 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3a7c1533e..afa31c3abfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -243,6 +243,7 @@ Docs: https://docs.openclaw.ai - Agents/tool policy: validate caller group IDs against session or spawned context before applying group-scoped tool policies or persisting gateway group metadata, so forged group IDs cannot unlock more permissive tools. (#73720) Thanks @mmaps. - Commands: keep channel-prefixed owner allowlist entries scoped to matching providers so webchat command contexts cannot inherit external channel owners. Thanks @zsxsoft. - Auth/device pairing: bound bootstrap handoff token issuance, redemption, and approved pairing baselines to the documented per-role scope allowlist, so bootstrap approvals cannot persistently grant `operator.admin`, `operator.pairing`, or `node.exec` scopes. Thanks @eleqtrizit. +- Providers/GitHub Copilot: support the GUI/RPC wizard device-code auth flow so onboarding from non-TTY clients (gateway RPC bridge, GUI wizards) completes instead of returning empty profiles. Dangerous-state handling now distinguishes `access_denied` and `expired_token` from transport errors. (#73290) Thanks @indierawk2k2. ## 2026.4.27 diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 4ef1dcd9ac1..a8033017b13 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, - upsertAuthProfile, } from "openclaw/plugin-sdk/agent-runtime"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -216,17 +215,36 @@ describe("github-copilot plugin", () => { }, }), ); - mocks.githubCopilotLoginCommand.mockImplementationOnce(async (opts: { agentDir?: string }) => { - upsertAuthProfile({ - profileId: "github-copilot:github", - credential: { - type: "token", - provider: "github-copilot", - token: "refreshed-token", - }, - agentDir: opts.agentDir, - }); + const fetchMock = vi.fn(async (input: unknown) => { + const target = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input instanceof Request + ? input.url + : String(input); + if (target === "https://github.com/login/device/code") { + return new Response( + JSON.stringify({ + device_code: "device-code-stub", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 0, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (target === "https://github.com/login/oauth/access_token") { + return new Response( + JSON.stringify({ access_token: "refreshed-token", token_type: "bearer" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`); }); + vi.stubGlobal("fetch", fetchMock); const prompter = { confirm: vi.fn(async () => true), note: vi.fn(), @@ -253,16 +271,18 @@ describe("github-copilot plugin", () => { oauth: { createVpsAwareHandlers: vi.fn() }, } as never); - expect(mocks.githubCopilotLoginCommand).toHaveBeenCalledWith( - { yes: true, profileId: "github-copilot:github", agentDir }, - expect.any(Object), - ); + expect(prompter.confirm).toHaveBeenCalledWith({ + message: "GitHub Copilot auth already exists. Re-run login?", + initialValue: false, + }); + expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled(); expect(result.profiles[0]?.credential).toEqual({ type: "token", provider: "github-copilot", token: "refreshed-token", }); } finally { + vi.unstubAllGlobals(); if (isTtyDescriptor) { Object.defineProperty(process.stdin, "isTTY", isTtyDescriptor); } else { diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 6e843ec7658..dc3431a9ea0 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -256,15 +256,14 @@ export default definePluginEntry({ } async function runGitHubCopilotAuth(ctx: ProviderAuthContext) { - const { githubCopilotLoginCommand } = await loadGithubCopilotRuntime(); - let authResult = resolveExistingCopilotAuthResult(ctx.agentDir); - if (authResult) { + const existing = resolveExistingCopilotAuthResult(ctx.agentDir); + if (existing) { const runLogin = await ctx.prompter.confirm({ message: "GitHub Copilot auth already exists. Re-run login?", initialValue: false, }); if (!runLogin) { - return authResult; + return existing; } } @@ -276,26 +275,54 @@ export default definePluginEntry({ "GitHub Copilot", ); - if (!process.stdin.isTTY) { + const { runGitHubCopilotDeviceFlow } = await import("./login.js"); + + const result = await runGitHubCopilotDeviceFlow({ + showCode: async ({ verificationUrl, userCode, expiresInMs }) => { + const expiresInMinutes = Math.max(1, Math.round(expiresInMs / 60_000)); + await ctx.prompter.note( + [ + "Open this URL in your browser and enter the code below.", + `URL: ${verificationUrl}`, + `Code: ${userCode}`, + `Code expires in ${expiresInMinutes} minutes. Never share it.`, + "", + "If a browser does not open automatically after you continue, copy the URL manually.", + ].join("\n"), + "Authorize GitHub Copilot", + ); + }, + openUrl: async (url) => { + await ctx.openUrl(url); + }, + }); + + if (result.status === "access_denied") { + await ctx.prompter.note("GitHub Copilot login was cancelled.", "GitHub Copilot"); + return { profiles: [] }; + } + + if (result.status === "expired") { await ctx.prompter.note( - "GitHub Copilot login requires an interactive TTY.", + "The GitHub device code expired. Retry login to get a new code.", "GitHub Copilot", ); return { profiles: [] }; } - try { - await githubCopilotLoginCommand( - { yes: true, profileId: "github-copilot:github", agentDir: ctx.agentDir }, - ctx.runtime, - ); - } catch (err) { - await ctx.prompter.note(`GitHub Copilot login failed: ${String(err)}`, "GitHub Copilot"); - return { profiles: [] }; - } - - authResult = resolveExistingCopilotAuthResult(ctx.agentDir); - return authResult ?? { profiles: [] }; + return { + profiles: [ + { + profileId: DEFAULT_COPILOT_PROFILE_ID, + credential: { + type: "token" as const, + provider: PROVIDER_ID, + token: result.accessToken, + }, + }, + ], + defaultModel: DEFAULT_COPILOT_MODEL, + }; } api.registerMemoryEmbeddingProvider(githubCopilotMemoryEmbeddingProviderAdapter); diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index 8bf60313ede..03a9b168f10 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -11,6 +11,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/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"; type DeviceCodeResponse = { device_code: string; @@ -32,6 +33,26 @@ type DeviceTokenResponse = error_uri?: string; }; +const GITHUB_DEVICE_ACCESS_DENIED = Symbol("github-device-access-denied"); +const GITHUB_DEVICE_EXPIRED = Symbol("github-device-expired"); + +class GitHubDeviceFlowError extends Error { + readonly kind: symbol; + constructor(kind: symbol, message: string) { + super(message); + this.kind = kind; + this.name = "GitHubDeviceFlowError"; + } +} + +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"); @@ -105,15 +126,100 @@ async function pollForAccessToken(params: { continue; } if (err === "expired_token") { - throw new Error("GitHub device code expired; run login again"); + throw new GitHubDeviceFlowError( + GITHUB_DEVICE_EXPIRED, + "GitHub device code expired; run login again", + ); } if (err === "access_denied") { - throw new Error("GitHub login cancelled"); + throw new GitHubDeviceFlowError(GITHUB_DEVICE_ACCESS_DENIED, "GitHub login cancelled"); } throw new Error(`GitHub device flow error: ${err}`); } - throw new Error("GitHub device code expired; run login again"); + throw new GitHubDeviceFlowError( + GITHUB_DEVICE_EXPIRED, + "GitHub device code expired; run login again", + ); +} + +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.verification_uri); + const userCode = normalizeGitHubDeviceUserCode(device.user_code); + const expiresInMs = device.expires_in * 1000; + // Anchor expiry to when GitHub issued the code, not when the UI finishes prompting. + const expiresAt = Date.now() + expiresInMs; + + await io.showCode({ + verificationUrl, + userCode, + 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.device_code, + intervalMs: Math.max(1000, device.interval * 1000), + 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( @@ -166,8 +272,6 @@ export async function githubCopilotLoginCommand( type: "token", provider: "github-copilot", token: accessToken, - // GitHub device flow token doesn't reliably include expiry here. - // Leave expires unset; we'll exchange into Copilot token plus expiry later. }, agentDir: opts.agentDir, }); diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 84f9fa2aef1..0378f4404d2 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -27,8 +27,8 @@ const allowedRawFetchCallsites = new Set([ bundledPluginCallsite("elevenlabs", "speech-provider.ts", 295), bundledPluginCallsite("elevenlabs", "tts.ts", 74), bundledPluginCallsite("feishu", "src/monitor.webhook.test-helpers.ts", 25), - bundledPluginCallsite("github-copilot", "login.ts", 48), - bundledPluginCallsite("github-copilot", "login.ts", 80), + bundledPluginCallsite("github-copilot", "login.ts", 69), + bundledPluginCallsite("github-copilot", "login.ts", 101), bundledPluginCallsite("googlechat", "src/auth.ts", 83), bundledPluginCallsite("huggingface", "models.ts", 142), bundledPluginCallsite("kilocode", "provider-models.ts", 130), diff --git a/src/plugin-sdk/test-helpers/provider-auth-contract.ts b/src/plugin-sdk/test-helpers/provider-auth-contract.ts index 1ee10620bb6..14ad55e27ba 100644 --- a/src/plugin-sdk/test-helpers/provider-auth-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-auth-contract.ts @@ -350,7 +350,104 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont } }); - it("keeps auth gated on interactive TTYs", async () => { + function stubGitHubDeviceFlowFetch( + outcome: { accessToken: string } | { error: "access_denied" | "expired_token" }, + ) { + const fetchMock = vi.fn(async (input: unknown) => { + const target = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input instanceof Request + ? input.url + : String(input); + if (target === "https://github.com/login/device/code") { + return new Response( + JSON.stringify({ + device_code: "device-code-stub", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 0, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (target === "https://github.com/login/oauth/access_token") { + const body = + "accessToken" in outcome + ? { access_token: outcome.accessToken, token_type: "bearer" } + : { error: outcome.error }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch in github-copilot device flow stub: ${target}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + function buildSpyAuthContext() { + const ctx = buildAuthContext() as ReturnType & { + openUrl: (url: string) => Promise; + prompter: WizardPrompter; + }; + ctx.openUrl = vi.fn(async () => {}); + ctx.prompter.note = vi.fn(async () => {}); + return ctx; + } + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps device auth results provider-owned", async () => { + const provider = await getProvider(); + stubGitHubDeviceFlowFetch({ accessToken: "github-device-token" }); + const ctx = buildSpyAuthContext(); + + const result = await provider.auth[0]?.run(ctx as never); + + expect(result).toEqual({ + profiles: [ + { + profileId: "github-copilot:github", + credential: { + type: "token", + provider: "github-copilot", + token: "github-device-token", + }, + }, + ], + defaultModel: "github-copilot/claude-opus-4.7", + }); + // Credential is sourced from the device flow response, not from the existing + // on-disk auth store. ensureAuthProfileStore is still called by the + // resolveExistingCopilotAuthResult existence check, which legitimately probes + // the store before launching the device flow when no profile exists yet. + expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); + }); + + it("uses the wizard prompter and openUrl hooks for the device code (no stdin/stdout)", async () => { + const provider = await getProvider(); + stubGitHubDeviceFlowFetch({ accessToken: "github-device-token" }); + const ctx = buildSpyAuthContext(); + + await provider.auth[0]?.run(ctx as never); + + expect(ctx.openUrl).toHaveBeenCalledWith("https://github.com/login/device"); + const noteCalls = (ctx.prompter.note as ReturnType).mock.calls; + const codeNote = noteCalls.find( + ([msg]) => typeof msg === "string" && msg.includes("ABCD-1234"), + ); + expect(codeNote).toBeDefined(); + expect(codeNote?.[0]).toContain("https://github.com/login/device"); + }); + + it("supports non-interactive (GUI/RPC) auth contexts without a TTY", async () => { const provider = await getProvider(); const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); @@ -360,12 +457,21 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont enumerable: true, get: () => false, }); + stubGitHubDeviceFlowFetch({ accessToken: "rpc-client-token" }); + const ctx = buildSpyAuthContext(); try { - await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ - profiles: [], - }); - expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); + const result = await provider.auth[0]?.run(ctx as never); + expect(result?.profiles).toEqual([ + { + profileId: "github-copilot:github", + credential: { + type: "token", + provider: "github-copilot", + token: "rpc-client-token", + }, + }, + ]); } finally { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); @@ -374,5 +480,33 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont } } }); + + it("returns no profiles and notes cancellation when the user denies access", async () => { + const provider = await getProvider(); + stubGitHubDeviceFlowFetch({ error: "access_denied" }); + const ctx = buildSpyAuthContext(); + + const result = await provider.auth[0]?.run(ctx as never); + + expect(result).toEqual({ profiles: [] }); + const noteCalls = (ctx.prompter.note as ReturnType).mock.calls; + expect( + noteCalls.some(([msg]) => typeof msg === "string" && msg.toLowerCase().includes("cancel")), + ).toBe(true); + }); + + it("returns no profiles and notes expiry when the device code expires", async () => { + const provider = await getProvider(); + stubGitHubDeviceFlowFetch({ error: "expired_token" }); + const ctx = buildSpyAuthContext(); + + const result = await provider.auth[0]?.run(ctx as never); + + expect(result).toEqual({ profiles: [] }); + const noteCalls = (ctx.prompter.note as ReturnType).mock.calls; + expect( + noteCalls.some(([msg]) => typeof msg === "string" && msg.toLowerCase().includes("expired")), + ).toBe(true); + }); }); }