From c20450a82b82d32ef56a89de95589913deca679b Mon Sep 17 00:00:00 2001 From: Andy Ye Date: Sat, 9 May 2026 20:22:21 -0700 Subject: [PATCH] fix(github-copilot): mint tokens with vscode chat identity --- CHANGELOG.md | 1 + extensions/github-copilot/models.test.ts | 1 + src/agents/copilot-dynamic-headers.ts | 6 ++-- src/agents/github-copilot-token.test.ts | 37 ++++++++++++++++++++++-- src/plugin-sdk/provider-auth.ts | 6 +++- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d90c8750da..bb07accf483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - OpenAI/Codex: point gateway missing-key recovery and wizard docs at the canonical `openai/gpt-5.5` plus Codex OAuth route, and fix trajectory export errors so they suggest the valid `openclaw sessions` command. - Google/Gemini: normalize retired `google/gemini-3-pro-preview` primary, fallback, and model-map refs during config load and unrelated config writes so saved config keeps targeting Gemini 3.1 Pro Preview. - Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside emitted Google provider model config, so regenerated models.json rows test `google/gemini-3.1-pro-preview`. +- GitHub Copilot: mint short-lived Copilot API tokens with the same `vscode-chat` integration identity used by runtime requests, and refresh legacy cached tokens missing that identity so image-capable Copilot models no longer inherit the `copilot-language-server` scope. Fixes #79946. - Plugins/doctor: drop stale managed npm install records when `openclaw doctor --fix` removes npm packages that shadow bundled plugins, so the rebuilt registry no longer resurrects the removed package metadata. - Discord/voice: reuse or suppress late realtime consult tool calls without stealing newer speaker context or speaking forced fallback answers twice. - Discord/voice: synthesize realtime playback timestamps from emitted Discord PCM so OpenAI realtime barge-in truncation no longer sees `audioEndMs=0` and skips legitimate interruptions. diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 248ddee488a..be65ecd3b45 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -359,6 +359,7 @@ describe("github-copilot token", () => { token: "cached;proxy-ep=proxy.example.com;", expiresAt: now + 60 * 60 * 1000, updatedAt: now, + integrationId: "vscode-chat", }); const fetchImpl = vi.fn(); diff --git a/src/agents/copilot-dynamic-headers.ts b/src/agents/copilot-dynamic-headers.ts index 8a25404104c..99f5b2caf1a 100644 --- a/src/agents/copilot-dynamic-headers.ts +++ b/src/agents/copilot-dynamic-headers.ts @@ -1,7 +1,7 @@ import type { Context } from "@mariozechner/pi-ai"; -import { buildCopilotIdeHeaders } from "../plugin-sdk/provider-auth.js"; +import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "../plugin-sdk/provider-auth.js"; -export { buildCopilotIdeHeaders } from "../plugin-sdk/provider-auth.js"; +export { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "../plugin-sdk/provider-auth.js"; function inferCopilotInitiator(messages: Context["messages"]): "agent" | "user" { const last = messages[messages.length - 1]; @@ -43,7 +43,7 @@ export function buildCopilotDynamicHeaders(params: { }): Record { return { ...buildCopilotIdeHeaders(), - "Copilot-Integration-Id": "vscode-chat", + "Copilot-Integration-Id": COPILOT_INTEGRATION_ID, "Openai-Organization": "github-copilot", "x-initiator": inferCopilotInitiator(params.messages), ...(params.hasImages ? { "Copilot-Vision-Request": "true" } : {}), diff --git a/src/agents/github-copilot-token.test.ts b/src/agents/github-copilot-token.test.ts index 36ec07790ff..7d50685f98b 100644 --- a/src/agents/github-copilot-token.test.ts +++ b/src/agents/github-copilot-token.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { buildCopilotIdeHeaders } from "./copilot-dynamic-headers.js"; +import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "./copilot-dynamic-headers.js"; import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken, @@ -47,7 +47,7 @@ describe("resolveCopilotApiToken", () => { expect(result.expiresAt).toBe(12_345_678_901_000); }); - it("sends IDE headers when exchanging the GitHub token", async () => { + it("sends IDE and integration headers when exchanging the GitHub token", async () => { const fetchImpl = vi.fn(async () => ({ ok: true, json: async () => ({ @@ -71,7 +71,40 @@ describe("resolveCopilotApiToken", () => { expect(init.headers).toEqual({ Accept: "application/json", Authorization: "Bearer github-token", + "Copilot-Integration-Id": COPILOT_INTEGRATION_ID, ...buildCopilotIdeHeaders({ includeApiVersion: true }), }); }); + + it("refreshes legacy cached tokens without the vscode-chat integration identity", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + json: async () => ({ + token: "fresh-copilot-token", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + })); + const saveJsonFileImpl = vi.fn(); + + const result = await resolveCopilotApiToken({ + githubToken: "github-token", + cachePath: "/tmp/github-copilot-token-test.json", + loadJsonFileImpl: () => ({ + token: "legacy-copilot-token", + expiresAt: Date.now() + 60 * 60 * 1000, + updatedAt: Date.now(), + }), + saveJsonFileImpl, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result.token).toBe("fresh-copilot-token"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(saveJsonFileImpl).toHaveBeenCalledWith("/tmp/github-copilot-token-test.json", { + token: "fresh-copilot-token", + expiresAt: expect.any(Number), + updatedAt: expect.any(Number), + integrationId: COPILOT_INTEGRATION_ID, + }); + }); }); diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 07191d4988c..ef42a5a86c4 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -98,12 +98,14 @@ export const COPILOT_EDITOR_VERSION = "vscode/1.96.2"; export const COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"; export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0"; export const COPILOT_GITHUB_API_VERSION = "2025-04-01"; +export const COPILOT_INTEGRATION_ID = "vscode-chat"; export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; export type CachedCopilotToken = { token: string; expiresAt: number; updatedAt: number; + integrationId?: string; }; export function buildCopilotIdeHeaders( @@ -124,7 +126,7 @@ function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) { } function isCopilotTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean { - return cache.expiresAt - now > 5 * 60 * 1000; + return cache.integrationId === COPILOT_INTEGRATION_ID && cache.expiresAt - now > 5 * 60 * 1000; } function parseCopilotTokenResponse(value: unknown): { @@ -232,6 +234,7 @@ export async function resolveCopilotApiToken(params: { headers: { Accept: "application/json", Authorization: `Bearer ${params.githubToken}`, + "Copilot-Integration-Id": COPILOT_INTEGRATION_ID, ...buildCopilotIdeHeaders({ includeApiVersion: true }), }, }); @@ -245,6 +248,7 @@ export async function resolveCopilotApiToken(params: { token: json.token, expiresAt: json.expiresAt, updatedAt: Date.now(), + integrationId: COPILOT_INTEGRATION_ID, }; saveJsonFileFn(cachePath, payload);