diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf07697b3f..bc8ed7af79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai - MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux. - Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson. - ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run. +- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. Thanks @vincentkoc. ## 2026.4.1-beta.1 diff --git a/src/agents/github-copilot-token.test.ts b/src/agents/github-copilot-token.test.ts index 0bad7397c14..daba16f4947 100644 --- a/src/agents/github-copilot-token.test.ts +++ b/src/agents/github-copilot-token.test.ts @@ -1,7 +1,31 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveCopilotApiToken } from "./github-copilot-token.js"; +import { + deriveCopilotApiBaseUrlFromToken, + resolveCopilotApiToken, +} from "./github-copilot-token.js"; describe("resolveCopilotApiToken", () => { + it("derives native Copilot base URLs from Copilot proxy hints", () => { + expect( + deriveCopilotApiBaseUrlFromToken( + "copilot-token;proxy-ep=https://proxy.individual.githubcopilot.com;", + ), + ).toBe("https://api.individual.githubcopilot.com"); + expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=proxy.example.com;")).toBe( + "https://api.example.com", + ); + expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=proxy.example.com:8443;")).toBe( + "https://api.example.com", + ); + }); + + it("rejects malformed or non-http proxy hints", () => { + expect( + deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=javascript:alert(1);"), + ).toBeNull(); + expect(deriveCopilotApiBaseUrlFromToken("copilot-token;proxy-ep=://bad;")).toBeNull(); + }); + it("treats 11-digit expires_at values as seconds epochs", async () => { const fetchImpl = vi.fn(async () => ({ ok: true, diff --git a/src/agents/github-copilot-token.ts b/src/agents/github-copilot-token.ts index ee0d09098a2..d600a7d72da 100644 --- a/src/agents/github-copilot-token.ts +++ b/src/agents/github-copilot-token.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { resolveProviderEndpoint } from "./provider-attribution.js"; const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"; @@ -55,6 +56,24 @@ function parseCopilotTokenResponse(value: unknown): { export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com"; +function resolveCopilotProxyHost(proxyEp: string): string | null { + const trimmed = proxyEp.trim(); + if (!trimmed) { + return null; + } + + const urlText = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const url = new URL(urlText); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return url.hostname.toLowerCase(); + } catch { + return null; + } +} + export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { const trimmed = token.trim(); if (!trimmed) { @@ -71,12 +90,14 @@ export function deriveCopilotApiBaseUrlFromToken(token: string): string | null { // pi-ai expects converting proxy.* -> api.* // (see upstream getGitHubCopilotBaseUrl). - const host = proxyEp.replace(/^https?:\/\//, "").replace(/^proxy\./i, "api."); - if (!host) { + const proxyHost = resolveCopilotProxyHost(proxyEp); + if (!proxyHost) { return null; } + const host = proxyHost.replace(/^proxy\./i, "api."); - return `https://${host}`; + const baseUrl = `https://${host}`; + return resolveProviderEndpoint(baseUrl).endpointClass === "invalid" ? null : baseUrl; } export async function resolveCopilotApiToken(params: { diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index 6a40b0d2ce4..242f39cde4d 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -340,6 +340,23 @@ describe("provider attribution", () => { }); }); + it("classifies native GitHub Copilot endpoints separately from custom hosts", () => { + expect(resolveProviderEndpoint("https://api.individual.githubcopilot.com")).toMatchObject({ + endpointClass: "github-copilot-native", + hostname: "api.individual.githubcopilot.com", + }); + + expect(resolveProviderEndpoint("https://api.enterprise.githubcopilot.com")).toMatchObject({ + endpointClass: "github-copilot-native", + hostname: "api.enterprise.githubcopilot.com", + }); + + expect(resolveProviderEndpoint("https://api.githubcopilot.example.com")).toMatchObject({ + endpointClass: "custom", + hostname: "api.githubcopilot.example.com", + }); + }); + it("does not classify malformed or embedded Google host strings as native endpoints", () => { expect(resolveProviderEndpoint("proxy/generativelanguage.googleapis.com")).toMatchObject({ endpointClass: "custom", @@ -516,4 +533,20 @@ describe("provider attribution", () => { compatibilityFamily: "moonshot", }); }); + + it("treats native GitHub Copilot base URLs as known native endpoints", () => { + expect( + resolveProviderRequestCapabilities({ + provider: "github-copilot", + api: "openai-responses", + baseUrl: "https://api.individual.githubcopilot.com", + capability: "llm", + transport: "http", + }), + ).toMatchObject({ + endpointClass: "github-copilot-native", + knownProviderFamily: "github-copilot", + isKnownNativeEndpoint: true, + }); + }); }); diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index bef9c899ceb..330d598c01f 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -34,6 +34,7 @@ export type ProviderRequestCapability = "llm" | "audio" | "image" | "video" | "o export type ProviderEndpointClass = | "default" | "anthropic-public" + | "github-copilot-native" | "moonshot-native" | "modelstudio-native" | "openai-public" @@ -201,6 +202,9 @@ export function resolveProviderEndpoint( if (host === "api.anthropic.com") { return { endpointClass: "anthropic-public", hostname: host }; } + if (host.endsWith(".githubcopilot.com")) { + return { endpointClass: "github-copilot-native", hostname: host }; + } if (host === "chatgpt.com") { return { endpointClass: "openai-codex", hostname: host }; } @@ -494,6 +498,7 @@ export function resolveProviderRequestCapabilities( const endpointClass = policy.endpointClass; const isKnownNativeEndpoint = endpointClass === "anthropic-public" || + endpointClass === "github-copilot-native" || endpointClass === "moonshot-native" || endpointClass === "modelstudio-native" || endpointClass === "openai-public" ||