headroom/tests/test_provider_copilot_vscode_config.py
Tejas Chopra b77d612913
fix(copilot): send VS Code inline completions to the host that serves them (#3112)
## 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/<engine>/completions` (`@vscode/copilot-api`
0.5.2). Probed unauthenticated against the live hosts:

| host | `POST /v1/engines/<e>/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 subscription 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/<engine>/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 <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:21:22 -07:00

109 lines
4.1 KiB
Python

from __future__ import annotations
from pathlib import Path
import click
import pytest
from headroom.providers.copilot.vscode import (
configure_vscode_proxy_settings,
remove_vscode_proxy_settings,
vscode_proxy_url,
vscode_settings_path,
)
@pytest.mark.parametrize(
("platform", "env", "expected"),
[
(
"darwin",
{"HOME": "/Users/a"},
"/Users/a/Library/Application Support/Code/User/settings.json",
),
(
"win32",
{"APPDATA": "C:/Users/A/AppData/Roaming"},
"C:/Users/A/AppData/Roaming/Code/User/settings.json",
),
("linux", {"HOME": "/home/a"}, "/home/a/.config/Code/User/settings.json"),
("linux", {"HOME": "/home/a", "XDG_CONFIG_HOME": "/cfg"}, "/cfg/Code/User/settings.json"),
],
)
def test_vscode_settings_path_is_cross_platform(
platform: str, env: dict[str, str], expected: str
) -> None:
assert str(vscode_settings_path(platform=platform, environ=env)).replace("\\", "/") == expected
def test_proxy_url_carries_project_without_changing_model() -> None:
assert vscode_proxy_url(8787, "my project") == "http://127.0.0.1:8787/p/my%20project"
def test_configure_update_and_remove_preserve_jsonc_verbatim(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
original = '{\n\t// user comment\n\t"editor.fontSize": 15,\n}\n'
path.write_text(original, encoding="utf-8")
assert configure_vscode_proxy_settings(path, "http://127.0.0.1:8787") == "added"
configured = path.read_text(encoding="utf-8")
assert "editor.fontSize" in configured
assert "user comment" in configured
assert '"github.copilot.advanced.debug.overrideProxyUrl"' in configured
assert '"github.copilot.advanced.debug.overrideCapiUrl"' 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")
assert "8787" not in path.read_text(encoding="utf-8")
assert remove_vscode_proxy_settings(path) is True
assert path.read_text(encoding="utf-8") == original
assert remove_vscode_proxy_settings(path) is False
def test_configure_refuses_malformed_and_unmanaged_override(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
for original in (
"{broken",
'{"github.copilot.advanced.debug.overrideProxyUrl":"http://other"}',
'{"github.copilot.advanced.debug.overrideCapiUrl":"http://other"}',
):
path.write_text(original, encoding="utf-8")
with pytest.raises(click.ClickException, match="did not overwrite|refusing"):
configure_vscode_proxy_settings(path, "http://127.0.0.1:8787")
assert path.read_text(encoding="utf-8") == original
@pytest.mark.parametrize("prefix", [b"", b"\xef\xbb\xbf"])
def test_configure_and_remove_preserve_windows_line_endings_and_bom(
tmp_path: Path, prefix: bytes
) -> None:
path = tmp_path / "settings.json"
original = prefix + b'{\r\n\t"editor.fontSize": 15\r\n}\r\n'
path.write_bytes(original)
configure_vscode_proxy_settings(path, "http://127.0.0.1:8787")
assert path.read_bytes().startswith(prefix + b"{\r\n")
assert remove_vscode_proxy_settings(path) is True
assert path.read_bytes() == original
def test_configure_refuses_duplicate_managed_markers(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
original = (
"{\n"
"// --- Headroom Copilot proxy ---\n"
"// --- end Headroom Copilot proxy ---\n"
"// --- Headroom Copilot proxy ---\n"
"// --- end Headroom Copilot proxy ---\n"
"}\n"
)
path.write_text(original, encoding="utf-8")
with pytest.raises(click.ClickException, match="marker block"):
configure_vscode_proxy_settings(path, "http://127.0.0.1:8787")
assert path.read_text(encoding="utf-8") == original