From 22c42b6b309d90193e8489944f90db92e17eb265 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 29 Apr 2026 16:30:43 +0530 Subject: [PATCH] fix(github-copilot): reuse existing auth profiles --- extensions/github-copilot/index.test.ts | 146 +++++++++++++++++- extensions/github-copilot/index.ts | 59 ++++--- extensions/github-copilot/login.ts | 5 +- .../test-helpers/provider-auth-contract.ts | 7 +- 4 files changed, 186 insertions(+), 31 deletions(-) diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 98efde04412..4ef1dcd9ac1 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -4,16 +4,20 @@ 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"; -const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); +const mocks = vi.hoisted(() => ({ + githubCopilotLoginCommand: vi.fn(), + resolveCopilotApiToken: vi.fn(), +})); vi.mock("./register.runtime.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test", - resolveCopilotApiToken: resolveCopilotApiTokenMock, - githubCopilotLoginCommand: vi.fn(), + resolveCopilotApiToken: mocks.resolveCopilotApiToken, + githubCopilotLoginCommand: mocks.githubCopilotLoginCommand, fetchCopilotUsage: vi.fn(), })); @@ -22,6 +26,7 @@ import plugin from "./index.js"; const tempDirs: string[] = []; afterEach(async () => { + vi.clearAllMocks(); clearRuntimeAuthProfileStoreSnapshots(); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -98,11 +103,11 @@ describe("github-copilot plugin", () => { } as never); expect(result).toBeNull(); - expect(resolveCopilotApiTokenMock).not.toHaveBeenCalled(); + expect(mocks.resolveCopilotApiToken).not.toHaveBeenCalled(); }); it("uses live plugin config to re-enable discovery after startup disable", async () => { - resolveCopilotApiTokenMock.mockResolvedValueOnce({ + mocks.resolveCopilotApiToken.mockResolvedValueOnce({ token: "copilot_api_token", baseUrl: "https://api.githubcopilot.live", }); @@ -125,7 +130,7 @@ describe("github-copilot plugin", () => { resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }), } as never); - expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({ + expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({ githubToken: "gh_test_token", env: { GH_TOKEN: "gh_test_token" }, }); @@ -137,6 +142,135 @@ describe("github-copilot plugin", () => { }); }); + it("offers to reuse an existing token profile during interactive onboarding", async () => { + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[0]; + const agentDir = await createAgentDir(); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ + version: 1, + profiles: { + "github-copilot:github": { + type: "token", + provider: "github-copilot", + token: "existing-token", + }, + }, + }), + ); + const prompter = { + confirm: vi.fn(async () => false), + note: vi.fn(), + }; + + const result = await method.run({ + config: {}, + env: {}, + agentDir, + workspaceDir: "/tmp/workspace", + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + opts: {}, + secretInputMode: "plaintext", + allowSecretRefPrompt: false, + isRemote: false, + openUrl: vi.fn(), + oauth: { createVpsAwareHandlers: vi.fn() }, + } as never); + + expect(prompter.confirm).toHaveBeenCalledWith({ + message: "GitHub Copilot auth already exists. Re-run login?", + initialValue: false, + }); + expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled(); + expect(result).toEqual({ + profiles: [ + { + profileId: "github-copilot:github", + credential: { + type: "token", + provider: "github-copilot", + token: "existing-token", + }, + }, + ], + defaultModel: "github-copilot/claude-opus-4.7", + }); + }); + + it("can refresh an existing token profile during interactive onboarding", async () => { + const provider = registerProviderWithPluginConfig({}); + const method = provider.auth[0]; + const agentDir = await createAgentDir(); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify({ + version: 1, + profiles: { + "github-copilot:github": { + type: "token", + provider: "github-copilot", + token: "existing-token", + }, + }, + }), + ); + 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 prompter = { + confirm: vi.fn(async () => true), + note: vi.fn(), + }; + const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + + try { + const result = await method.run({ + config: {}, + env: {}, + agentDir, + workspaceDir: "/tmp/workspace", + prompter, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + opts: {}, + secretInputMode: "plaintext", + allowSecretRefPrompt: false, + isRemote: false, + openUrl: vi.fn(), + oauth: { createVpsAwareHandlers: vi.fn() }, + } as never); + + expect(mocks.githubCopilotLoginCommand).toHaveBeenCalledWith( + { yes: true, profileId: "github-copilot:github", agentDir }, + expect.any(Object), + ); + expect(result.profiles[0]?.credential).toEqual({ + type: "token", + provider: "github-copilot", + token: "refreshed-token", + }); + } finally { + if (isTtyDescriptor) { + Object.defineProperty(process.stdin, "isTTY", isTtyDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + } + }); + it("stores GitHub Copilot token from non-interactive onboarding", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 44ec17359e2..6ba7a5924a9 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -3,6 +3,7 @@ import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-run import { definePluginEntry, type ProviderAuthContext, + type ProviderAuthResult, type ProviderAuthMethodNonInteractiveContext, } from "openclaw/plugin-sdk/plugin-entry"; import { @@ -86,6 +87,29 @@ function resolveExistingCopilotTokenProfileId(agentDir?: string): string | undef }); } +function resolveExistingCopilotAuthResult(agentDir?: string): ProviderAuthResult | null { + const profileId = resolveExistingCopilotTokenProfileId(agentDir); + if (!profileId) { + return null; + } + const authStore = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + const credential = authStore.profiles[profileId]; + if (!credential || credential.type !== "token") { + return null; + } + return { + profiles: [ + { + profileId, + credential, + }, + ], + defaultModel: DEFAULT_COPILOT_MODEL, + }; +} + async function resolveCopilotNonInteractiveToken( ctx: ProviderAuthMethodNonInteractiveContext, flagValue: string | undefined, @@ -233,6 +257,17 @@ export default definePluginEntry({ async function runGitHubCopilotAuth(ctx: ProviderAuthContext) { const { githubCopilotLoginCommand } = await loadGithubCopilotRuntime(); + let authResult = resolveExistingCopilotAuthResult(ctx.agentDir); + if (authResult) { + const runLogin = await ctx.prompter.confirm({ + message: "GitHub Copilot auth already exists. Re-run login?", + initialValue: false, + }); + if (!runLogin) { + return authResult; + } + } + await ctx.prompter.note( [ "This will open a GitHub device login to authorize Copilot.", @@ -251,7 +286,7 @@ export default definePluginEntry({ try { await githubCopilotLoginCommand( - { yes: true, profileId: "github-copilot:github" }, + { yes: true, profileId: "github-copilot:github", agentDir: ctx.agentDir }, ctx.runtime, ); } catch (err) { @@ -259,23 +294,8 @@ export default definePluginEntry({ return { profiles: [] }; } - const authStore = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - const credential = authStore.profiles["github-copilot:github"]; - if (!credential || credential.type !== "token") { - return { profiles: [] }; - } - - return { - profiles: [ - { - profileId: DEFAULT_COPILOT_PROFILE_ID, - credential, - }, - ], - defaultModel: DEFAULT_COPILOT_MODEL, - }; + authResult = resolveExistingCopilotAuthResult(ctx.agentDir); + return authResult ?? { profiles: [] }; } api.registerMemoryEmbeddingProvider(githubCopilotMemoryEmbeddingProviderAdapter); @@ -301,6 +321,9 @@ export default definePluginEntry({ choiceLabel: "GitHub Copilot", choiceHint: "Device login with your GitHub account", methodId: "device", + modelAllowlist: { + loadCatalog: true, + }, }, }, catalog: { diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index 35a276cad5e..8bf60313ede 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -117,7 +117,7 @@ async function pollForAccessToken(params: { } export async function githubCopilotLoginCommand( - opts: { profileId?: string; yes?: boolean }, + opts: { profileId?: string; yes?: boolean; agentDir?: string }, runtime: RuntimeEnv, ) { if (!process.stdin.isTTY) { @@ -127,7 +127,7 @@ export async function githubCopilotLoginCommand( intro(stylePromptTitle("GitHub Copilot login")); const profileId = opts.profileId?.trim() || "github-copilot:github"; - const store = ensureAuthProfileStore(undefined, { + const store = ensureAuthProfileStore(opts.agentDir, { allowKeychainPrompt: false, }); @@ -169,6 +169,7 @@ export async function githubCopilotLoginCommand( // 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, }); await updateConfig((cfg) => diff --git a/src/plugin-sdk/test-helpers/provider-auth-contract.ts b/src/plugin-sdk/test-helpers/provider-auth-contract.ts index e1c79b8a10f..1ee10620bb6 100644 --- a/src/plugin-sdk/test-helpers/provider-auth-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-auth-contract.ts @@ -308,7 +308,7 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont return requireProvider(await registerProviders(githubCopilotPlugin), "github-copilot"); } - it("keeps device auth results provider-owned", async () => { + it("keeps existing device auth results provider-owned", async () => { const provider = await getProvider(); state.authStore.profiles["github-copilot:github"] = { type: "token", @@ -327,10 +327,7 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont try { const result = await provider.auth[0]?.run(buildAuthContext() as never); - expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith( - { yes: true, profileId: "github-copilot:github" }, - expect.any(Object), - ); + expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); expect(result).toEqual({ profiles: [ {