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 <tuandinh0801@users.noreply.github.com>
This commit is contained in:
Tuan Dinh
2026-09-18 21:58:48 +07:00
committed by GitHub
parent 24706705fa
commit 6fec29ca2d
10 changed files with 477 additions and 37 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 `<NEXT_PUBLIC_BASE_URL>/callback` and scopes `api, read_user, openid, profile, email`. Falls back to `GITLAB_OAUTH_CLIENT_ID`. |

View File

@@ -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<string, string> {
// 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<string, string> = {
"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,

View File

@@ -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<string>();
// 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 &&

View File

@@ -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<string, string> | 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<string, string> | null;
fetchWithStartTimeout: (requestUrl: string, requestOptions: RequestInit) => Promise<Response>;
};
export type CopilotIdentityFallbackResult = {
response: Response;
finalHeaders: Record<string, string>;
};
/**
* 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<CopilotIdentityFallbackResult> {
const { response, url, fetchOptions, clientHeaders, fetchWithStartTimeout } = args;
const finalHeaders = fetchOptions.headers as Record<string, string>;
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<string, string> = {
...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 };
};
}

View File

@@ -331,12 +331,17 @@ export class GithubExecutor extends BaseExecutor {
): Record<string, string> {
const token = this.getCopilotToken(credentials) || credentials.accessToken;
const initiator = this.resolveInitiatorHeader(clientHeaders);
const clientIntegrationId =
this.readClientHeader(clientHeaders, "copilot-integration-id") || undefined;
const headers: Record<string, string> = {
...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";
}

View File

@@ -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<string, string>;
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<string, string> };
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<string, string>;
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<string, string>;
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<string, string>;
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;
}
}
});

View File

@@ -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<string, string>;
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;
}
});

View File

@@ -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;
}
}
});