diff --git a/CHANGELOG.md b/CHANGELOG.md index 5975c6ea9..b22ffb3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.clawd.bot >>>>>>> Stashed changes - Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status. +- 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 b35e05011..35e1cb394 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, then exchange it for -Copilot API tokens when Clawdbot runs. This is the **default** and simplest path -because it does not require VS Code. +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. ### 2) Copilot Proxy plugin (`copilot-proxy`) @@ -39,6 +39,8 @@ 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 @@ -66,5 +68,7 @@ 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 exchanges it for a - Copilot API token when Clawdbot runs. +- 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. diff --git a/src/agents/auth-profiles.copilot.test.ts b/src/agents/auth-profiles.copilot.test.ts new file mode 100644 index 000000000..9bae50d90 --- /dev/null +++ b/src/agents/auth-profiles.copilot.test.ts @@ -0,0 +1,70 @@ +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 8c59a3044..d84f0aedf 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -103,6 +103,13 @@ 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 32a4a44bd..08fa80eea 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -19,6 +19,7 @@ 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 adfb2ebb7..3ab92c550 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,6 +3,7 @@ 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-" }); @@ -51,16 +52,6 @@ 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"); @@ -71,48 +62,55 @@ describe("models-config", () => { providers: Record; }; - expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); + expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL); expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0); } finally { process.env.COPILOT_GITHUB_TOKEN = previous; } }); }); - it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => { + it("uses enterprise URL from auth profiles to derive base URL", 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 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 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 { ensureClawdbotModelsJson } = await import("./models-config.js"); - await ensureClawdbotModelsJson({ models: { providers: {} } }); + await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); - expect(resolveCopilotApiToken).toHaveBeenCalledWith( - expect.objectContaining({ githubToken: "copilot-token" }), + 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", ); } finally { - process.env.COPILOT_GITHUB_TOKEN = previous; - process.env.GH_TOKEN = previousGh; - process.env.GITHUB_TOKEN = previousGithub; + // no-op } }); }); 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 13090d170..387978cd2 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,6 +3,7 @@ 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-" }); @@ -43,7 +44,7 @@ describe("models-config", () => { process.env.HOME = previousHome; }); - it("falls back to default baseUrl when token exchange fails", async () => { + it("uses default baseUrl when env token is present", async () => { await withTempHome(async () => { const previous = process.env.COPILOT_GITHUB_TOKEN; process.env.COPILOT_GITHUB_TOKEN = "gh-token"; @@ -51,11 +52,6 @@ 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"); @@ -67,13 +63,13 @@ describe("models-config", () => { providers: Record; }; - expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test"); + expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL); } finally { process.env.COPILOT_GITHUB_TOKEN = previous; } }); }); - it("uses agentDir override auth profiles for copilot injection", async () => { + it("normalizes enterprise URL when deriving base URL", async () => { await withTempHome(async (home) => { const previous = process.env.COPILOT_GITHUB_TOKEN; const previousGh = process.env.GH_TOKEN; @@ -94,9 +90,12 @@ describe("models-config", () => { version: 1, profiles: { "github-copilot:github": { - type: "token", + type: "oauth", provider: "github-copilot", - token: "gh-profile-token", + refresh: "gh-profile-token", + access: "gh-profile-token", + expires: 0, + enterpriseUrl: "https://company.ghe.com/", }, }, }, @@ -105,16 +104,6 @@ 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); @@ -124,7 +113,9 @@ describe("models-config", () => { providers: Record; }; - expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example"); + expect(parsed.providers["github-copilot"]?.baseUrl).toBe( + "https://copilot-api.company.ghe.com", + ); } 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 251f7b92b..a8f946267 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 { - DEFAULT_COPILOT_API_BASE_URL, - resolveCopilotApiToken, -} from "../providers/github-copilot-token.js"; + normalizeGithubCopilotDomain, + resolveGithubCopilotBaseUrl, +} from "../providers/github-copilot-utils.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { @@ -331,29 +331,18 @@ export async function resolveImplicitCopilotProvider(params: { if (!hasProfile && !githubToken) return null; - let selectedGithubToken = githubToken; - if (!selectedGithubToken && hasProfile) { + let enterpriseDomain: string | null = null; + if (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 && profile.type === "token") { - selectedGithubToken = profile.token; + if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") { + enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl); } } - 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; - } - } + const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain); // pi-coding-agent's ModelRegistry marks a model "available" only if its // `AuthStorage` has auth configured for that provider (via auth.json/env/etc). @@ -364,7 +353,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 exchanges tokens at runtime. + // Clawdbot uses its own auth store and passes the GitHub token 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 e030e7d52..7935d3fa7 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,6 +3,7 @@ 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-" }); @@ -80,25 +81,16 @@ 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); - expect(resolveCopilotApiToken).toHaveBeenCalledWith( - expect.objectContaining({ githubToken: "alpha-token" }), - ); + 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); } finally { if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; else process.env.COPILOT_GITHUB_TOKEN = previous; @@ -117,16 +109,6 @@ 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 9c0f420b6..f2aa5169f 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -128,13 +128,6 @@ 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 15248aeaa..05f5072cf 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -7,9 +7,18 @@ 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[] { @@ -60,7 +69,7 @@ export function resolveModel( if (inlineMatch) { const normalized = normalizeModelCompat(inlineMatch as Model); return { - model: normalized, + model: applyProviderModelOverrides(normalized), authStorage, modelRegistry, }; @@ -78,7 +87,7 @@ export function resolveModel( contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, } as Model); - return { model: fallbackModel, authStorage, modelRegistry }; + return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry }; } return { error: `Unknown model: ${provider}/${modelId}`, @@ -86,5 +95,9 @@ export function resolveModel( modelRegistry, }; } - return { model: normalizeModelCompat(model), authStorage, modelRegistry }; + return { + model: applyProviderModelOverrides(normalizeModelCompat(model)), + authStorage, + modelRegistry, + }; } diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 174178b09..12bfa218a 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -178,16 +178,7 @@ export async function runEmbeddedPiAgent( lastProfileId = apiKeyInfo.profileId; return; } - 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); - } + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); lastProfileId = apiKeyInfo.profileId; }; diff --git a/src/commands/auth-choice.apply.github-copilot.ts b/src/commands/auth-choice.apply.github-copilot.ts index 30a1591b2..661397488 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: "token", + mode: "oauth", }); if (params.setDefaultModel) { diff --git a/src/providers/github-copilot-auth.ts b/src/providers/github-copilot-auth.ts index 1d37fcc4b..d961e468f 100644 --- a/src/providers/github-copilot-auth.ts +++ b/src/providers/github-copilot-auth.ts @@ -1,4 +1,4 @@ -import { intro, note, outro, spinner } from "@clack/prompts"; +import { intro, note, outro, select, spinner, text, isCancel } from "@clack/prompts"; import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; import { updateConfig } from "../commands/models/shared.js"; @@ -6,10 +6,22 @@ import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; import { CONFIG_PATH_CLAWDBOT } from "../config/config.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 = "Iv1.b507a08c87ecfe98"; -const DEVICE_CODE_URL = "https://github.com/login/device/code"; -const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; +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`, + }; +} type DeviceCodeResponse = { device_code: string; @@ -38,17 +50,21 @@ function parseJsonResponse(value: unknown): T { return value as T; } -async function requestDeviceCode(params: { scope: string }): Promise { - const body = new URLSearchParams({ +async function requestDeviceCode(params: { + scope: string; + domain: string; +}): Promise { + const body = JSON.stringify({ client_id: CLIENT_ID, scope: params.scope, }); - const res = await fetch(DEVICE_CODE_URL, { + const res = await fetch(getUrls(params.domain).deviceCodeUrl, { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/json", + "User-Agent": resolveGithubCopilotUserAgent(), }, body, }); @@ -65,24 +81,27 @@ async function requestDeviceCode(params: { scope: string }): Promise { - const bodyBase = new URLSearchParams({ + const bodyBase = { 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(ACCESS_TOKEN_URL, { + const res = await fetch(urls.accessTokenUrl, { method: "POST", headers: { Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/json", + "User-Agent": resolveGithubCopilotUserAgent(), }, - body: bodyBase, + body: JSON.stringify(bodyBase), }); if (!res.ok) { @@ -96,11 +115,14 @@ async function pollForAccessToken(params: { const err = "error" in json ? json.error : "unknown"; if (err === "authorization_pending") { - await new Promise((r) => setTimeout(r, params.intervalMs)); + await new Promise((r) => setTimeout(r, params.intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS)); continue; } if (err === "slow_down") { - await new Promise((r) => setTimeout(r, params.intervalMs + 2000)); + 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)); continue; } if (err === "expired_token") { @@ -137,9 +159,42 @@ 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" }); + const device = await requestDeviceCode({ scope: "read:user", domain }); spin.stop("Device code ready"); note( @@ -153,6 +208,7 @@ export async function githubCopilotLoginCommand( const polling = spinner(); polling.start("Waiting for GitHub authorization..."); const accessToken = await pollForAccessToken({ + domain, deviceCode: device.device_code, intervalMs, expiresAt, @@ -162,11 +218,13 @@ export async function githubCopilotLoginCommand( upsertAuthProfile({ profileId, credential: { - type: "token", + type: "oauth", 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. + refresh: accessToken, + access: accessToken, + // Copilot access tokens are treated as non-expiring (see resolveApiKeyForProfile). + expires: 0, + enterpriseUrl: enterpriseDomain ?? undefined, }, }); @@ -174,12 +232,13 @@ export async function githubCopilotLoginCommand( applyAuthProfileConfig(cfg, { provider: "github-copilot", profileId, - mode: "token", + mode: "oauth", }), ); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); + runtime.log(`Auth profile: ${profileId} (github-copilot/oauth)`); + runtime.log(`Base URL: ${resolveGithubCopilotBaseUrl(enterpriseDomain ?? undefined)}`); outro("Done"); } diff --git a/src/providers/github-copilot-token.ts b/src/providers/github-copilot-token.ts index 19efd4a9d..a0752c290 100644 --- a/src/providers/github-copilot-token.ts +++ b/src/providers/github-copilot-token.ts @@ -2,6 +2,7 @@ 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"; @@ -53,7 +54,7 @@ function parseCopilotTokenResponse(value: unknown): { return { token, expiresAt: expiresAtMs }; } -export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; +export const DEFAULT_COPILOT_API_BASE_URL = DEFAULT_GITHUB_COPILOT_BASE_URL; 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 new file mode 100644 index 000000000..7494664da --- /dev/null +++ b/src/providers/github-copilot-utils.ts @@ -0,0 +1,24 @@ +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; +}