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
This commit is contained in:
RebelSyntax 2026-01-29 23:10:56 -05:00
parent 151ddd624b
commit c800a686e7
4 changed files with 163 additions and 13 deletions

View File

@ -3,6 +3,7 @@ summary: "Sign in to GitHub Copilot from OpenClaw using the device flow"
read_when: read_when:
- You want to use GitHub Copilot as a model provider - You want to use GitHub Copilot as a model provider
- You need the `openclaw models auth login-github-copilot` flow - You need the `openclaw models auth login-github-copilot` flow
- You already use the GitHub Copilot CLI
--- ---
# Github Copilot # Github Copilot
@ -10,9 +11,9 @@ read_when:
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot 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 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`) ### 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 Copilot API tokens when OpenClaw runs. This is the **default** and simplest path
because it does not require VS Code. 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 Use the **Copilot Proxy** VS Code extension as a local bridge. OpenClaw talks to
the proxys `/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. 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. You must enable the plugin and keep the VS Code extension running.
@ -33,38 +64,38 @@ profile.
## CLI setup ## CLI setup
```bash \`\`\`bash
openclaw models auth login-github-copilot openclaw models auth login-github-copilot
``` \`\`\`
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes. open until it completes.
### Optional flags ### Optional flags
```bash \`\`\`bash
openclaw models auth login-github-copilot --profile-id github-copilot:work openclaw models auth login-github-copilot --profile-id github-copilot:work
openclaw models auth login-github-copilot --yes openclaw models auth login-github-copilot --yes
``` \`\`\`
## Set a default model ## Set a default model
```bash \`\`\`bash
openclaw models set github-copilot/gpt-4o openclaw models set github-copilot/gpt-4o
``` \`\`\`
### Config snippet ### Config snippet
```json5 \`\`\`json5
{ {
agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } } agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } }
} }
``` \`\`\`
## Notes ## Notes
- Requires an interactive TTY; run it directly in a terminal. - Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try - 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 - The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when OpenClaw runs. Copilot API token when OpenClaw runs.

View File

@ -26,6 +26,7 @@ const DEFAULT_REDACT_PATTERNS: string[] = [
// Common token prefixes. // Common token prefixes.
String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`, String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`,
String.raw`\b(ghp_[A-Za-z0-9]{20,})\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(github_pat_[A-Za-z0-9_]{20,})\b`,
String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`, String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`,
String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`, String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`,

View File

@ -78,4 +78,70 @@ describe("github-copilot token", () => {
expect(res.baseUrl).toBe("https://api.contoso.test"); expect(res.baseUrl).toBe("https://api.contoso.test");
expect(saveJsonFile).toHaveBeenCalledTimes(1); 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();
});
}); });

View File

@ -73,6 +73,39 @@ export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
return `https://${host}`; 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: { export async function resolveCopilotApiToken(params: {
githubToken: string; githubToken: string;
env?: NodeJS.ProcessEnv; 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 fetchImpl = params.fetchImpl ?? fetch;
const res = await fetchImpl(COPILOT_TOKEN_URL, { const res = await fetchImpl(COPILOT_TOKEN_URL, {
method: "GET", method: "GET",