Files
OmniRoute/tests/unit/ghe-copilot.test.ts
hppsc1215 b3d3dd5954 feat(providers): Complete GHE Copilot OAuth provider implementation (#7546)
* docs: add design spec for GHE Copilot provider

* feat(mitm): add GHE Copilot target descriptor

* feat(executors): add GheCopilotExecutor for GHE Copilot

* feat(executors): register GheCopilotExecutor in factory

* feat(providers): add ghe-copilot provider with gheUrl validation

* feat(providers): add ghe-copilot to OAUTH_PROVIDERS and enforce HTTPS gheUrl validation

* test(ghe-copilot): add unit tests for GheCopilotExecutor and GHE_COPILOT_TARGET

* feat: complete GHE Copilot provider implementation

* feat: register ghe-copilot provider in registry

Add GHE Copilot registry entry (executor: "ghe-copilot") so the
provider is resolvable by the API routes and gets the same model
catalog as github Copilot.

* feat: wire ghe-copilot into OAuth flow with per-connection gheUrl

- Add gheCopilot OAuth provider (device-code flow targeting GHE host)
- Register in OAuth PROVIDERS map
- Thread gheUrl from query param → device-code request → poll →
  postExchange → providerSpecificData so the GHE host is used end-to-end
- Restore corrupted src/lib/oauth/providers/github.ts from HEAD

* feat: add ghe-copilot device-code UI with gheUrl input

- Route ghe-copilot through the device-code OAuth branch (was falling
  through to browser OAuth → "Browser OAuth unavailable" error)
- Add a gheUrl collection step so the enterprise host is supplied before
  the device-code request, and thread it into /device-code + /poll

* fix: thread gheUrl through GHE Copilot pollToken + postExchange

pollToken read gheUrl from config (GITHUB_CONFIG, which has none) and
threw "gheUrl is required" on every poll — the connection hung forever
after device authorization. Now reads gheUrl from extraData (passed by
the route), and postExchange carries it forward into mapTokens so it is
persisted in providerSpecificData for the executor.

* fix: GHE Copilot chat routing + account test

- Capture endpoints.proxy from the GHE token response and store it as
  copilotProxyUrl; route chat/responses traffic to that enterprise host
  instead of the static gheUrl/chat/completions path (was 406/404).
- Always route GHE Copilot to /chat/completions (GHE proxy 404s on
  /responses); the Responses API is served via the chat transformer.
- Strip the ghe-copilot/ prefix from the upstream model id.
- Remove openai-responses targetFormat from GHE models so chatCore does
  not run the Responses transformer (which dropped `messages`).
- Add ghe-copilot to OAUTH_TEST_CONFIG (account test was "unsupported").
- Register executor in eslint suppressions.

* fix: drop stream:false for GHE Copilot

The GHE Copilot proxy rejects `stream: false` ("stream": false is not
supported). Only forward the flag when actually streaming; omit it
otherwise.

* fix: force stream:true upstream for GHE Copilot (streaming-only proxy)

The GHE Copilot proxy rejects `stream: false`. forceStream:true in the
registry makes chatCore pass upstreamStream=true, but GithubExecutor
.transformRequest ignores the stream arg (void stream) and keeps the
client's stream:false. Override transformRequest in GheCopilotExecutor to
force stream:true so the proxy accepts the request; chatCore drains the
SSE back to JSON for non-stream clients.

* fix: GHE Copilot live model discovery from copilotProxyUrl/models

- Add fetchGheCopilotModels/parseGheCopilotModels using enterprise proxy URL
  and { models: [{ name }] } response shape (no static allowlist)
- Wire ghe-copilot into models-import route; use plain fetch (safeOutboundFetch
  header guard strips the copilot bearer token -> 403)
- Import now returns real enterprise models (copilot-nes-oct, etc.) and chat
  resolves them correctly

* fix: GHE Copilot uses endpoints.api host for chat + model discovery

The GHE token endpoint returns two hosts:
  - endpoints.api   (copilotApiUrl)   -> chat/completions + real chat model
    catalog, shape { data: [{ id }] }
  - endpoints.proxy (copilotProxyUrl) -> NES/autocomplete/instant-apply only,
    shape { models: [{ name }] }

We were routing chat AND model discovery to endpoints.proxy, so import only
returned completion models (copilot-nes-*, instant-apply) and never the real
chat models (claude-*, gpt-*, gemini-*).

- Executor: capture endpoints.api as copilotApiUrl; buildUrl prefers it
- OAuth postExchange/mapTokens: persist copilotApiUrl from endpoints.api
- Model discovery: fetch from copilotApiUrl/models, parse { data:[{id}] }
  (and proxy { models:[{name}] }) shapes, no allowlist
- All traffic stays on the configured GHE host (deutschebahn.ghe.com),
  never api.githubcopilot.com

Verified: import returns 28 real chat models; chat with gpt-4o streams OK.

* feat(providers): finalize GHE Copilot implementation and add changelog fragment

* fix(providers): resolve ghe-copilot no-explicit-any + complexity ratchet

- Replace the 6 explicit `any` types in GheCopilotExecutor
  (transformRequest, refreshCredentials) with proper ProviderCredentials /
  unknown / ExecutorLog types, and drop the config/quality/eslint-suppressions.json
  allowlist entry added for them — policy requires new violations be fixed,
  not frozen.
- Extract refreshViaGitHubToken() and buildRefreshedProviderSpecificData()
  helpers out of refreshCredentials() to bring its cyclomatic complexity
  (21) back under the repo's ratchet threshold (15); behavior unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(oauth): unblock #7546 file-size gate for GHE Copilot OAuth provider

Extracts the GHE enterprise-URL config step from OAuthModal.tsx into a
new leaf component (src/shared/components/oauthModal/GheConfigStep.tsx)
to shrink the frozen file's own growth, and rebaselines the two
remaining irreducible wiring bumps (device-code route.ts 960->963,
OAuthModal.tsx 1030->1056) with justification comments, mirroring the
existing #7399/#6636 precedent on this same file.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(ghe-copilot): drop planning spec from docs/ and revert out-of-scope eslint bump

Planning artifacts live outside the repo tree; package.json/lock restored to the
release state (the eslint patch bump was unrelated drift from the fork's history).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(oauth): validate gheUrl (HTTPS-only) at both raw entry points of the device-code flow

Applies the PR's existing providerSpecificData HTTPS rule to the OAuth route's
searchParams and device-flow extraData entry points, rejecting malformed or
non-HTTPS enterprise URLs with 400 before any upstream fetch. Private-IP hosts
stay allowed by design — on-prem GHE Server is the primary use case.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): extend oauth route file-size note for the gheUrl validation guards (963->970)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Alexander Helm <alexander.helm@deutschebahn.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
Co-authored-by: hppsc1215 <hppsc1215@users.noreply.github.com>
2026-07-19 21:22:37 -03:00

126 lines
4.7 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { GheCopilotExecutor } from "../../open-sse/executors/ghe-copilot.ts";
import { GHE_COPILOT_TARGET } from "../../src/mitm/targets/ghe-copilot.ts";
import type { ProviderCredentials } from "../../open-sse/executors/base.ts";
test("GHE_COPILOT_TARGET has correct id and patterns", () => {
assert.strictEqual(GHE_COPILOT_TARGET.id, "ghe-copilot");
assert.deepStrictEqual(GHE_COPILOT_TARGET.endpointPatterns, [
"/chat/completions",
"/v1/chat/completions",
"/responses",
]);
});
test("buildUrl uses gheUrl for chat/completions with credentials", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
const url = executor.buildUrl("gpt-4o", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl uses gheUrl for responses endpoint with codex model", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
const url = executor.buildUrl("gpt-4o-codex", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl handles gheUrl with trailing slash", () => {
const exec = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com/",
clientId: "test",
clientSecret: "test",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com/" },
};
const url = exec.buildUrl("gpt-4o", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl handles gheUrl already containing /chat/completions", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com/chat/completions" },
};
const url = executor.buildUrl("gpt-4o", true, 0, credentials);
assert.strictEqual(url, "https://ghe.company.com/chat/completions");
});
test("buildUrl throws without gheUrl in credentials", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = { providerSpecificData: {} };
assert.throws(() => executor.buildUrl("gpt-4o", true, 0, credentials), {
message: "GHE Copilot executor requires gheUrl in providerSpecificData",
});
});
test("refreshCopilotToken delegates to GHE token endpoint", async () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
copilotToken: "test-token",
};
const result = await executor.refreshCopilotToken("github-access-token", undefined, credentials);
assert.strictEqual(result, null);
});
test("refreshGitHubToken delegates to GHE OAuth endpoint", async () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
const credentials: ProviderCredentials = {
providerSpecificData: { gheUrl: "https://ghe.company.com" },
};
const result = await executor.refreshGitHubToken("refresh-token", undefined, credentials);
assert.strictEqual(result, null);
});
test("executor extends GithubExecutor", () => {
const executor = new GheCopilotExecutor({
gheUrl: "https://ghe.company.com",
clientId: "test-client",
clientSecret: "test-secret",
});
assert.strictEqual(executor.constructor.name, "GheCopilotExecutor");
});
test("isValidGheUrl accepts https enterprise hosts and rejects malformed or non-https input", async () => {
const { isValidGheUrl } = await import("../../src/shared/validation/providerSpecificData.ts");
assert.equal(isValidGheUrl("https://github.mycorp.example"), true);
assert.equal(isValidGheUrl("https://10.0.0.5"), true); // on-prem GHE on private IP is the primary use case
assert.equal(isValidGheUrl("http://github.mycorp.example"), false);
assert.equal(isValidGheUrl("javascript:alert(1)"), false);
assert.equal(isValidGheUrl("not a url"), false);
});