From 6fec29ca2dabc22ea81bc235113d0b97128341a5 Mon Sep 17 00:00:00 2001 From: Tuan Dinh Date: Fri, 18 Sep 2026 21:58:48 +0700 Subject: [PATCH] fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider (#13705) * fix(copilot): fallback to copilot-chat on 403 identity denial for standard provider * fix(copilot): document COPILOT_INTEGRATION_ID, extract identity fallback, add changelog Adds the missing COPILOT_INTEGRATION_ID entry to .env.example (fixes tests/unit/issue-7793-env-doc-sync-repro.test.ts), extracts the GitHub Copilot 403 identity fallback out of open-sse/executors/base.ts into its own module (open-sse/executors/copilotIdentityFallback.ts) to bring the file back under the frozen file-size ratchet, and adds a changelog.d/fixes fragment for the PR. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: tuandinh0801 --- .env.example | 7 + .../13705-copilot-403-identity-fallback.md | 1 + docs/reference/ENVIRONMENT.md | 1 + open-sse/config/providerHeaderProfiles.ts | 24 ++- open-sse/executors/base.ts | 46 ++--- open-sse/executors/copilotIdentityFallback.ts | 109 ++++++++++ open-sse/executors/github.ts | 25 ++- tests/unit/executor-github.test.ts | 189 ++++++++++++++++++ tests/unit/ghe-copilot.test.ts | 45 +++++ tests/unit/provider-header-profiles.test.ts | 67 +++++++ 10 files changed, 477 insertions(+), 37 deletions(-) create mode 100644 changelog.d/fixes/13705-copilot-403-identity-fallback.md create mode 100644 open-sse/executors/copilotIdentityFallback.ts diff --git a/.env.example b/.env.example index 6cb7323fbe..67dc10bd17 100644 --- a/.env.example +++ b/.env.example @@ -1420,6 +1420,13 @@ CURSOR_USER_AGENT="Cursor/3.4" # Override the advertised GitHub Copilot CLI version independently of # GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts. # GITHUB_COPILOT_CLI_VERSION=1.0.82 +# +# Pin the `copilot-integration-id` header sent to standard GitHub Copilot, +# overriding the default copilot-developer-cli identity (and disabling the +# automatic 403-identity fallback to copilot-chat). Set this only if your +# Copilot account/org requires a specific integration id. Used by: +# open-sse/config/providerHeaderProfiles.ts, open-sse/executors/copilotIdentityFallback.ts. +# COPILOT_INTEGRATION_ID=copilot-chat # Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits) # from the Codex Responses stream. These frames break the OpenAI SDK's diff --git a/changelog.d/fixes/13705-copilot-403-identity-fallback.md b/changelog.d/fixes/13705-copilot-403-identity-fallback.md new file mode 100644 index 0000000000..74ca31ff04 --- /dev/null +++ b/changelog.d/fixes/13705-copilot-403-identity-fallback.md @@ -0,0 +1 @@ +- **fix(copilot):** fall back to the `copilot-chat` identity once when a standard GitHub Copilot account rejects the CLI identity with a 403, without breaking Enterprise Copilot ([#13705](https://github.com/diegosouzapw/OmniRoute/pull/13705)) — thanks @tuandinh0801 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 10cbf532a6..927cb3b7be 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -603,6 +603,7 @@ Built-in credentials for **localhost development**. For remote deployments, regi | `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | | `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | Public client. | | `GHE_COPILOT_OAUTH_CLIENT_ID` | GHE Copilot | Optional override for GitHub Enterprise Copilot's OAuth client id. Falls back to `GITHUB_OAUTH_CLIENT_ID`'s public default when unset. | +| `COPILOT_INTEGRATION_ID` | GitHub Copilot | Optional override for the GitHub Copilot client integration ID sent in `Copilot-Integration-Id` and `Editor-Plugin-Version` headers. Defaults to `copilot-developer-cli`. | | `WINDSURF_API_KEY` | Windsurf / Devin (v3.8) | API key fallback used by `open-sse/executors/devin-cli.ts` when no per-connection credential is available. Optional. | | `CLI_DEVIN_BIN` | Devin CLI (v3.8) | Custom path to the Devin CLI binary (`devin`). Resolved by `open-sse/executors/devin-cli.ts`. | | `GITLAB_DUO_OAUTH_CLIENT_ID` | GitLab Duo (v3.8) | OAuth client ID for GitLab Duo. Register an app at `https://gitlab.com/-/profile/applications` with redirect URI `/callback` and scopes `api, read_user, openid, profile, email`. Falls back to `GITLAB_OAUTH_CLIENT_ID`. | diff --git a/open-sse/config/providerHeaderProfiles.ts b/open-sse/config/providerHeaderProfiles.ts index 68a314ddd5..84403b66ef 100644 --- a/open-sse/config/providerHeaderProfiles.ts +++ b/open-sse/config/providerHeaderProfiles.ts @@ -41,12 +41,26 @@ export const GITHUB_COPILOT_REFRESH_USER_AGENT = "GithubCopilot/1.0"; export function getGitHubCopilotChatUserAgent(): string { return `GitHubCopilotChat/${getGitHubCopilotCliVersion()}`; } -export const GITHUB_COPILOT_INTEGRATION_ID = "copilot-developer-cli"; +export const GITHUB_COPILOT_CLI_INTEGRATION_ID = "copilot-developer-cli"; +export const GITHUB_COPILOT_CHAT_INTEGRATION_ID = "copilot-chat"; +export const GITHUB_COPILOT_INTEGRATION_ID = GITHUB_COPILOT_CLI_INTEGRATION_ID; export const GITHUB_COPILOT_OPENAI_INTENT = "conversation-agent"; export const GITHUB_COPILOT_INTERACTION_TYPE = "conversation-user"; export const GITHUB_COPILOT_HARNESS_ID = "copilot-sdk"; export const GITHUB_COPILOT_DEFAULT_INITIATOR = "user"; +export function normalizeCopilotIntegrationId(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || /[\r\n]/.test(trimmed)) return null; + return trimmed; +} + +export function resolveCopilotIntegrationIdOverride(): string | null { + const raw = typeof process === "undefined" ? undefined : process.env?.COPILOT_INTEGRATION_ID; + return normalizeCopilotIntegrationId(raw); +} + // Stable per-install device fingerprint (the CLI's X-Client-Machine-Id). The // real @github/copilot CLI sends ONE stable UUID on every inference + /models // call (verified identical across all captured requests) — a per-call random id @@ -86,7 +100,7 @@ export const CURSOR_REGISTRY_VERSION = "3.9"; export function getGitHubCopilotChatHeaders( accept = "application/json", initiator = GITHUB_COPILOT_DEFAULT_INITIATOR, - options: { vision?: boolean; intent?: string } = {} + options: { vision?: boolean; intent?: string; integrationId?: string } = {} ): Record { // Matches the live @github/copilot CLI 1.0.81-6 inference request 1:1 (MITM- // captured). NOTE the CLI does NOT send `editor-plugin-version` nor @@ -97,8 +111,12 @@ export function getGitHubCopilotChatHeaders( // is the catalog-unlock lever; the stable X-Client-Machine-Id is the CLI's // per-install device fingerprint. const version = getGitHubCopilotCliVersion(); + const integrationId = + normalizeCopilotIntegrationId(options.integrationId) || + resolveCopilotIntegrationIdOverride() || + GITHUB_COPILOT_CLI_INTEGRATION_ID; const headers: Record = { - "copilot-integration-id": GITHUB_COPILOT_INTEGRATION_ID, + "copilot-integration-id": integrationId, "editor-version": `copilot/${version}`, "user-agent": `copilot/${version}`, "openai-intent": options.intent || GITHUB_COPILOT_OPENAI_INTENT, diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index cdbd1046a7..19c8bc9fd9 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -12,6 +12,7 @@ import { normalizeAnthropicHeaderVariants, } from "../config/anthropicHeaders.ts"; import { applyContextEditingToBody } from "../config/contextEditing.ts"; +import { createCopilotIdentityFallback } from "./copilotIdentityFallback.ts"; import { findOffendingField, detectUnsupportedParam, @@ -317,7 +318,6 @@ export type ExecutorExecuteResult = transformedBody?: unknown; transport?: string; }; - export class BaseExecutor { provider: string; config: ProviderConfig; @@ -821,24 +821,17 @@ export class BaseExecutor { } } - // Set by the Context Editing 400-fallback below: once an upstream rejects the - // `context_management` param, suppress its re-injection on every later - // retry/fallback URL (each iteration rebuilds a fresh `transformedBody`). + // Context Editing 400-fallback below: suppresses `context_management` re-injection + // on later retry/fallback URLs once an upstream rejects it. let contextEditingDisabled = false; - // Tracks which request fields have already been stripped via the generic 400 - // field-downgrade below, so each known field is stripped at most once across - // all fallback URLs (bounded retry loop). + // Fields already stripped by the generic 400 field-downgrade below (once each, + // across all fallback URLs — bounded retry loop). const strippedFields = new Set(); - // Set by the thinking_budget 400 clamp-and-retry below: the upstream's - // advertised max (parsed from the error) is applied to every later - // retry/fallback URL so they don't re-hit the same 400. The clamp itself - // fires at most once per URL (guarded inline) so a persistent 400 cannot - // loop. The learned cap is also recorded process-wide via - // recordLearnedThinkingCap so future requests skip the 400 entirely. + // thinking_budget 400 clamp-and-retry below: upstream's learned max applied to + // later retry URLs (bounded per URL); also recorded via recordLearnedThinkingCap. let thinkingBudgetClampedMax: number | null = null; - // Set by the reasoning_effort 4xx clamp-and-retry below — guards the same - // "fires at most once per URL" invariant as thinkingBudgetClampedMax above. - let reasoningEffortClamped = false; + let reasoningEffortClamped = false; // reasoning_effort 4xx clamp-and-retry below. + const applyCopilotIdentityFallback = createCopilotIdentityFallback(this.provider, log); for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const requestCredentials = withForcedResponsesUpstream( @@ -1465,12 +1458,9 @@ export class BaseExecutor { body: bodyString, }; - // OpenRouter `:free`-variant local window (#6842): record every real - // dispatch attempt (failed attempts still consume a request slot per - // OpenRouter's own accounting) and self-correct the local counters - // from the upstream `X-RateLimit-*` headers on the response. Scoped - // to `:free` models only — no-op (and no extra work) for every other - // OpenRouter request or provider. + // OpenRouter `:free`-variant local window (#6842): record every dispatch + // attempt and self-correct local counters from `X-RateLimit-*` headers. + // Scoped to `:free` models only — no-op for every other request/provider. const openrouterFreeWindowAccountKey = this.provider === "openrouter" && isFreeVariantModel(model) && @@ -1481,9 +1471,7 @@ export class BaseExecutor { recordFreeWindowAttempt(openrouterFreeWindowAccountKey); } - // WAF burst guard: agentrouter.org's content filter becomes more - // aggressive after rapid requests. Enforce a small inter-request gap - // to avoid tripping it. See open-sse/services/wafRateLimit.ts. + // WAF burst guard for agentrouter.org's content filter — see wafRateLimit.ts. if (this.provider === "agentrouter") { await gateOutboundRequest(`agentrouter:${url}`); } @@ -1494,6 +1482,14 @@ export class BaseExecutor { correctFromRateLimitHeaders(openrouterFreeWindowAccountKey, response.headers); } + ({ response, finalHeaders } = await applyCopilotIdentityFallback({ + response, + url, + fetchOptions, + clientHeaders, + fetchWithStartTimeout, + })); + // Context Editing 400-fallback for Claude-compatible relays. if ( response.status === HTTP_STATUS.BAD_REQUEST && diff --git a/open-sse/executors/copilotIdentityFallback.ts b/open-sse/executors/copilotIdentityFallback.ts new file mode 100644 index 0000000000..dac30f7806 --- /dev/null +++ b/open-sse/executors/copilotIdentityFallback.ts @@ -0,0 +1,109 @@ +import { HTTP_STATUS } from "../config/constants.ts"; +import { + GITHUB_COPILOT_CLI_INTEGRATION_ID, + GITHUB_COPILOT_CHAT_INTEGRATION_ID, + resolveCopilotIntegrationIdOverride, +} from "../config/providerHeaderProfiles.ts"; +import type { ExecutorLog } from "./base.ts"; + +export function readHeaderCaseInsensitive( + headers: Record | null | undefined, + name: string +): string | null { + if (!headers) return null; + const target = name.toLowerCase(); + const direct = headers[name] ?? headers[target]; + if (typeof direct === "string") return direct; + for (const key in headers) { + if (key.toLowerCase() === target && typeof headers[key] === "string") { + return headers[key]; + } + } + return null; +} + +export type CopilotIdentityFallbackArgs = { + response: Response; + url: string; + fetchOptions: RequestInit; + clientHeaders?: Record | null; + fetchWithStartTimeout: (requestUrl: string, requestOptions: RequestInit) => Promise; +}; + +export type CopilotIdentityFallbackResult = { + response: Response; + finalHeaders: Record; +}; + +/** + * GitHub Copilot 403 identity fallback: business/org accounts may reject the CLI + * identity (copilot-developer-cli) while allowing copilot-chat. Returns a bound + * per-`execute()`-call helper (provider + log fixed, retry state kept in the + * closure) so at most one identity retry happens across every fallback URL, + * gated strictly to standard github (not ghe-copilot), and only when identity + * was not explicitly pinned by the client or the operator. + */ +export function createCopilotIdentityFallback(provider: string, log?: ExecutorLog | null) { + let retried = false; + + return async function applyCopilotIdentityFallback( + args: CopilotIdentityFallbackArgs + ): Promise { + const { response, url, fetchOptions, clientHeaders, fetchWithStartTimeout } = args; + const finalHeaders = fetchOptions.headers as Record; + + if ( + retried || + provider !== "github" || + response.status !== HTTP_STATUS.FORBIDDEN || + resolveCopilotIntegrationIdOverride() || + process.env.COPILOT_INTEGRATION_ID?.trim() || + readHeaderCaseInsensitive(clientHeaders, "copilot-integration-id")?.trim() + ) { + return { response, finalHeaders }; + } + + const currentIntegrationId = readHeaderCaseInsensitive(finalHeaders, "copilot-integration-id"); + if (currentIntegrationId !== GITHUB_COPILOT_CLI_INTEGRATION_ID) { + return { response, finalHeaders }; + } + + const errText = await response + .clone() + .text() + .catch(() => ""); + const isQuotaError = /quota|rate[_-]?limit|exceeded|insufficient_quota/i.test(errText); + const hasIdentityEvidence = + !isQuotaError && + (/access denied/i.test(errText) || + (/copilot/i.test(errText) && /403/.test(errText)) || + /integration[_-]?id/i.test(errText) || + /not (?:permitted|allowed|authorized)/i.test(errText)); + + if (!hasIdentityEvidence) { + return { response, finalHeaders }; + } + + await response.text().catch(() => ""); + log?.warn?.( + "COPILOT_IDENTITY", + `Standard GitHub Copilot identity ${GITHUB_COPILOT_CLI_INTEGRATION_ID} denied (403) — retrying once with ${GITHUB_COPILOT_CHAT_INTEGRATION_ID}` + ); + const retryHeaders: Record = { + ...finalHeaders, + "copilot-integration-id": GITHUB_COPILOT_CHAT_INTEGRATION_ID, + }; + for (const key of Object.keys(retryHeaders)) { + if (key.toLowerCase() === "copilot-integration-id" && key !== "copilot-integration-id") { + delete retryHeaders[key]; + } + } + retried = true; + const retryResponse = await fetchWithStartTimeout(url, { + ...fetchOptions, + headers: retryHeaders, + }); + + return { response: retryResponse, finalHeaders: retryHeaders }; + }; +} diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 6211b509a0..99e973437d 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -331,12 +331,17 @@ export class GithubExecutor extends BaseExecutor { ): Record { const token = this.getCopilotToken(credentials) || credentials.accessToken; const initiator = this.resolveInitiatorHeader(clientHeaders); + const clientIntegrationId = + this.readClientHeader(clientHeaders, "copilot-integration-id") || undefined; const headers: Record = { - ...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator), + ...getGitHubCopilotChatHeaders( + stream ? "text/event-stream" : "application/json", + initiator, + clientIntegrationId ? { integrationId: clientIntegrationId } : undefined + ), Authorization: `Bearer ${token}`, - "x-request-id": - crypto.randomUUID?.() || randomIdFallback(), + "x-request-id": crypto.randomUUID?.() || randomIdFallback(), }; // Per-call / per-conversation / per-turn correlation ids the @github/copilot @@ -344,13 +349,12 @@ export class GithubExecutor extends BaseExecutor { // id (getGitHubCopilotMachineId) is stable per-install; these three are // fresh uuids. A Copilot-aware client may pin the session/task ids across a // conversation via its own headers — honor those when present, else mint. - const genId = () => - crypto.randomUUID?.() || randomIdFallback(); - headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId(); + const genId = () => crypto.randomUUID?.() || randomIdFallback(); + headers["x-interaction-id"] = + this.readClientHeader(clientHeaders, "x-interaction-id") || genId(); headers["x-client-session-id"] = this.readClientHeader(clientHeaders, "x-client-session-id") || genId(); - headers["x-agent-task-id"] = - this.readClientHeader(clientHeaders, "x-agent-task-id") || genId(); + headers["x-agent-task-id"] = this.readClientHeader(clientHeaders, "x-agent-task-id") || genId(); // Repository correlation sentinels. The CLI sends the working repo's nwo/host // or these literals when there is no repository context. OmniRoute is not // repo-scoped, so forward a client-supplied value when present, else sentinel. @@ -376,7 +380,10 @@ export class GithubExecutor extends BaseExecutor { // /v1/messages proxy returns an empty content block for image turns unless // copilot-vision-request:true is present; a Copilot-aware harness that sends // it should have it honored rather than stripped. - if ((this.readClientHeader(clientHeaders, "copilot-vision-request") || "").toLowerCase() === "true") { + if ( + (this.readClientHeader(clientHeaders, "copilot-vision-request") || "").toLowerCase() === + "true" + ) { headers["copilot-vision-request"] = "true"; } diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index 9f17e6695d..2c1aabc8a3 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; import { GithubExecutor } from "../../open-sse/executors/github.ts"; import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.ts"; +import { + GITHUB_COPILOT_CLI_INTEGRATION_ID, + GITHUB_COPILOT_CHAT_INTEGRATION_ID, +} from "../../open-sse/config/providerHeaderProfiles.ts"; function registerModel(provider, model) { PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model]; @@ -593,3 +597,188 @@ test("GithubExecutor.transformRequest strips invalid synthetic Responses reasoni assert.equal(result.input[0].id, undefined); assert.equal(result.input[0].type, "reasoning"); }); + +test("GithubExecutor.buildHeaders honors case-insensitive client copilot-integration-id", () => { + const executor = new GithubExecutor(); + + const lowerCase = executor.buildHeaders({ accessToken: "gh" }, true, { + "copilot-integration-id": "custom-cli-id", + }); + assert.equal(lowerCase["copilot-integration-id"], "custom-cli-id"); + + const mixedCase = executor.buildHeaders({ accessToken: "gh" }, true, { + "CoPiLoT-InTeGrAtIoN-iD": "custom-mixed-id", + }); + assert.equal(mixedCase["copilot-integration-id"], "custom-mixed-id"); + + const defaultHeaders = executor.buildHeaders({ accessToken: "gh" }); + assert.equal(defaultHeaders["copilot-integration-id"], GITHUB_COPILOT_CLI_INTEGRATION_ID); +}); + +test("GithubExecutor.execute retries 403 identity denial once with copilot-chat", async () => { + const executor = new GithubExecutor(); + const originalFetch = globalThis.fetch; + const seenIntegrationIds: string[] = []; + + globalThis.fetch = async (_url, init: RequestInit = {}) => { + const headers = init.headers as Record; + const integrationId = headers["copilot-integration-id"]; + seenIntegrationIds.push(integrationId); + + if (integrationId === GITHUB_COPILOT_CLI_INTEGRATION_ID) { + return new Response( + JSON.stringify({ + message: "Access denied: copilot-developer-cli is not permitted by organization policy", + }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + accessToken: "gh-access-token", + providerSpecificData: { copilotToken: "copilot-token" }, + }, + }); + + assert.deepEqual(seenIntegrationIds, [ + GITHUB_COPILOT_CLI_INTEGRATION_ID, + GITHUB_COPILOT_CHAT_INTEGRATION_ID, + ]); + const res = result as { response: Response; headers: Record }; + assert.equal(res.response.status, 200); + assert.equal(res.headers["copilot-integration-id"], GITHUB_COPILOT_CHAT_INTEGRATION_ID); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GithubExecutor.execute repeated 403 identity denial retries at most once", async () => { + const executor = new GithubExecutor(); + const originalFetch = globalThis.fetch; + const seenIntegrationIds: string[] = []; + + globalThis.fetch = async (_url, init: RequestInit = {}) => { + const headers = init.headers as Record; + seenIntegrationIds.push(headers["copilot-integration-id"]); + return new Response(JSON.stringify({ message: "Access denied: Copilot 403 Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + accessToken: "gh-access-token", + providerSpecificData: { copilotToken: "copilot-token" }, + }, + }); + + assert.deepEqual(seenIntegrationIds, [ + GITHUB_COPILOT_CLI_INTEGRATION_ID, + GITHUB_COPILOT_CHAT_INTEGRATION_ID, + ]); + const res = result as { response: Response }; + assert.equal(res.response.status, 403); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GithubExecutor.execute suppresses 403 fallback when client header or env pin is present or on quota error", async () => { + const executor = new GithubExecutor(); + const originalFetch = globalThis.fetch; + let callCount = 0; + const seenIntegrationIds: string[] = []; + + globalThis.fetch = async (_url, init: RequestInit = {}) => { + callCount++; + const headers = init.headers as Record; + seenIntegrationIds.push(headers["copilot-integration-id"]); + return new Response(JSON.stringify({ message: "Access denied: organization policy" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + }; + + const originalEnv = process.env.COPILOT_INTEGRATION_ID; + try { + // 1. Explicit client header pin suppresses fallback + callCount = 0; + seenIntegrationIds.length = 0; + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + accessToken: "gh-access-token", + providerSpecificData: { copilotToken: "copilot-token" }, + }, + clientHeaders: { "copilot-integration-id": GITHUB_COPILOT_CLI_INTEGRATION_ID }, + }); + assert.equal(callCount, 1); + assert.deepEqual(seenIntegrationIds, [GITHUB_COPILOT_CLI_INTEGRATION_ID]); + + // 2. Explicit env pin suppresses fallback + process.env.COPILOT_INTEGRATION_ID = GITHUB_COPILOT_CLI_INTEGRATION_ID; + callCount = 0; + seenIntegrationIds.length = 0; + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + accessToken: "gh-access-token", + providerSpecificData: { copilotToken: "copilot-token" }, + }, + }); + assert.equal(callCount, 1); + assert.deepEqual(seenIntegrationIds, [GITHUB_COPILOT_CLI_INTEGRATION_ID]); + delete process.env.COPILOT_INTEGRATION_ID; + + // 3. Quota 403 error does not trigger identity retry + callCount = 0; + seenIntegrationIds.length = 0; + globalThis.fetch = async (_url, init: RequestInit = {}) => { + callCount++; + const headers = init.headers as Record; + seenIntegrationIds.push(headers["copilot-integration-id"]); + return new Response(JSON.stringify({ message: "Quota exceeded: monthly limit reached" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + }; + await executor.execute({ + model: "gpt-4.1", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { + accessToken: "gh-access-token", + providerSpecificData: { copilotToken: "copilot-token" }, + }, + }); + assert.equal(callCount, 1); + assert.deepEqual(seenIntegrationIds, [GITHUB_COPILOT_CLI_INTEGRATION_ID]); + } finally { + globalThis.fetch = originalFetch; + if (originalEnv === undefined) { + delete process.env.COPILOT_INTEGRATION_ID; + } else { + process.env.COPILOT_INTEGRATION_ID = originalEnv; + } + } +}); diff --git a/tests/unit/ghe-copilot.test.ts b/tests/unit/ghe-copilot.test.ts index d71a1fd444..4e1a5ca0ea 100644 --- a/tests/unit/ghe-copilot.test.ts +++ b/tests/unit/ghe-copilot.test.ts @@ -190,3 +190,48 @@ test("isValidGheUrl accepts https enterprise hosts and rejects malformed or non- assert.equal(isValidGheUrl("javascript:alert(1)"), false); assert.equal(isValidGheUrl("not a url"), false); }); + +test("GheCopilotExecutor.execute does not trigger identity fallback on 403", async () => { + const executor = new GheCopilotExecutor({ + gheUrl: "https://ghe.company.com", + clientId: "test-client", + clientSecret: "test-secret", + }); + const originalFetch = globalThis.fetch; + let callCount = 0; + const seenIntegrationIds: string[] = []; + + globalThis.fetch = async (_url, init: RequestInit = {}) => { + callCount++; + const headers = init.headers as Record; + seenIntegrationIds.push(headers["copilot-integration-id"]); + return new Response( + JSON.stringify({ message: "Access denied: Enterprise Copilot 403 Forbidden" }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + }; + + try { + const credentials: ProviderCredentials = { + accessToken: "ghe-token", + providerSpecificData: { + gheUrl: "https://ghe.company.com", + copilotToken: "copilot-token", + }, + }; + + const result = await executor.execute({ + model: "gpt-4o", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials, + }); + + assert.equal(callCount, 1, "GHE Copilot must never retry on 403"); + assert.deepEqual(seenIntegrationIds, ["copilot-developer-cli"]); + const res = result as { response: Response }; + assert.equal(res.response.status, 403); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/provider-header-profiles.test.ts b/tests/unit/provider-header-profiles.test.ts index 09c33ae017..3ff9be4523 100644 --- a/tests/unit/provider-header-profiles.test.ts +++ b/tests/unit/provider-header-profiles.test.ts @@ -7,6 +7,8 @@ import { GITHUB_COPILOT_CLI_USER_AGENT, GITHUB_COPILOT_CHAT_USER_AGENT, GITHUB_COPILOT_EDITOR_VERSION, + GITHUB_COPILOT_CLI_INTEGRATION_ID, + GITHUB_COPILOT_CHAT_INTEGRATION_ID, GITHUB_COPILOT_INTEGRATION_ID, GITHUB_COPILOT_INTERACTION_TYPE, GITHUB_COPILOT_HARNESS_ID, @@ -22,6 +24,8 @@ import { getGitHubCopilotRefreshHeaders, getKiroServiceHeaders, getQoderDashscopeCompatHeaders, + normalizeCopilotIntegrationId, + resolveCopilotIntegrationIdOverride, } from "../../open-sse/config/providerHeaderProfiles.ts"; test("provider header profiles expose current GitHub chat and internal headers", () => { @@ -107,3 +111,66 @@ test("provider header profiles tolerate browser-like process shims", async () => Object.defineProperty(process, "version", { value: originalVersion, configurable: true }); } }); + +test("Copilot integration ID constants and resolution precedence", () => { + assert.equal(GITHUB_COPILOT_CLI_INTEGRATION_ID, "copilot-developer-cli"); + assert.equal(GITHUB_COPILOT_CHAT_INTEGRATION_ID, "copilot-chat"); + assert.equal(GITHUB_COPILOT_INTEGRATION_ID, GITHUB_COPILOT_CLI_INTEGRATION_ID); + + // normalizeCopilotIntegrationId validates strings and rejects CR/LF or whitespace + assert.equal(normalizeCopilotIntegrationId("custom-client"), "custom-client"); + assert.equal(normalizeCopilotIntegrationId(" custom-client "), "custom-client"); + assert.equal(normalizeCopilotIntegrationId(""), null); + assert.equal(normalizeCopilotIntegrationId(" "), null); + assert.equal(normalizeCopilotIntegrationId("bad\r\nheader"), null); + assert.equal(normalizeCopilotIntegrationId("bad\nheader"), null); + assert.equal(normalizeCopilotIntegrationId(123), null); + assert.equal(normalizeCopilotIntegrationId(null), null); + + // Default headers use CLI integration ID + const defaultHeaders = getGitHubCopilotChatHeaders(); + assert.equal(defaultHeaders["copilot-integration-id"], GITHUB_COPILOT_CLI_INTEGRATION_ID); + + // options.integrationId overrides default + const optionHeaders = getGitHubCopilotChatHeaders("application/json", "user", { + integrationId: "custom-opt-id", + }); + assert.equal(optionHeaders["copilot-integration-id"], "custom-opt-id"); + + // Environment variable override and precedence + const originalEnv = process.env.COPILOT_INTEGRATION_ID; + try { + process.env.COPILOT_INTEGRATION_ID = "env-integration-id"; + assert.equal(resolveCopilotIntegrationIdOverride(), "env-integration-id"); + + // Env overrides default + const envHeaders = getGitHubCopilotChatHeaders(); + assert.equal(envHeaders["copilot-integration-id"], "env-integration-id"); + + // options.integrationId takes precedence over env override + const optOverEnvHeaders = getGitHubCopilotChatHeaders("application/json", "user", { + integrationId: "opt-wins", + }); + assert.equal(optOverEnvHeaders["copilot-integration-id"], "opt-wins"); + + // Invalid env value with CR/LF falls back to CLI default + process.env.COPILOT_INTEGRATION_ID = "invalid\r\nid"; + assert.equal(resolveCopilotIntegrationIdOverride(), null); + const fallbackHeaders = getGitHubCopilotChatHeaders(); + assert.equal(fallbackHeaders["copilot-integration-id"], GITHUB_COPILOT_CLI_INTEGRATION_ID); + + // Empty/whitespace env value falls back to CLI default + process.env.COPILOT_INTEGRATION_ID = " "; + assert.equal(resolveCopilotIntegrationIdOverride(), null); + assert.equal( + getGitHubCopilotChatHeaders()["copilot-integration-id"], + GITHUB_COPILOT_CLI_INTEGRATION_ID + ); + } finally { + if (originalEnv === undefined) { + delete process.env.COPILOT_INTEGRATION_ID; + } else { + process.env.COPILOT_INTEGRATION_ID = originalEnv; + } + } +});