From c800a686e772199138bf84cd7ec5df438285dde7 Mon Sep 17 00:00:00 2001 From: RebelSyntax Date: Thu, 29 Jan 2026 23:10:56 -0500 Subject: [PATCH] fix(github-copilot): use gho_ tokens directly without exchange Tokens from the GitHub Copilot CLI (prefixed with `gho_`) can be used directly with the Copilot API without requiring a token exchange via /copilot_internal/v2/token (which returns HTTP 404 for these tokens). This fixes authentication for users who: - Installed the Copilot CLI (`npm install -g @github/copilot`) - Authenticated via `copilot auth login` - Set COPILOT_GITHUB_TOKEN or GH_TOKEN to their gho_ token The fix detects gho_ prefixed tokens and skips the exchange, caching them with an 8-hour TTL (matching typical OAuth token lifetimes). Also adds COPILOT_API_BASE_URL env var support for enterprise users whose proxy blocks api.individual.githubcopilot.com. Set this to your enterprise endpoint (e.g., https://api.business.githubcopilot.com). [AI-assisted] Tested with GitHub Copilot CLI 0.0.399 on WSL2. Closes #3437 --- docs/providers/github-copilot.md | 57 ++++++++++++++----- src/logging/redact.ts | 1 + src/providers/github-copilot-token.test.ts | 66 ++++++++++++++++++++++ src/providers/github-copilot-token.ts | 52 +++++++++++++++++ 4 files changed, 163 insertions(+), 13 deletions(-) diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index d57603b5a..208e14fd7 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -3,6 +3,7 @@ summary: "Sign in to GitHub Copilot from OpenClaw using the device flow" read_when: - You want to use GitHub Copilot as a model provider - You need the `openclaw models auth login-github-copilot` flow + - You already use the GitHub Copilot CLI --- # Github Copilot @@ -10,9 +11,9 @@ read_when: GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot models for your GitHub account and plan. OpenClaw can use Copilot as a model -provider in two different ways. +provider in three different ways. -## Two ways to use Copilot in OpenClaw +## Three ways to use Copilot in OpenClaw ### 1) Built-in GitHub Copilot provider (`github-copilot`) @@ -20,10 +21,40 @@ Use the native device-login flow to obtain a GitHub token, then exchange it for Copilot API tokens when OpenClaw runs. This is the **default** and simplest path because it does not require VS Code. -### 2) Copilot Proxy plugin (`copilot-proxy`) +### 2) Copilot CLI token (`gho_` tokens) + +If you already use the [GitHub Copilot CLI](https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line), +you can use its OAuth token directly. These tokens (prefixed with `gho_`) work +with the Copilot API without any additional exchange. + +\`\`\`bash +# Install and authenticate the Copilot CLI +npm install -g @github/copilot +copilot auth login + +# Get your token from ~/.copilot/config.json and set it. +# The file structure looks like: +# { +# "github.com": { +# "token": "gho_your_token_here", +# "user": "your_username" +# } +# } +# Use the value of github.com.token: +export COPILOT_GITHUB_TOKEN="gho_your_token_here" +\`\`\` + +For **enterprise users** whose proxy blocks the individual endpoint, set the +base URL to your enterprise endpoint: + +\`\`\`bash +export COPILOT_API_BASE_URL="https://api.business.githubcopilot.com" +\`\`\` + +### 3) Copilot Proxy plugin (`copilot-proxy`) Use the **Copilot Proxy** VS Code extension as a local bridge. OpenClaw talks to -the proxy’s `/v1` endpoint and uses the model list you configure there. Choose +the proxy's `/v1` endpoint and uses the model list you configure there. Choose this when you already run Copilot Proxy in VS Code or need to route through it. You must enable the plugin and keep the VS Code extension running. @@ -33,38 +64,38 @@ profile. ## CLI setup -```bash +\`\`\`bash openclaw 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. ### Optional flags -```bash +\`\`\`bash openclaw models auth login-github-copilot --profile-id github-copilot:work openclaw models auth login-github-copilot --yes -``` +\`\`\` ## Set a default model -```bash +\`\`\`bash openclaw models set github-copilot/gpt-4o -``` +\`\`\` ### Config snippet -```json5 +\`\`\`json5 { agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } } } -``` +\`\`\` ## Notes - 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`). + 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 OpenClaw runs. diff --git a/src/logging/redact.ts b/src/logging/redact.ts index c3926d868..1d451750f 100644 --- a/src/logging/redact.ts +++ b/src/logging/redact.ts @@ -26,6 +26,7 @@ const DEFAULT_REDACT_PATTERNS: string[] = [ // Common token prefixes. String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`, String.raw`\b(ghp_[A-Za-z0-9]{20,})\b`, + String.raw`\b(gho_[A-Za-z0-9]{20,})\b`, String.raw`\b(github_pat_[A-Za-z0-9_]{20,})\b`, String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`, String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`, diff --git a/src/providers/github-copilot-token.test.ts b/src/providers/github-copilot-token.test.ts index 04c32c1b6..9cb387cca 100644 --- a/src/providers/github-copilot-token.test.ts +++ b/src/providers/github-copilot-token.test.ts @@ -78,4 +78,70 @@ describe("github-copilot token", () => { expect(res.baseUrl).toBe("https://api.contoso.test"); expect(saveJsonFile).toHaveBeenCalledTimes(1); }); + + it("uses gho_ tokens directly without exchange", async () => { + loadJsonFile.mockReturnValue(undefined); + + const fetchImpl = vi.fn(); + + const { resolveCopilotApiToken } = await import("./github-copilot-token.js"); + + const res = await resolveCopilotApiToken({ + githubToken: "gho_testtoken123456789", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + // gho_ tokens should be used directly + expect(res.token).toBe("gho_testtoken123456789"); + expect(res.source).toBe("copilot-cli:direct"); + expect(res.baseUrl).toBe("https://api.individual.githubcopilot.com"); + // Should NOT call the exchange endpoint + expect(fetchImpl).not.toHaveBeenCalled(); + // Should cache the token + expect(saveJsonFile).toHaveBeenCalledTimes(1); + }); + + it("exchanges non-gho tokens via API", async () => { + loadJsonFile.mockReturnValue(undefined); + + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + token: "exchanged;proxy-ep=https://proxy.github.test;", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + }); + + const { resolveCopilotApiToken } = await import("./github-copilot-token.js"); + + const res = await resolveCopilotApiToken({ + githubToken: "ghp_regularoauthtoken", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + // Non-gho tokens should go through the exchange + expect(res.token).toBe("exchanged;proxy-ep=https://proxy.github.test;"); + expect(String(res.source)).toContain("fetched:"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("uses COPILOT_API_BASE_URL env var for gho_ tokens", async () => { + loadJsonFile.mockReturnValue(undefined); + + const fetchImpl = vi.fn(); + + const { resolveCopilotApiToken } = await import("./github-copilot-token.js"); + + const res = await resolveCopilotApiToken({ + githubToken: "gho_enterprisetoken", + env: { COPILOT_API_BASE_URL: "https://api.business.githubcopilot.com" }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + // Should use the env var for baseUrl + expect(res.token).toBe("gho_enterprisetoken"); + expect(res.baseUrl).toBe("https://api.business.githubcopilot.com"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); }); diff --git a/src/providers/github-copilot-token.ts b/src/providers/github-copilot-token.ts index 19efd4a9d..58c71dd5f 100644 --- a/src/providers/github-copilot-token.ts +++ b/src/providers/github-copilot-token.ts @@ -73,6 +73,39 @@ export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { return `https://${host}`; } +/** + * Check if a token is a GitHub OAuth token from the Copilot CLI. + * These tokens (prefixed with `gho_`) can be used directly with the Copilot API + * without requiring a token exchange via /copilot_internal/v2/token. + */ +function isCopilotCliToken(token: string): boolean { + return token.startsWith("gho_"); +} + +/** + * Default cache TTL for Copilot CLI OAuth tokens (prefixed with `gho_`). + * + * As of this writing, the Copilot CLI issues `gho_` tokens with an 8-hour lifetime, + * so we cache them for the same duration. GitHub OAuth token lifetimes can be + * configured and may change over time; this value is therefore a best-effort + * approximation for cache expiry rather than a guaranteed reflection of the + * server-side token lifetime. + */ +const COPILOT_CLI_TOKEN_TTL_MS = 8 * 60 * 60 * 1000; + +/** + * Resolve the Copilot API base URL. + * Checks COPILOT_API_BASE_URL env var first (for enterprise/custom endpoints), + * then falls back to the default individual endpoint. + */ +function resolveCopilotApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string { + const envUrl = env.COPILOT_API_BASE_URL?.trim(); + if (envUrl) { + return envUrl; + } + return DEFAULT_COPILOT_API_BASE_URL; +} + export async function resolveCopilotApiToken(params: { githubToken: string; env?: NodeJS.ProcessEnv; @@ -97,6 +130,25 @@ export async function resolveCopilotApiToken(params: { } } + // Copilot CLI tokens (gho_*) can be used directly with the Copilot API. + // These tokens are already authenticated and don't need the /v2/token exchange. + // See: https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line + if (isCopilotCliToken(params.githubToken)) { + const now = Date.now(); + const payload: CachedCopilotToken = { + token: params.githubToken, + expiresAt: now + COPILOT_CLI_TOKEN_TTL_MS, + updatedAt: now, + }; + saveJsonFile(cachePath, payload); + return { + token: payload.token, + expiresAt: payload.expiresAt, + source: "copilot-cli:direct", + baseUrl: resolveCopilotApiBaseUrl(env), + }; + } + const fetchImpl = params.fetchImpl ?? fetch; const res = await fetchImpl(COPILOT_TOKEN_URL, { method: "GET",