From b77d61291399976985f12adcd6014aba2f0275cf Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Tue, 18 Aug 2026 15:21:22 -0700 Subject: [PATCH] fix(copilot): send VS Code inline completions to the host that serves them (#3112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description #3077 stopped Copilot's inline completions being forwarded to `api.openai.com` (the corporate-blocked host in the original report) — but sent them to the **CAPI host**, which does not serve that endpoint. Copilot has two surfaces on two different hosts, and GitHub's own client library keeps them apart: ```js _getCAPIUrl(t) -> t?.endpoints.api || "https://api.githubcopilot.com" _getProxyUrl(t) -> t?.endpoints.proxy || DEFAULT_PROXY_BASE_URL DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com" ``` building completions as `${proxyBaseURL}/v1/engines//completions` (`@vscode/copilot-api` 0.5.2). Probed unauthenticated against the live hosts: | host | `POST /v1/engines//completions` | |---|---| | `copilot-proxy.githubusercontent.com` | **401** — exists, needs auth | | `proxy.individual.githubcopilot.com` | **401** — CNAME to the above | | `api.githubcopilot.com` | **404** — does not serve this path | So the destination #3077 chose could not have worked. Three separate defects were in the way, each sufficient on its own to keep completions broken. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `copilot_auth.py`: added `DEFAULT_COMPLETIONS_PROXY_URL` and made it the default in `copilot_completions_base_url()`, replacing the CAPI host. - `copilot_auth.py`: the "custom deployment keeps its own host" rule now excludes public Copilot hosts. Without this, `headroom wrap vscode` — the common setup, and the one that exports `GITHUB_COPILOT_API_URL=` — resolved straight back to the 404 host. **This was a bug in my own first cut of the fix, found by testing the real `wrap vscode` environment rather than just the routing table.** - `copilot_auth.py`: added `is_copilot_completions_host()` and `is_copilot_upstream_url()` (chat ∪ completions). The completions host was recognised as Copilot **nowhere**, so `apply_copilot_api_auth` attached no credentials (401 — routing correctly to a host we then failed to authenticate against) and `build_copilot_upstream_url` skipped `mark_request_routed_to_copilot()`, mislabelling the provider in telemetry. - The union is applied at exactly those two call sites. `is_copilot_api_url` is left alone, so validation of a token payload's `endpoints.api` and the Responses-API preference check keep their strict chat-only meaning. All six call sites were read before choosing this. - `proxy_targets.py`: the "already a Copilot host" guard now keys on the *completions* host. A CAPI host is not a completions host, so it must still be redirected; a genuine per-SKU completions host or operator override is still left untouched. - `providers/copilot/vscode.py`, `cli/wrap.py`, `docs/…/vscode-copilot.mdx`: stop writing/printing `github.copilot.advanced.debug.overrideAuthType`. No such setting exists in the modern Copilot Chat extension — the only one left after `GitHub.copilot` was deprecated in early 2026. Its full `advanced.*` surface is `authPermissions`, `authProvider`, `debug.overrideCapiUrl`, `debug.overrideProxyUrl`, `debug.use*Fetcher`. It is still *recognised* so a stale hand-written copy is detected, just never emitted. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text tests/test_copilot_vscode_completions_routing.py 59 passed Copilot-related suites 293 passed, 8 skipped Full suite: 3 failed, 11250 passed, 581 skipped in 342.50s ``` The 3 failures are pre-existing and environmental, identical to a plain-`main` baseline on this machine: no `cargo` (`test_no_native_tls_in_wheel_build_tree`), no `codex` CLI (`test_learn/test_integration.py`), and `test_run_server_installs_cancelled_error_filter`, which fails under full-suite ordering on `main` too. ## Real Behavior Proof - Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off `main` @ `139c7cbd`, `HEADROOM_SKIP_UPSTREAM_CHECK=1` - Exact command / steps: (1) composed the real request path — `select_passthrough_base_url(proxy, headers, path)` → `build_copilot_upstream_url` → `apply_copilot_api_auth` — across 7 deployment shapes (no config, `wrap vscode`, advertised `endpoints.proxy`, operator override, GHE `.ghe.com`, GHE custom domain, target already a completions host); (2) probed the three candidate hosts unauthenticated with `curl -X POST /v1/engines/gpt-4o-copilot/completions`; (3) round-tripped `settings.json` through empty / one-setting / comments+array / CRLF shapes asserting valid JSON, idempotency and clean removal. - Observed result: before — `api.githubcopilot.com/...` (404 host), and with `GITHUB_COPILOT_API_URL` set as `wrap vscode` sets it, `api.business.githubcopilot.com` (also 404); no `Authorization` header on the completions host. After — `copilot-proxy.githubusercontent.com/v1/engines/gpt-41-copilot/completions` with credentials attached in every public-Copilot shape, `endpoints.proxy` and the operator override still winning, and a GHE tenant staying on its own host. `settings.json` stays valid JSON in all four shapes with the dead key gone; the two `restored=False` cases are pre-existing whitespace/CRLF normalisation, identical on `main`. Reverting the source fails 14 of the new tests, including the credential test on the completions host. - Not tested: no live VS Code session and no authenticated completion — the 401 proves the endpoint exists, not that GitHub accepts our forwarded request, which needs a real Copilot token. Confirmation from @rganesh-msys is still wanted. **Enterprise remains unresolved by default**: a GHE tenant stays on its own CAPI host, which is likely still the wrong surface for completions, but staying in-tenant beats forwarding keystrokes to a public GitHub host — `GITHUB_COPILOT_PROXY_URL` is the exact fix and now takes precedence over everything. ## Runtime Rollout Safety - Rollout-managed feature(s): None — no rollout channel gates this. - Minimum rollout channel: n/a - Stable/default behavior changed: Yes, and deliberately — the completions destination moves from a host that answers 404 to the one GitHub's own client defaults to. Only `/v1/engines//completions` is affected; every other path keeps its upstream, pinned by tests. Copilot credentials now also reach the completions host, which is the point. - Kill switch / disable path: `GITHUB_COPILOT_PROXY_URL` pins the destination explicitly and beats all inference. - Unsafe override required: No. - Qualification impact: None. - Rollback path: Revert this commit; completions return to the CAPI host (404) and the settings block regains the inert `overrideAuthType`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes Two things found while reading the extension source, **not changed here**: 1. `advanced.debug.overrideProxyUrl` is **not** deprecated — the report that Copilot 0.60.0 stopped honouring it does not hold. The current canonical key is `github.copilot.internal.completionsUrl`, and `advanced.debug.overrideProxyUrl` is checked as its explicit legacy fallback (`getEndpointOverrideUrl` in `completions-core/lib/src/networkConfiguration.ts`), so what we write still works. Worth migrating to the `internal.*` keys eventually, since they take precedence. 2. `endpoints.proxy` is still only recorded during a token exchange, which is opt-in via `GITHUB_COPILOT_USE_TOKEN_EXCHANGE`, and the base URL is chosen before auth runs. With the default now correct this is a refinement for per-SKU hosts rather than a correctness requirement, so it is left as-is. Closes #3076 --------- Co-authored-by: Tejas Chopra Co-authored-by: Claude Opus 5 --- docs/content/docs/vscode-copilot.mdx | 4 +- e2e/wrap/run.py | 5 +- headroom/cli/wrap.py | 3 +- headroom/copilot_auth.py | 99 +++++- headroom/providers/copilot/vscode.py | 12 +- headroom/providers/proxy_targets.py | 15 +- tests/test_cli/test_wrap_vscode.py | 5 +- ...test_copilot_vscode_completions_routing.py | 299 +++++++++++++++++- tests/test_provider_copilot_vscode_config.py | 5 +- 9 files changed, 417 insertions(+), 30 deletions(-) diff --git a/docs/content/docs/vscode-copilot.mdx b/docs/content/docs/vscode-copilot.mdx index a552a946d..003224905 100644 --- a/docs/content/docs/vscode-copilot.mdx +++ b/docs/content/docs/vscode-copilot.mdx @@ -45,8 +45,8 @@ The command: 1. validates Copilot subscription access and resolves the account API endpoint; 2. starts Headroom on `127.0.0.1:8787` with the short-lived upstream token; 3. adds a marker-owned block to VS Code user settings containing - `github.copilot.advanced.debug.overrideProxyUrl` and - `github.copilot.advanced.debug.overrideAuthType`; + `github.copilot.advanced.debug.overrideProxyUrl` (inline completions) and + `github.copilot.advanced.debug.overrideCapiUrl` (chat); 4. keeps running until `Ctrl+C` so the local proxy is available to VS Code. Continue using Copilot's normal model picker. The request body—and therefore the diff --git a/e2e/wrap/run.py b/e2e/wrap/run.py index c28a8b1a9..1a6b613c7 100644 --- a/e2e/wrap/run.py +++ b/e2e/wrap/run.py @@ -796,8 +796,9 @@ def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None: "VS Code wrap should route Copilot Chat generation through Headroom", ) assert_true( - '"github.copilot.advanced.debug.overrideAuthType": "token"' in configured, - "VS Code wrap should configure token auth", + "overrideAuthType" not in configured, + "VS Code wrap must not write overrideAuthType: no such setting exists in " + "the modern Copilot Chat extension, so VS Code flags it as unknown (#3076)", ) assert_true( "synthetic-e2e-token" not in configured, "Settings must not contain credentials" diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 2d1f7d52e..03be77c4c 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -5543,9 +5543,8 @@ def vscode_copilot( f' "github.copilot.advanced.debug.overrideProxyUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",' ) click.echo( - f' "github.copilot.advanced.debug.overrideCapiUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",' + f' "github.copilot.advanced.debug.overrideCapiUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}"' ) - click.echo(' "github.copilot.advanced.debug.overrideAuthType": "token"') _run_proxy_only_watcher( agent_label="VS CODE COPILOT", diff --git a/headroom/copilot_auth.py b/headroom/copilot_auth.py index 701c2cc95..6ac8cd596 100644 --- a/headroom/copilot_auth.py +++ b/headroom/copilot_auth.py @@ -28,6 +28,17 @@ from headroom.copilot_macos_keychain import read_copilot_oauth_token as read_mac logger = logging.getLogger(__name__) DEFAULT_API_URL = "https://api.githubcopilot.com" +# Copilot serves *chat* from the CAPI host above and *inline completions* from a +# separate proxy host. GitHub's own client library keeps them apart: +# +# _getCAPIUrl(t) -> t?.endpoints.api || "https://api.githubcopilot.com" +# _getProxyUrl(t) -> t?.endpoints.proxy || DEFAULT_PROXY_BASE_URL +# DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com" +# +# and builds completions as `${proxyBaseURL}/v1/engines//completions` +# (@vscode/copilot-api 0.5.2). Sending that path to the CAPI host is the wrong +# surface, so the completions default has to be its own constant (#3076). +DEFAULT_COMPLETIONS_PROXY_URL = "https://copilot-proxy.githubusercontent.com" DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token" DEFAULT_USER_INFO_URL = "https://api.github.com/copilot_internal/user" DEFAULT_GITHUB_HOST = "github.com" @@ -280,6 +291,47 @@ def reset_observed_completions_endpoint() -> None: _observed_completions_base_url = None +def _url_host(value: str) -> str: + """Hostname for a URL, tolerating a scheme-less value. + + Mirrors the normalization :func:`is_copilot_api_url` performs, so a host + configured without "https://" is not silently treated as a different host. + """ + + parsed = urlparse(value) + netloc_or_path = parsed.netloc.lower() or parsed.path.lower() + return (parsed.hostname or netloc_or_path.split("/", 1)[0]).lower() + + +def is_copilot_completions_host(url: str | None) -> bool: + """Return True when *url* already points at a Copilot inline-completions host. + + Distinct from :func:`is_copilot_api_url`, which matches the CAPI (chat) + surface. A CAPI host is *not* a completions host, so the two must not be + conflated when deciding whether a completions request is already addressed + correctly. + """ + + if not url: + return False + # Compare hosts, never whole strings: this is asked both about a bare base + # URL (routing) and about a fully-built URL with the path appended (auth). + # A string compare answers True for the first and False for the second, so + # an operator override would route correctly and then be forwarded with no + # credentials at all. + host = _url_host(url) + if not host: + return False + override = os.environ.get("GITHUB_COPILOT_PROXY_URL", "").strip() + if override and host == _url_host(override): + return True + if host == "copilot-proxy.githubusercontent.com": + return True + # Per-SKU hosts GitHub hands out via `endpoints.proxy`, e.g. + # proxy.individual.githubcopilot.com / proxy.business… / proxy.enterprise…. + return host.startswith("proxy.") and host.endswith(".githubcopilot.com") + + def copilot_completions_base_url() -> str: """Return the base URL serving Copilot's inline-completions endpoint. @@ -290,8 +342,19 @@ def copilot_completions_base_url() -> str: to this endpoint) is a config edit rather than a code change. 2. ``endpoints.proxy`` from the last Copilot token exchange — GitHub telling us directly where completions go. - 3. The Copilot API URL, which is where GitHub's consolidated surface - serves them. + 3. ``copilot-proxy.githubusercontent.com`` — GitHub's own documented + default for this endpoint (see ``DEFAULT_COMPLETIONS_PROXY_URL``). + 4. For an enterprise or otherwise custom Copilot deployment, that + deployment's own host. Falling back to the public GitHub host there would + send an enterprise tenant's keystrokes outside their deployment, which is + worse than failing to resolve. + + Note what step 4 must *not* capture: a configured API URL that is itself a + public Copilot host. ``headroom wrap vscode`` sets ``GITHUB_COPILOT_API_URL`` + to the resolved subscription URL (e.g. ``api.business.githubcopilot.com``), + which is the chat surface — returning it here would put the completions path + straight back on the host that answers it with 404. Only a host outside + ``*.githubcopilot.com`` indicates a deployment whose traffic has to stay put. Never performs I/O; step 2 only reads what a previous exchange recorded. """ @@ -301,7 +364,10 @@ def copilot_completions_base_url() -> str: return override.rstrip("/") if _observed_completions_base_url: return _observed_completions_base_url - return copilot_api_url() + configured = _configured_api_url_override() + if configured and not _is_public_copilot_api_host(_url_host(configured)): + return configured + return DEFAULT_COMPLETIONS_PROXY_URL def _github_oauth_domain(domain: str | None = None) -> str: @@ -1052,6 +1118,25 @@ def is_copilot_api_url(url: str | None) -> bool: return _is_public_copilot_api_host(hostname) or _is_ghe_copilot_api_host(hostname) +def is_copilot_upstream_url(url: str | None) -> bool: + """Return True for any Copilot-served upstream: chat (CAPI) or completions. + + Copilot has two surfaces on two different hosts, and code that asks "is this + request going to Copilot?" means the union. :func:`is_copilot_api_url` alone + answers only for chat, so the completions host looked like a stranger: + ``apply_copilot_api_auth`` attached no credentials to it (401) and + ``build_copilot_upstream_url`` skipped ``mark_request_routed_to_copilot``, + which mislabels the provider in telemetry. + + Deliberately *not* folded into :func:`is_copilot_api_url`, which also gates + validation of the ``endpoints.api`` value from a token exchange and the + Responses-API preference check — neither of which should treat a completions + host as a chat host (#3076). + """ + + return is_copilot_api_url(url) or is_copilot_completions_host(url) + + def _is_public_copilot_api_host(host: str) -> bool: """Return True for GitHub-hosted Copilot API domains.""" @@ -1143,7 +1228,7 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str: normalized_base = base_url.rstrip("/") normalized_path = path if path.startswith("/") else f"/{path}" - if is_copilot_api_url(normalized_base): + if is_copilot_upstream_url(normalized_base): # Single routing chokepoint for every Copilot surface (OpenAI # chat/responses and Anthropic messages all build their upstream URL # here), so mark the request for provider relabeling downstream. @@ -1411,7 +1496,11 @@ def _is_managed_copilot_seeded_bearer(token: str) -> bool: async def apply_copilot_api_auth(headers: dict[str, str], *, url: str) -> dict[str, str]: """Apply Copilot auth headers for GitHub Copilot API requests.""" resolved = dict(headers) - if not is_copilot_api_url(url): + # Both Copilot surfaces need credentials. Gating on the chat host alone left + # inline completions unauthenticated: the request reached + # copilot-proxy.githubusercontent.com with no Authorization header, and that + # host answers 401 (#3076). + if not is_copilot_upstream_url(url): return resolved for name, value in _copilot_chat_header_defaults().items(): diff --git a/headroom/providers/copilot/vscode.py b/headroom/providers/copilot/vscode.py index 354a4efe5..28fa8418b 100644 --- a/headroom/providers/copilot/vscode.py +++ b/headroom/providers/copilot/vscode.py @@ -18,6 +18,15 @@ _MARKER_START = "// --- Headroom Copilot proxy ---" _MARKER_END = "// --- end Headroom Copilot proxy ---" _PROXY_KEY = "github.copilot.advanced.debug.overrideProxyUrl" _CAPI_KEY = "github.copilot.advanced.debug.overrideCapiUrl" +# Written by Headroom until #3076: it no longer exists. The modern Copilot Chat +# extension — the only one left after `GitHub.copilot` was deprecated in early +# 2026 — defines no `authType` setting in either its own configuration +# (`advanced.authPermissions`, `advanced.authProvider`, +# `advanced.debug.overrideCapiUrl`, `advanced.debug.overrideProxyUrl`, +# `advanced.debug.use*Fetcher`) or in the completions code merged into it. Still +# recognised below so a stale hand-written copy is detected, but never emitted: +# VS Code flags unknown keys, and shipping one that does nothing invited the +# conclusion that the override mechanism had stopped working. _AUTH_KEY = "github.copilot.advanced.debug.overrideAuthType" @@ -119,8 +128,7 @@ def _managed_block(proxy_url: str, *, owns_preceding_comma: bool, line_sep: str) return ( f"\t{marker}{line_sep}" f"\t{json.dumps(_PROXY_KEY)}: {json.dumps(proxy_url)},{line_sep}" - f"\t{json.dumps(_CAPI_KEY)}: {json.dumps(proxy_url)},{line_sep}" - f'\t{json.dumps(_AUTH_KEY)}: "token"{line_sep}' + f"\t{json.dumps(_CAPI_KEY)}: {json.dumps(proxy_url)}{line_sep}" f"\t{_MARKER_END}" ) diff --git a/headroom/providers/proxy_targets.py b/headroom/providers/proxy_targets.py index db8d99e71..86b2c5447 100644 --- a/headroom/providers/proxy_targets.py +++ b/headroom/providers/proxy_targets.py @@ -7,7 +7,7 @@ from typing import Any, cast from headroom.copilot_auth import ( copilot_completions_base_url, - is_copilot_api_url, + is_copilot_completions_host, is_copilot_completions_path, ) from headroom.providers.codex import resolve_codex_routing @@ -53,7 +53,7 @@ def select_passthrough_base_url( path is not None and provider_name == "openai" and is_copilot_completions_path(path) - and not is_copilot_api_url(target) + and not is_copilot_completions_host(target) ): # Copilot's inline completions arrive here because # `/v1/engines//completions` matches no built-in route. Nothing @@ -69,10 +69,13 @@ def select_passthrough_base_url( # GitHub's own answer where we have it rather than a hardcoded guess, # and GHE deployments keep their host. # - # When the target is already a Copilot host — `headroom wrap vscode` - # points the OpenAI target at the resolved subscription URL — it is left - # alone, so an account-specific host is never overwritten with the - # generic one. + # The guard is on the *completions* host, not "any Copilot host". A CAPI + # host is not a completions host: `headroom wrap vscode` points the + # OpenAI target at the resolved subscription URL, which is the chat + # surface (`GITHUB_COPILOT_API_URL`), and leaving that alone sent + # `/v1/engines/.../completions` to a host that does not serve it. An + # already-correct completions host — an operator override or a per-SKU + # `endpoints.proxy` value — is still left untouched. # # Scoped to the OpenAI fall-through, which is the branch that is wrong # for this path. Every other branch above reflects a deliberate choice diff --git a/tests/test_cli/test_wrap_vscode.py b/tests/test_cli/test_wrap_vscode.py index fccab5cac..01a7daa7b 100644 --- a/tests/test_cli/test_wrap_vscode.py +++ b/tests/test_cli/test_wrap_vscode.py @@ -67,7 +67,10 @@ def test_wrap_vscode_no_configure_prints_transparent_settings(tmp_path: Path) -> assert not path.exists() assert "overrideProxyUrl" in result.output assert "overrideCapiUrl" in result.output - assert "overrideAuthType" in result.output + # No `overrideAuthType`: the setting does not exist in the modern Copilot + # Chat extension, so printing it told users to add a key VS Code flags as + # unknown and which does nothing (#3076). + assert "overrideAuthType" not in result.output def test_unwrap_vscode_removes_only_managed_settings(tmp_path: Path) -> None: diff --git a/tests/test_copilot_vscode_completions_routing.py b/tests/test_copilot_vscode_completions_routing.py index c02ca0fe1..de8fa452e 100644 --- a/tests/test_copilot_vscode_completions_routing.py +++ b/tests/test_copilot_vscode_completions_routing.py @@ -15,6 +15,8 @@ extension already built the exact path Copilot serves. from __future__ import annotations +import asyncio + import pytest from headroom import copilot_auth @@ -27,6 +29,13 @@ from headroom.copilot_auth import ( from headroom.providers.proxy_targets import select_passthrough_base_url COPILOT_API = "https://api.githubcopilot.com" +# GitHub serves inline completions from a *different* host than chat. Verified +# unauthenticated against the live endpoints: +# POST copilot-proxy.githubusercontent.com/v1/engines//completions -> 401 +# POST api.githubcopilot.com/v1/engines//completions -> 404 +# and proxy..githubcopilot.com is a CNAME to the former. 401 means "exists, +# needs auth"; 404 means the CAPI host does not serve this path at all (#3076). +COMPLETIONS_PROXY = "https://copilot-proxy.githubusercontent.com" COMPLETIONS = "/v1/engines/gpt-41-copilot/completions" @@ -100,20 +109,36 @@ def test_completions_do_not_fall_through_to_the_openai_target() -> None: """The reported bug: keystrokes forwarded to api.openai.com.""" proxy = _proxy(OPENAI_API_URL="https://api.openai.com") - assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == COPILOT_API + # ...and they must land on the completions host, not the chat host, which + # answers this path with 404. + assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == COMPLETIONS_PROXY -def test_an_account_specific_copilot_host_is_left_alone() -> None: - """`headroom wrap vscode` points the OpenAI target at the resolved host. +def test_a_chat_host_is_not_treated_as_a_completions_host() -> None: + """A CAPI host must still be redirected, because it does not serve this path. - That URL is account-specific (individual/business/enterprise), so replacing - it with the generic one would route a subscriber to the wrong tenant. + `headroom wrap vscode` points the OpenAI target at the resolved subscription + URL, which is the *chat* surface (it is what `GITHUB_COPILOT_API_URL` is set + to). Leaving it alone — as an "it's already a Copilot host" guard did — sent + `/v1/engines/.../completions` to a host that answers 404. """ proxy = _proxy(OPENAI_API_URL="https://api.business.githubcopilot.com") + assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == COMPLETIONS_PROXY + + +def test_an_account_specific_completions_host_is_left_alone() -> None: + """A host that already serves completions is never rewritten. + + These are the per-SKU hosts GitHub hands out through `endpoints.proxy`, so + replacing one with the generic default would move a subscriber off the host + their own token named. + """ + proxy = _proxy(OPENAI_API_URL="https://proxy.business.githubcopilot.com") + assert ( select_passthrough_base_url(proxy, {}, COMPLETIONS) - == "https://api.business.githubcopilot.com" + == "https://proxy.business.githubcopilot.com" ) @@ -173,8 +198,14 @@ def test_explicit_provider_auth_is_never_hijacked() -> None: # --------------------------------------------------------------------------- # # Where completions are sent # --------------------------------------------------------------------------- # -def test_completions_host_defaults_to_the_copilot_api() -> None: - assert copilot_completions_base_url() == COPILOT_API +def test_completions_host_defaults_to_githubs_completions_proxy() -> None: + """The default is GitHub's own default for this endpoint, not the CAPI host. + + `@vscode/copilot-api` resolves it as + ``token?.endpoints.proxy || DEFAULT_PROXY_BASE_URL`` where + ``DEFAULT_PROXY_BASE_URL = "https://copilot-proxy.githubusercontent.com"``. + """ + assert copilot_completions_base_url() == COMPLETIONS_PROXY def test_github_advertised_completions_host_wins_over_the_default() -> None: @@ -223,7 +254,7 @@ def test_an_operator_override_beats_everything() -> None: def test_a_payload_without_a_usable_proxy_host_changes_nothing(payload) -> None: # type: ignore[no-untyped-def] copilot_auth._remember_completions_endpoint(payload) - assert copilot_completions_base_url() == COPILOT_API + assert copilot_completions_base_url() == COMPLETIONS_PROXY def test_the_advertised_host_is_used_for_routing() -> None: @@ -265,3 +296,253 @@ def test_a_non_copilot_upstream_is_never_rewritten() -> None: build_copilot_upstream_url("https://api.openai.com", COMPLETIONS) == f"https://api.openai.com{COMPLETIONS}" ) + + +# --------------------------------------------------------------------------- # +# Telling the two Copilot surfaces apart +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "url", + [ + "https://copilot-proxy.githubusercontent.com", + "https://copilot-proxy.githubusercontent.com/", + # Scheme-less, as a hand-written config value can be. Failing to + # recognise it means forwarding with no credentials. + "copilot-proxy.githubusercontent.com", + "proxy.individual.githubcopilot.com/v1/engines/x/completions", + "https://proxy.individual.githubcopilot.com", + "https://proxy.business.githubcopilot.com", + "https://proxy.enterprise.githubcopilot.com", + ], +) +def test_completions_hosts_are_recognised(url: str) -> None: + assert copilot_auth.is_copilot_completions_host(url) is True + + +@pytest.mark.parametrize( + "url", + [ + None, + "", + # The chat surface. Recognising it as a completions host is the bug this + # function exists to prevent: it answers this path with 404. + "https://api.githubcopilot.com", + "https://api.business.githubcopilot.com", + "https://copilot-api.acme.ghe.com", + "https://api.openai.com", + # Not a Copilot host merely because "proxy" appears somewhere. + "https://proxy.example.com", + "https://notproxy.githubcopilot.com", + ], +) +def test_non_completions_hosts_are_rejected(url) -> None: # type: ignore[no-untyped-def] + assert copilot_auth.is_copilot_completions_host(url) is False + + +def test_an_operator_override_counts_as_a_completions_host( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Otherwise the redirect would fight the operator's own configuration.""" + monkeypatch.setenv("GITHUB_COPILOT_PROXY_URL", "https://copilot.internal.acme/") + + assert copilot_auth.is_copilot_completions_host("https://copilot.internal.acme") is True + proxy = _proxy(OPENAI_API_URL="https://copilot.internal.acme") + assert select_passthrough_base_url(proxy, {}, COMPLETIONS) == "https://copilot.internal.acme" + + +# --------------------------------------------------------------------------- # +# An enterprise tenant's keystrokes must not leave their deployment +# --------------------------------------------------------------------------- # +def test_an_enterprise_deployment_is_never_sent_to_the_public_host( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The public default applies only when no custom deployment is configured. + + For a GHE tenant, defaulting to ``copilot-proxy.githubusercontent.com`` + would forward editor keystrokes to a host outside their deployment. Staying + on their own host may still be the wrong surface, but it keeps the traffic + inside the tenant; ``GITHUB_COPILOT_PROXY_URL`` is the exact fix. + """ + monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://copilot-api.github.acme.com") + + resolved = copilot_completions_base_url() + + assert resolved == "https://copilot-api.github.acme.com" + assert "githubusercontent.com" not in resolved + assert "githubcopilot.com" not in resolved + + +def test_the_advertised_host_still_wins_for_an_enterprise_deployment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """GitHub naming the host beats any inference from the configured API URL.""" + monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://copilot-api.github.acme.com") + copilot_auth._remember_completions_endpoint( + {"endpoints": {"proxy": "https://copilot-proxy.github.acme.com"}} + ) + + assert copilot_completions_base_url() == "https://copilot-proxy.github.acme.com" + + +# --------------------------------------------------------------------------- # +# Routing to the right host is only half of it: it needs credentials +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "url", + [ + "https://copilot-proxy.githubusercontent.com/v1/engines/gpt-41-copilot/completions", + "https://proxy.business.githubcopilot.com/v1/engines/gpt-41-copilot/completions", + # The chat surface must keep working exactly as before. + "https://api.githubcopilot.com/chat/completions", + ], +) +def test_a_copilot_upstream_is_authenticated(monkeypatch: pytest.MonkeyPatch, url: str) -> None: + """Both Copilot surfaces get credentials. + + Gating auth on the chat host alone routed completions to the correct host + with no Authorization header at all, which that host answers 401 — the fix + for the destination would have been inert without this. + """ + + class _Token: + token = "test-copilot-token" + + class _Provider: + async def get_api_token(self): # noqa: ANN202 + return _Token() + + monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider()) + + resolved = asyncio.run(copilot_auth.apply_copilot_api_auth({}, url=url)) + + assert resolved.get("Authorization") == "Bearer test-copilot-token" + + +def test_a_non_copilot_upstream_is_never_given_copilot_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The widened gate must not start handing Copilot tokens to other hosts.""" + + class _Provider: + async def get_api_token(self): # noqa: ANN202 + raise AssertionError("must not mint a Copilot token for a non-Copilot host") + + monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider()) + + for url in ( + "https://api.openai.com/v1/engines/x/completions", + "https://proxy.example.com/v1/engines/x/completions", + "https://evil.githubcopilot.com.attacker.test/v1/engines/x/completions", + ): + assert asyncio.run(copilot_auth.apply_copilot_api_auth({}, url=url)) == {} + + +def test_a_completions_host_is_marked_as_copilot_routed() -> None: + """`build_copilot_upstream_url` is the chokepoint that labels the provider.""" + url = copilot_auth.build_copilot_upstream_url( + "https://copilot-proxy.githubusercontent.com", COMPLETIONS + ) + + assert url == f"https://copilot-proxy.githubusercontent.com{COMPLETIONS}" + assert copilot_auth.is_copilot_upstream_url(url) is True + + +@pytest.mark.parametrize( + "configured_api_url", + [ + # What `headroom wrap vscode` actually exports (wrap.py sets + # GITHUB_COPILOT_API_URL to the resolved subscription URL). + "https://api.business.githubcopilot.com", + "https://api.individual.githubcopilot.com", + "https://api.githubcopilot.com", + ], +) +def test_a_public_capi_url_does_not_become_the_completions_host( + monkeypatch: pytest.MonkeyPatch, configured_api_url: str +) -> None: + """A configured *chat* URL must not drag completions back onto the 404 host. + + The in-tenant rule for a custom deployment has to exclude public Copilot + hosts, or the single most common setup — `headroom wrap vscode`, which + exports GITHUB_COPILOT_API_URL — lands right back where it started. + """ + monkeypatch.setenv("GITHUB_COPILOT_API_URL", configured_api_url) + + assert copilot_completions_base_url() == COMPLETIONS_PROXY + + +@pytest.mark.parametrize( + "configured_api_url", + [ + "https://copilot-api.acme.ghe.com", + "https://copilot-api.github.acme.com", + ], +) +def test_a_custom_deployment_still_keeps_its_own_host( + monkeypatch: pytest.MonkeyPatch, configured_api_url: str +) -> None: + """Only a host outside *.githubcopilot.com marks a deployment to stay put.""" + monkeypatch.setenv("GITHUB_COPILOT_API_URL", configured_api_url) + + assert copilot_completions_base_url() == configured_api_url + + +# --------------------------------------------------------------------------- # +# The two callers pass different shapes of URL +# --------------------------------------------------------------------------- # +def test_an_operator_override_is_matched_on_the_full_request_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Routing sees a base URL; auth sees the base URL *plus the path*. + + Matching the override by whole-string equality answered True for the first + and False for the second, so an operator gateway was routed to correctly and + then forwarded with no credentials — a 401 on the one configuration that is + the documented remedy for a custom deployment. + """ + monkeypatch.setenv("GITHUB_COPILOT_PROXY_URL", "https://gw.corp.internal") + + base = "https://gw.corp.internal" + full = f"https://gw.corp.internal{COMPLETIONS}" + + assert copilot_auth.is_copilot_completions_host(base) is True + assert copilot_auth.is_copilot_completions_host(full) is True + assert copilot_auth.is_copilot_completions_host("https://gw.corp.internal/") is True + assert copilot_auth.is_copilot_upstream_url(full) is True + # A different host is still not the override. + assert copilot_auth.is_copilot_completions_host(f"https://elsewhere.test{COMPLETIONS}") is False + + +def test_an_operator_override_gateway_receives_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """End of the same chain: the gateway must actually be authenticated.""" + + class _Token: + token = "test-copilot-token" + + class _Provider: + async def get_api_token(self): # noqa: ANN202 + return _Token() + + monkeypatch.setenv("GITHUB_COPILOT_PROXY_URL", "https://gw.corp.internal") + monkeypatch.setattr(copilot_auth, "get_copilot_token_provider", lambda: _Provider()) + + resolved = asyncio.run( + copilot_auth.apply_copilot_api_auth({}, url=f"https://gw.corp.internal{COMPLETIONS}") + ) + + assert resolved.get("Authorization") == "Bearer test-copilot-token" + + +@pytest.mark.parametrize( + "configured", + ["api.githubcopilot.com", "api.business.githubcopilot.com"], +) +def test_a_scheme_less_public_capi_url_is_still_recognised( + monkeypatch: pytest.MonkeyPatch, configured: str +) -> None: + """A hand-written value without "https://" must not read as a custom host.""" + monkeypatch.setenv("GITHUB_COPILOT_API_URL", configured) + + assert copilot_completions_base_url() == COMPLETIONS_PROXY diff --git a/tests/test_provider_copilot_vscode_config.py b/tests/test_provider_copilot_vscode_config.py index 992888e22..a90d30da1 100644 --- a/tests/test_provider_copilot_vscode_config.py +++ b/tests/test_provider_copilot_vscode_config.py @@ -51,7 +51,10 @@ def test_configure_update_and_remove_preserve_jsonc_verbatim(tmp_path: Path) -> assert "user comment" in configured assert '"github.copilot.advanced.debug.overrideProxyUrl"' in configured assert '"github.copilot.advanced.debug.overrideCapiUrl"' in configured - assert '"github.copilot.advanced.debug.overrideAuthType": "token"' in configured + # `overrideAuthType` is deliberately absent: no such setting exists in the + # modern Copilot Chat extension, and writing one made VS Code flag an + # unknown key while doing nothing (#3076). + assert "overrideAuthType" not in configured assert configure_vscode_proxy_settings(path, "http://127.0.0.1:9999") == "updated" assert "9999" in path.read_text(encoding="utf-8")