From f1afc722daca2becdcfb4bacd5927c96130326f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 07:12:01 +0000 Subject: [PATCH] Revert "fix: improve GitHub Copilot integration" This reverts commit 21a9b3b66f9b01851c36db0b683ad942cd23d668. --- CHANGELOG.md | 1 - docs/providers/github-copilot.md | 14 +-- src/agents/auth-profiles.copilot.test.ts | 70 ------------ src/agents/auth-profiles/oauth.ts | 7 -- src/agents/auth-profiles/types.ts | 1 - ...s-github-copilot-provider-token-is.test.ts | 70 ++++++------ ...fault-baseurl-token-exchange-fails.test.ts | 33 ++++-- src/agents/models-config.providers.ts | 29 +++-- ...-github-copilot-profile-env-tokens.test.ts | 32 ++++-- src/agents/pi-embedded-runner/compact.ts | 7 ++ src/agents/pi-embedded-runner/model.ts | 19 +--- src/agents/pi-embedded-runner/run.ts | 13 ++- .../auth-choice.apply.github-copilot.ts | 2 +- src/providers/github-copilot-auth.ts | 105 ++++-------------- src/providers/github-copilot-token.ts | 3 +- src/providers/github-copilot-utils.ts | 24 ---- 16 files changed, 153 insertions(+), 277 deletions(-) delete mode 100644 src/agents/auth-profiles.copilot.test.ts delete mode 100644 src/providers/github-copilot-utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c8f961fd..52e9126ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,6 @@ Docs: https://docs.clawd.bot - Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status. - macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483) -- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment). ## 2026.1.21-2 diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index 35e1cb394..b35e05011 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -16,9 +16,9 @@ provider in two different ways. ### 1) Built-in GitHub Copilot provider (`github-copilot`) -Use the native device-login flow to obtain a GitHub token and use it directly -against the Copilot API. This is the **default** and simplest path because it -does not require VS Code. Enterprise domains are supported. +Use the native device-login flow to obtain a GitHub token, then exchange it for +Copilot API tokens when Clawdbot runs. This is the **default** and simplest path +because it does not require VS Code. ### 2) Copilot Proxy plugin (`copilot-proxy`) @@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot You'll be prompted to visit a URL and enter a one-time code. Keep the terminal open until it completes. -If you're on GitHub Enterprise, the login will ask for your enterprise URL or -domain (for example `company.ghe.com`). ### Optional flags @@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o - Requires an interactive TTY; run it directly in a terminal. - Copilot model availability depends on your plan; if a model is rejected, try another ID (for example `github-copilot/gpt-4.1`). -- The login stores a GitHub token in the auth profile store and uses it directly - for Copilot API calls. -- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.` - for GitHub Enterprise. +- The login stores a GitHub token in the auth profile store and exchanges it for a + Copilot API token when Clawdbot runs. diff --git a/src/agents/auth-profiles.copilot.test.ts b/src/agents/auth-profiles.copilot.test.ts deleted file mode 100644 index 9bae50d90..000000000 --- a/src/agents/auth-profiles.copilot.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - type AuthProfileStore, - ensureAuthProfileStore, - resolveApiKeyForProfile, -} from "./auth-profiles.js"; - -vi.mock("@mariozechner/pi-ai", () => ({ - getOAuthApiKey: vi.fn(() => { - throw new Error("refresh should not be called"); - }), -})); - -describe("auth-profiles (github-copilot)", () => { - const previousStateDir = process.env.CLAWDBOT_STATE_DIR; - const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - let tempDir: string | null = null; - - afterEach(async () => { - vi.unstubAllGlobals(); - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }); - tempDir = null; - } - if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR; - else process.env.CLAWDBOT_STATE_DIR = previousStateDir; - if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR; - else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; - if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; - else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - }); - - it("treats copilot oauth tokens with expires=0 as non-expiring", async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-")); - process.env.CLAWDBOT_STATE_DIR = tempDir; - process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent"); - process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; - - const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json"); - await fs.mkdir(path.dirname(authProfilePath), { recursive: true }); - - const store: AuthProfileStore = { - version: 1, - profiles: { - "github-copilot:github": { - type: "oauth", - provider: "github-copilot", - refresh: "gh-token", - access: "gh-token", - expires: 0, - enterpriseUrl: "company.ghe.com", - }, - }, - }; - await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`); - - const loaded = ensureAuthProfileStore(); - const resolved = await resolveApiKeyForProfile({ - store: loaded, - profileId: "github-copilot:github", - }); - - expect(resolved?.apiKey).toBe("gh-token"); - }); -}); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index d84f0aedf..8c59a3044 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -103,13 +103,6 @@ async function tryResolveOAuthProfile(params: { if (profileConfig && profileConfig.provider !== cred.provider) return null; if (profileConfig && profileConfig.mode !== cred.type) return null; - if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) { - return { - apiKey: buildOAuthApiKey(cred.provider, cred), - provider: cred.provider, - email: cred.email, - }; - } if (Date.now() < cred.expires) { return { apiKey: buildOAuthApiKey(cred.provider, cred), diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 08fa80eea..32a4a44bd 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -19,7 +19,6 @@ export type TokenCredential = { token: string; /** Optional expiry timestamp (ms since epoch). */ expires?: number; - enterpriseUrl?: string; email?: string; }; diff --git a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts index 3ab92c550..adfb2ebb7 100644 --- a/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts +++ b/src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js"; async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "clawdbot-models-" }); @@ -52,6 +51,16 @@ describe("models-config", () => { try { vi.resetModules(); + vi.doMock("../providers/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", + resolveCopilotApiToken: vi.fn().mockResolvedValue({ + token: "copilot", + expiresAt: Date.now() + 60 * 60 * 1000, + source: "mock", + baseUrl: "https://api.copilot.example", + }), + })); + const { ensureClawdbotModelsJson } = await import("./models-config.js"); const agentDir = path.join(home, "agent-default-base-url"); @@ -62,55 +71,48 @@ describe("models-config", () => { providers: Record; }; - expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL); + expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0); } finally { process.env.COPILOT_GITHUB_TOKEN = previous; } }); }); - it("uses enterprise URL from auth profiles to derive base URL", async () => { + it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => { await withTempHome(async () => { + const previous = process.env.COPILOT_GITHUB_TOKEN; + const previousGh = process.env.GH_TOKEN; + const previousGithub = process.env.GITHUB_TOKEN; + process.env.COPILOT_GITHUB_TOKEN = "copilot-token"; + process.env.GH_TOKEN = "gh-token"; + process.env.GITHUB_TOKEN = "github-token"; + try { vi.resetModules(); - const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise"); - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile( - path.join(agentDir, "auth-profiles.json"), - JSON.stringify( - { - version: 1, - profiles: { - "github-copilot:github": { - type: "oauth", - provider: "github-copilot", - refresh: "gh-token", - access: "gh-token", - expires: 0, - enterpriseUrl: "company.ghe.com", - }, - }, - }, - null, - 2, - ), - ); + const resolveCopilotApiToken = vi.fn().mockResolvedValue({ + token: "copilot", + expiresAt: Date.now() + 60 * 60 * 1000, + source: "mock", + baseUrl: "https://api.copilot.example", + }); + + vi.doMock("../providers/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", + resolveCopilotApiToken, + })); const { ensureClawdbotModelsJson } = await import("./models-config.js"); - await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); + await ensureClawdbotModelsJson({ models: { providers: {} } }); - const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8"); - const parsed = JSON.parse(raw) as { - providers: Record; - }; - - expect(parsed.providers["github-copilot"]?.baseUrl).toBe( - "https://copilot-api.company.ghe.com", + expect(resolveCopilotApiToken).toHaveBeenCalledWith( + expect.objectContaining({ githubToken: "copilot-token" }), ); } finally { - // no-op + process.env.COPILOT_GITHUB_TOKEN = previous; + process.env.GH_TOKEN = previousGh; + process.env.GITHUB_TOKEN = previousGithub; } }); }); diff --git a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts index 387978cd2..13090d170 100644 --- a/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts +++ b/src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js"; async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "clawdbot-models-" }); @@ -44,7 +43,7 @@ describe("models-config", () => { process.env.HOME = previousHome; }); - it("uses default baseUrl when env token is present", async () => { + it("falls back to default baseUrl when token exchange fails", async () => { await withTempHome(async () => { const previous = process.env.COPILOT_GITHUB_TOKEN; process.env.COPILOT_GITHUB_TOKEN = "gh-token"; @@ -52,6 +51,11 @@ describe("models-config", () => { try { vi.resetModules(); + vi.doMock("../providers/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test", + resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")), + })); + const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { resolveClawdbotAgentDir } = await import("./agent-paths.js"); @@ -63,13 +67,13 @@ describe("models-config", () => { providers: Record; }; - expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL); + expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test"); } finally { process.env.COPILOT_GITHUB_TOKEN = previous; } }); }); - it("normalizes enterprise URL when deriving base URL", async () => { + it("uses agentDir override auth profiles for copilot injection", async () => { await withTempHome(async (home) => { const previous = process.env.COPILOT_GITHUB_TOKEN; const previousGh = process.env.GH_TOKEN; @@ -90,12 +94,9 @@ describe("models-config", () => { version: 1, profiles: { "github-copilot:github": { - type: "oauth", + type: "token", provider: "github-copilot", - refresh: "gh-profile-token", - access: "gh-profile-token", - expires: 0, - enterpriseUrl: "https://company.ghe.com/", + token: "gh-profile-token", }, }, }, @@ -104,6 +105,16 @@ describe("models-config", () => { ), ); + vi.doMock("../providers/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", + resolveCopilotApiToken: vi.fn().mockResolvedValue({ + token: "copilot", + expiresAt: Date.now() + 60 * 60 * 1000, + source: "mock", + baseUrl: "https://api.copilot.example", + }), + })); + const { ensureClawdbotModelsJson } = await import("./models-config.js"); await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); @@ -113,9 +124,7 @@ describe("models-config", () => { providers: Record; }; - expect(parsed.providers["github-copilot"]?.baseUrl).toBe( - "https://copilot-api.company.ghe.com", - ); + expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); } finally { if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = previous; diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a8f946267..251f7b92b 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,8 +1,8 @@ import type { ClawdbotConfig } from "../config/config.js"; import { - normalizeGithubCopilotDomain, - resolveGithubCopilotBaseUrl, -} from "../providers/github-copilot-utils.js"; + DEFAULT_COPILOT_API_BASE_URL, + resolveCopilotApiToken, +} from "../providers/github-copilot-token.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { @@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: { if (!hasProfile && !githubToken) return null; - let enterpriseDomain: string | null = null; - if (hasProfile) { + let selectedGithubToken = githubToken; + if (!selectedGithubToken && hasProfile) { // Use the first available profile as a default for discovery (it will be // re-resolved per-run by the embedded runner). const profileId = listProfilesForProvider(authStore, "github-copilot")[0]; const profile = profileId ? authStore.profiles[profileId] : undefined; - if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") { - enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl); + if (profile && profile.type === "token") { + selectedGithubToken = profile.token; } } - const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain); + let baseUrl = DEFAULT_COPILOT_API_BASE_URL; + if (selectedGithubToken) { + try { + const token = await resolveCopilotApiToken({ + githubToken: selectedGithubToken, + env, + }); + baseUrl = token.baseUrl; + } catch { + baseUrl = DEFAULT_COPILOT_API_BASE_URL; + } + } // pi-coding-agent's ModelRegistry marks a model "available" only if its // `AuthStorage` has auth configured for that provider (via auth.json/env/etc). @@ -353,7 +364,7 @@ export async function resolveImplicitCopilotProvider(params: { // GitHub token (not the exchanged Copilot token), and (3) matches existing // patterns for OAuth-like providers in pi-coding-agent. // Note: we deliberately do not write pi-coding-agent's `auth.json` here. - // Clawdbot uses its own auth store and passes the GitHub token at runtime. + // Clawdbot uses its own auth store and exchanges tokens at runtime. // `models list` uses Clawdbot's auth heuristics for availability. // We intentionally do NOT define custom models for Copilot in models.json. diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index 7935d3fa7..e030e7d52 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js"; async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "clawdbot-models-" }); @@ -81,16 +80,25 @@ describe("models-config", () => { ), ); + const resolveCopilotApiToken = vi.fn().mockResolvedValue({ + token: "copilot", + expiresAt: Date.now() + 60 * 60 * 1000, + source: "mock", + baseUrl: "https://api.copilot.example", + }); + + vi.doMock("../providers/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", + resolveCopilotApiToken, + })); + const { ensureClawdbotModelsJson } = await import("./models-config.js"); await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); - const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8"); - const parsed = JSON.parse(raw) as { - providers: Record; - }; - - expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL); + expect(resolveCopilotApiToken).toHaveBeenCalledWith( + expect.objectContaining({ githubToken: "alpha-token" }), + ); } finally { if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = previous; @@ -109,6 +117,16 @@ describe("models-config", () => { try { vi.resetModules(); + vi.doMock("../providers/github-copilot-token.js", () => ({ + DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com", + resolveCopilotApiToken: vi.fn().mockResolvedValue({ + token: "copilot", + expiresAt: Date.now() + 60 * 60 * 1000, + source: "mock", + baseUrl: "https://api.copilot.example", + }), + })); + const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { resolveClawdbotAgentDir } = await import("./agent-paths.js"); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index f2aa5169f..9c0f420b6 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: { `No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`, ); } + } else if (model.provider === "github-copilot") { + const { resolveCopilotApiToken } = + await import("../../providers/github-copilot-token.js"); + const copilotToken = await resolveCopilotApiToken({ + githubToken: apiKeyInfo.apiKey, + }); + authStorage.setRuntimeApiKey(model.provider, copilotToken.token); } else { authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); } diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 05f5072cf..15248aeaa 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -7,18 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { normalizeModelCompat } from "../model-compat.js"; import { normalizeProviderId } from "../model-selection.js"; -import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js"; type InlineModelEntry = ModelDefinitionConfig & { provider: string }; -function applyProviderModelOverrides(model: Model): Model { - if (model.provider === "github-copilot") { - const headers = { ...(model.headers ?? {}), "User-Agent": resolveGithubCopilotUserAgent() }; - return { ...model, headers }; - } - return model; -} - export function buildInlineProviderModels( providers: Record, ): InlineModelEntry[] { @@ -69,7 +60,7 @@ export function resolveModel( if (inlineMatch) { const normalized = normalizeModelCompat(inlineMatch as Model); return { - model: applyProviderModelOverrides(normalized), + model: normalized, authStorage, modelRegistry, }; @@ -87,7 +78,7 @@ export function resolveModel( contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, } as Model); - return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry }; + return { model: fallbackModel, authStorage, modelRegistry }; } return { error: `Unknown model: ${provider}/${modelId}`, @@ -95,9 +86,5 @@ export function resolveModel( modelRegistry, }; } - return { - model: applyProviderModelOverrides(normalizeModelCompat(model)), - authStorage, - modelRegistry, - }; + return { model: normalizeModelCompat(model), authStorage, modelRegistry }; } diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 5fab767e5..0e3388b84 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -184,8 +184,17 @@ export async function runEmbeddedPiAgent( lastProfileId = resolvedProfileId; return; } - authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); - lastProfileId = resolvedProfileId; + if (model.provider === "github-copilot") { + const { resolveCopilotApiToken } = + await import("../../providers/github-copilot-token.js"); + const copilotToken = await resolveCopilotApiToken({ + githubToken: apiKeyInfo.apiKey, + }); + authStorage.setRuntimeApiKey(model.provider, copilotToken.token); + } else { + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + } + lastProfileId = apiKeyInfo.profileId; }; const advanceAuthProfile = async (): Promise => { diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts index 661397488..30a1591b2 100644 --- a/src/commands/auth-choice.apply.github-copilot.ts +++ b/src/commands/auth-choice.apply.github-copilot.ts @@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot( nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "github-copilot:github", provider: "github-copilot", - mode: "oauth", + mode: "token", }); if (params.setDefaultModel) { diff --git a/src/providers/github-copilot-auth.ts b/src/providers/github-copilot-auth.ts index 0a37a6a86..75d7ad472 100644 --- a/src/providers/github-copilot-auth.ts +++ b/src/providers/github-copilot-auth.ts @@ -1,4 +1,4 @@ -import { intro, note, outro, select, spinner, text, isCancel } from "@clack/prompts"; +import { intro, note, outro, spinner } from "@clack/prompts"; import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; import { updateConfig } from "../commands/models/shared.js"; @@ -6,22 +6,10 @@ import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; import { logConfigUpdated } from "../config/logging.js"; import type { RuntimeEnv } from "../runtime.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; -import { - normalizeGithubCopilotDomain, - resolveGithubCopilotBaseUrl, - resolveGithubCopilotUserAgent, -} from "./github-copilot-utils.js"; -const CLIENT_ID = "Ov23li8tweQw6odWQebz"; -const DEFAULT_DOMAIN = "github.com"; -const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000; - -function getUrls(domain: string) { - return { - deviceCodeUrl: `https://${domain}/login/device/code`, - accessTokenUrl: `https://${domain}/login/oauth/access_token`, - }; -} +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"; type DeviceCodeResponse = { device_code: string; @@ -50,21 +38,17 @@ function parseJsonResponse(value: unknown): T { return value as T; } -async function requestDeviceCode(params: { - scope: string; - domain: string; -}): Promise { - const body = JSON.stringify({ +async function requestDeviceCode(params: { scope: string }): Promise { + const body = new URLSearchParams({ client_id: CLIENT_ID, scope: params.scope, }); - const res = await fetch(getUrls(params.domain).deviceCodeUrl, { + const res = await fetch(DEVICE_CODE_URL, { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": resolveGithubCopilotUserAgent(), + "Content-Type": "application/x-www-form-urlencoded", }, body, }); @@ -81,27 +65,24 @@ async function requestDeviceCode(params: { } async function pollForAccessToken(params: { - domain: string; deviceCode: string; intervalMs: number; expiresAt: number; }): Promise { - const bodyBase = { + const bodyBase = new URLSearchParams({ client_id: CLIENT_ID, device_code: params.deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }; - const urls = getUrls(params.domain); + }); while (Date.now() < params.expiresAt) { - const res = await fetch(urls.accessTokenUrl, { + const res = await fetch(ACCESS_TOKEN_URL, { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": resolveGithubCopilotUserAgent(), + "Content-Type": "application/x-www-form-urlencoded", }, - body: JSON.stringify(bodyBase), + body: bodyBase, }); if (!res.ok) { @@ -115,14 +96,11 @@ async function pollForAccessToken(params: { const err = "error" in json ? json.error : "unknown"; if (err === "authorization_pending") { - await new Promise((r) => setTimeout(r, params.intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS)); + await new Promise((r) => setTimeout(r, params.intervalMs)); continue; } if (err === "slow_down") { - const serverInterval = - "interval" in json && typeof json.interval === "number" ? json.interval : undefined; - const nextInterval = serverInterval ? serverInterval * 1000 : params.intervalMs + 5000; - await new Promise((r) => setTimeout(r, nextInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)); + await new Promise((r) => setTimeout(r, params.intervalMs + 2000)); continue; } if (err === "expired_token") { @@ -159,42 +137,9 @@ export async function githubCopilotLoginCommand( ); } - const deployment = await select({ - message: "Select GitHub deployment type", - options: [ - { label: "GitHub.com", value: DEFAULT_DOMAIN, hint: "Public" }, - { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" }, - ], - }); - if (isCancel(deployment)) { - throw new Error("GitHub login cancelled"); - } - - let domain = DEFAULT_DOMAIN; - let enterpriseDomain: string | null = null; - if (deployment === "enterprise") { - const enterpriseInput = await text({ - message: "Enter your GitHub Enterprise URL or domain", - placeholder: "company.ghe.com or https://company.ghe.com", - validate: (value) => { - if (!value) return "URL or domain is required"; - return normalizeGithubCopilotDomain(value) ? undefined : "Enter a valid URL or domain"; - }, - }); - if (isCancel(enterpriseInput)) { - throw new Error("GitHub login cancelled"); - } - const normalized = normalizeGithubCopilotDomain(enterpriseInput); - if (!normalized) { - throw new Error("Invalid GitHub Enterprise URL/domain"); - } - enterpriseDomain = normalized; - domain = normalized; - } - const spin = spinner(); spin.start("Requesting device code from GitHub..."); - const device = await requestDeviceCode({ scope: "read:user", domain }); + const device = await requestDeviceCode({ scope: "read:user" }); spin.stop("Device code ready"); note( @@ -208,7 +153,6 @@ export async function githubCopilotLoginCommand( const polling = spinner(); polling.start("Waiting for GitHub authorization..."); const accessToken = await pollForAccessToken({ - domain, deviceCode: device.device_code, intervalMs, expiresAt, @@ -218,13 +162,11 @@ export async function githubCopilotLoginCommand( upsertAuthProfile({ profileId, credential: { - type: "oauth", + type: "token", provider: "github-copilot", - refresh: accessToken, - access: accessToken, - // Copilot access tokens are treated as non-expiring (see resolveApiKeyForProfile). - expires: 0, - enterpriseUrl: enterpriseDomain ?? undefined, + token: accessToken, + // GitHub device flow token doesn't reliably include expiry here. + // Leave expires unset; we'll exchange into Copilot token plus expiry later. }, }); @@ -232,13 +174,12 @@ export async function githubCopilotLoginCommand( applyAuthProfileConfig(cfg, { provider: "github-copilot", profileId, - mode: "oauth", + mode: "token", }), ); - logConfigUpdated(runtime); - runtime.log(`Auth profile: ${profileId} (github-copilot/oauth)`); - runtime.log(`Base URL: ${resolveGithubCopilotBaseUrl(enterpriseDomain ?? undefined)}`); + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); outro("Done"); } diff --git a/src/providers/github-copilot-token.ts b/src/providers/github-copilot-token.ts index a0752c290..19efd4a9d 100644 --- a/src/providers/github-copilot-token.ts +++ b/src/providers/github-copilot-token.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; -import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "./github-copilot-utils.js"; const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; @@ -54,7 +53,7 @@ function parseCopilotTokenResponse(value: unknown): { return { token, expiresAt: expiresAtMs }; } -export const DEFAULT_COPILOT_API_BASE_URL = DEFAULT_GITHUB_COPILOT_BASE_URL; +export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { const trimmed = token.trim(); diff --git a/src/providers/github-copilot-utils.ts b/src/providers/github-copilot-utils.ts deleted file mode 100644 index 7494664da..000000000 --- a/src/providers/github-copilot-utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const DEFAULT_GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com"; - -export function resolveGithubCopilotUserAgent(): string { - const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown"; - return `clawdbot/${version}`; -} - -export function normalizeGithubCopilotDomain(input: string | null | undefined): string | null { - const trimmed = (input ?? "").trim(); - if (!trimmed) return null; - try { - const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`); - return url.hostname; - } catch { - return null; - } -} - -export function resolveGithubCopilotBaseUrl(enterpriseDomain?: string | null): string { - if (enterpriseDomain && enterpriseDomain.trim()) { - return `https://copilot-api.${enterpriseDomain.trim()}`; - } - return DEFAULT_GITHUB_COPILOT_BASE_URL; -}