feat(copilot): proxy VS Code models transparently (#2687)

## Description

Make Headroom a transparent proxy for VS Code GitHub Copilot. Users keep
using Copilot's normal model picker—GPT-4.1, Claude Sonnet, Claude Opus,
and other models in their entitlement—while Headroom silently forwards
the selected model instead of registering or requiring a separate
"Headroom" model.

This also fixes GitHub's device OAuth exchange by sending form-encoded
request bodies, matching the endpoint contract.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Add `headroom wrap vscode` to start a Copilot-seeded subscription
proxy and safely configure VS Code's shipped Copilot proxy override.
- Add `headroom unwrap vscode` for reversible cleanup.
- Preserve VS Code's selected model by changing only the proxy URL/auth
override; no custom model is registered and no model preference is
written.
- Support stable VS Code settings locations on macOS, Windows, and
Linux, plus `--settings-file` for Insiders, portable, and other
installations.
- Edit JSONC settings with a marker-owned block while preserving
unrelated bytes, comments, ordering, and trailing commas.
- Refuse malformed markers, invalid JSONC, or unmanaged existing Copilot
overrides instead of overwriting user configuration.
- Fix SIGINT cleanup so the managed settings block is removed and normal
shutdown exits successfully.
- Fix Copilot device OAuth start/poll requests to use
`application/x-www-form-urlencoded`.
- Add a compatibility matrix, setup/removal flow, credential behavior,
remote-development guidance, enterprise notes, troubleshooting, and
verification documentation.

## Testing

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ .venv/bin/pytest -q tests/test_provider_copilot_vscode_config.py tests/test_cli/test_wrap_vscode.py tests/test_cli/test_wrap_helpers.py tests/test_copilot_auth.py tests/test_cli/test_copilot_auth.py tests/test_provider_copilot_wrap.py tests/test_cli/test_wrap_copilot.py tests/test_copilot_provider_label.py tests/test_copilot_subscription_smoke.py
244 passed in 0.59s

$ .venv/bin/ruff check <changed Python files and tests>
All checks passed!

$ .venv/bin/mypy headroom/providers/copilot/vscode.py
Success: no issues found in 1 source file

$ cd docs && npm run types:check
fumadocs-mdx && next typegen && tsc --noEmit
# exited 0

$ git diff --check
# exited 0
```

The full 10,179-test suite was also sampled through approximately 83%,
but was stopped because of its runtime. It exposed existing failures in
`test_recover_codex.py`, `test_wrap_stale_marker.py`, and
`test_proxy_health.py`; therefore the broad `pytest`, repository-wide
Ruff, and repository-wide mypy boxes are intentionally not checked.

## Real Behavior Proof

- Environment: macOS arm64, VS Code 1.131.0, built-in GitHub Copilot
0.59.0, Headroom 0.33.1-dev.
- Exact command / steps:
  1. Completed `headroom copilot login` with GitHub's device flow.
  2. Ran `.venv/bin/headroom wrap vscode --port 8788`.
3. Confirmed VS Code retained its ordinary Copilot model catalog and
made `GET /models` through Headroom with `GitHubCopilotChat/0.59.0` and
`editor-version: vscode/1.131.0`.
4. Sent native Copilot `/p/headroom/chat/completions` requests through
the same endpoint using `gpt-4.1`, `claude-sonnet-4.6`, and
`claude-opus-4.7`.
- Observed result:
  - All three completion requests returned HTTP 200.
- GPT-4.1 resolved upstream to `gpt-4.1-2025-04-14`; Sonnet and Opus
retained their exact selected IDs.
  - All returned the requested exact marker content.
- VS Code's settings contained only the Headroom proxy URL and token
auth override—no Headroom model or model-selection setting.
- The proxy health endpoint remained ready with `openai_api_url` set to
`https://api.githubcopilot.com`.
- Not tested:
- Physical Windows or Linux hosts (their path/config behavior is covered
by unit tests).
- WSL, dev containers, SSH remotes, VS Code Insiders, or enterprise
Copilot deployments end-to-end.
  - Every model in the live Copilot catalog.
- A fully submitted chat from VS Code's UI automation; the real
extension's catalog request and native completion paths were verified
separately.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing targeted unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

Not applicable; this integration intentionally has no separate UI or
model entry.

## Additional Notes

The integration uses VS Code Copilot's shipped advanced/debug proxy
endpoint seam. The managed settings block is deliberately narrow and
reversible. Remote extension hosts may need their own reachable
proxy/configuration as documented.

---------

Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com>
This commit is contained in:
JD Davis 2026-08-03 06:42:48 -05:00 committed by GitHub
parent 56b3e4c1b1
commit 007446c73a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 864 additions and 25 deletions

View file

@ -231,6 +231,7 @@ shows an **Output Tokens Saved** card next to input compression, labelled
| Cursor | Manual setup | starts proxy and prints base URLs for Cursor settings |
| Aider | ✅ | starts proxy + launches |
| Copilot CLI | ✅ | starts proxy + launches |
| VS Code Copilot | ✅ | transparent proxy; preserves selected model |
| OpenClaw | ✅ | installs as ContextEngine plugin |
| OpenCode | ✅ | injects config · starts proxy + launches |
| Cline | ✅ | starts proxy + injects config |
@ -282,6 +283,24 @@ API endpoint advertised for the signed-in account.
Platform support note: macOS auth reuse via Copilot CLI Keychain storage has been smoke-tested. Windows Credential Manager, Linux Secret Service / `secret-tool`, and Docker/CI token-injection paths are implemented or planned as auth-discovery paths, but still need real OS validation before they should be considered fully vetted. For Docker and CI, prefer passing an explicit `GITHUB_COPILOT_TOKEN` or `GITHUB_COPILOT_GITHUB_TOKEN` rather than relying on host keychain access.
### GitHub Copilot in Visual Studio Code
Headroom transparently overrides Copilot's API proxy endpoint, so the normal VS
Code model picker remains authoritative. GPT-5.5, GPT-5.6 Luna/Sol/Terra, Claude
Sonnet/Opus, and other Copilot models keep their original model IDs while traffic
passes through the local compression proxy. Headroom does not patch VS Code or
change Codex settings:
```bash
headroom copilot-auth login
headroom wrap vscode
```
Keep the command running and use Copilot normally. Headroom holds the short-lived
upstream Copilot token only in the proxy process.
See the [cross-platform VS Code Copilot guide](https://headroom-docs.vercel.app/docs/vscode-copilot)
for paths, credential flow, remote-development notes, undo steps, and troubleshooting.
## When to use · When to skip
**Great fit if you…**

View file

@ -37,6 +37,7 @@
"litellm",
"claude-code-vertex",
"claude-code-azure-foundry",
"vscode-copilot",
"opencode",
"grok-build",
"mcp",

View file

@ -0,0 +1,163 @@
---
title: VS Code Copilot Integration
description: Transparently route the model selected in GitHub Copilot through Headroom on macOS, Windows, and Linux.
---
Headroom integrates below VS Code's native Copilot model picker. It overrides the
Copilot API proxy endpoint, not the model: if the user selects GPT-5.5, a GPT-5.6
variant, Claude Sonnet, Claude Opus, or another Copilot model, the same model ID
travels through Headroom to GitHub's Copilot API.
No `Headroom` model appears in the picker. Headroom does not patch the built-in
extension, terminate TLS, or edit Codex configuration.
## Requirements
- Current stable VS Code with GitHub Copilot enabled and signed in
- A GitHub account with Copilot access
- Headroom with proxy dependencies: `pip install "headroom-ai[proxy]"`
- Loopback access to `127.0.0.1` from the VS Code extension host
## One-time authentication
VS Code keeps its Copilot token in extension secret storage. Headroom deliberately
does not read or modify that encrypted store, so authorize Headroom separately:
```bash
headroom copilot-auth login
```
Open the printed GitHub device URL, enter the code, and approve it. Headroom saves
the reusable OAuth credential in its own auth file with user-only permissions.
At launch it exchanges that credential for a short-lived Copilot API token; that
token remains in the proxy process and is never written to VS Code settings.
## Start
Run from the project whose savings should receive attribution:
```bash
headroom wrap vscode
```
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`;
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
selected model—is not rewritten by the VS Code integration.
## What is routed
The shipped Copilot extension resolves both its chat/agent endpoint and its
completions-core endpoint through the proxy override. This covers native model
selection without registering duplicate models. Some ancillary Copilot services
(telemetry, GitHub API calls, MCP, embeddings, model discovery, cloud agents) use
separate endpoints and are intentionally not redirected.
```text
Copilot UI: user selects model M
-> native Copilot request with model M
-> http://127.0.0.1:8787/p/<project>/<native Copilot path>
-> Headroom compression, cache alignment, metrics, attribution
-> authenticated GitHub Copilot API, still with model M
-> response from model M
```
## Safe settings lifecycle
Headroom edits only a marked block in VS Code's `settings.json`. Existing JSONC
comments, formatting, trailing commas, and unrelated settings remain byte-for-byte
unchanged. Headroom refuses malformed files, incomplete markers, or a pre-existing
unmanaged Copilot endpoint override instead of overwriting them.
| Platform | Stable VS Code user settings |
|---|---|
| macOS | `~/Library/Application Support/Code/User/settings.json` |
| Windows | `%APPDATA%\\Code\\User\\settings.json` |
| Linux | `${XDG_CONFIG_HOME:-~/.config}/Code/User/settings.json` |
For Insiders, VSCodium, portable installations, a custom `--user-data-dir`, or a
remote extension host, provide the exact user settings file:
```bash
headroom wrap vscode --settings-file /path/to/User/settings.json
```
Use `--no-configure` to print the two settings without editing a file.
## Model coverage
There is no static Headroom model list. Availability stays controlled by Copilot
and the signed-in account. To validate a model, select it in VS Code and send a
short prompt; Headroom forwards the model identifier unchanged. This naturally
covers newly added Copilot models without a Headroom release.
The newest Copilot models may use the OpenAI Responses API instead of the legacy
Chat Completions API. Headroom proxies both routes. Do not treat a model's
`unsupported_api_for_model` response from `/chat/completions` as a proxy failure;
VS Code uses the endpoint supported by that model.
Live verification on July 31, 2026 confirmed the account catalog and a successful
HTTP 200 response through Headroom's `/responses` route for each of these exact
model IDs:
- `gpt-5.5`
- `gpt-5.6-luna`
- `gpt-5.6-sol`
- `gpt-5.6-terra`
Each response retained the requested model ID. Model availability remains subject
to the signed-in user's Copilot plan and organization policy.
The proxy supports the native Copilot OpenAI-compatible request paths used by
GPT and Claude models. Headroom's upstream auth hook replaces local client auth
with the current Copilot API token on every Copilot-bound request.
## Stop and undo
Press `Ctrl+C` to stop the session proxy. The VS Code endpoint setting remains so
future `headroom wrap vscode` runs need no reconfiguration; while the proxy is
stopped, Copilot requests will fail closed instead of bypassing Headroom.
Remove only Headroom's settings block with:
```bash
headroom unwrap vscode
```
Use the same `--settings-file` override used during setup. Other VS Code and
Copilot settings are preserved.
## Remote development
Copilot may run in the local or remote extension host depending on the workspace.
For Dev Containers, SSH, or WSL, `127.0.0.1` must refer to the host running
Headroom. Run Headroom in that environment or forward the chosen port. Portable,
remote, and profile-specific settings should use `--settings-file` explicitly.
## Enterprise
GitHub.com Enterprise Cloud normally requires no override; Headroom uses the API
URL advertised during token exchange. For GitHub Enterprise Server/custom domains,
set `GITHUB_COPILOT_ENTERPRISE_URL` or `GITHUB_COPILOT_ENTERPRISE_DOMAIN` before
both `copilot-auth login` and `wrap vscode`.
## Verification and troubleshooting
- `headroom copilot-auth status` should report `logged in`.
- `http://127.0.0.1:8787/health` should be healthy while the wrapper runs.
- The health payload's OpenAI upstream should be the Copilot API endpoint.
- Select several native models and confirm Headroom metrics show each request.
- If a model is unavailable, verify the Copilot account entitlement; Headroom
does not add or rename models.
- If connection is refused, keep the wrapper running and check loopback/remote
port reachability.
- Use `--port 8788` when the default port is occupied; settings update safely.
- If Headroom refuses settings, repair the reported JSONC/marker conflict or use
`--no-configure` and apply the printed settings manually.

View file

@ -35,6 +35,9 @@ CONTINUE_PORT = 28893
GOOSE_PORT = 28894
OPENHANDS_PORT = 28895
OPENCODE_PORT = 28896
CLAUDE_PORT = 28897
VSCODE_PORT = 28898
MOCK_UPSTREAM_PORT = 19001
def log(message: str) -> None:
@ -116,6 +119,13 @@ class MockOpenAIHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
self._record()
if self.path == "/copilot_internal/user":
port = self.server.server_address[1]
self._write_json(
200,
{"endpoints": {"api": f"http://127.0.0.1:{port}/v1"}},
)
return
if self.path == "/v1/models":
self._write_json(
200,
@ -634,7 +644,7 @@ def verify_codex_wrap(
def verify_claude_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
port = PROXY_PORT + 10
port = CLAUDE_PORT
run(
["headroom", "wrap", "claude", "--port", str(port), "--", "--help"],
env=base_env,
@ -716,6 +726,84 @@ def verify_cursor_wrap(base_env: dict[str, str], project_dir: Path) -> None:
stop_process(proc)
def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Exercise the Linux settings lifecycle without real Copilot credentials."""
port = VSCODE_PORT
settings_path = Path(base_env["HOME"]) / ".config" / "Code" / "User" / "settings.json"
settings_path.parent.mkdir(parents=True)
original = b'{\n // existing user setting\n "editor.fontSize": 15\n}\n'
settings_path.write_bytes(original)
env = base_env.copy()
for name in (
"COPILOT_GITHUB_TOKEN",
"COPILOT_PROVIDER_BEARER_TOKEN",
"GH_TOKEN",
"GITHUB_COPILOT_GITHUB_TOKEN",
"GITHUB_COPILOT_REFRESH_OAUTH_TOKEN",
"GITHUB_COPILOT_TOKEN",
"GITHUB_TOKEN",
):
env.pop(name, None)
env.update(
{
"GITHUB_COPILOT_API_TOKEN": "synthetic-e2e-token",
"GITHUB_COPILOT_API_URL": f"http://127.0.0.1:{MOCK_UPSTREAM_PORT}/v1",
"GITHUB_COPILOT_USER_INFO_URL": (
f"http://127.0.0.1:{MOCK_UPSTREAM_PORT}/copilot_internal/user"
),
"HEADROOM_COPILOT_AUTH_FILE": str(Path(base_env["HOME"]) / "copilot-auth.json"),
}
)
proc = subprocess.Popen(
["headroom", "wrap", "vscode", "--port", str(port)],
env=env,
cwd=str(project_dir),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
try:
output = wait_for_output(proc, "Press Ctrl+C to stop the proxy.", timeout=30)
project_prefix = f"/p/{quote(project_dir.name, safe='')}"
configured = settings_path.read_text(encoding="utf-8")
assert_true(
f'"github.copilot.advanced.debug.overrideProxyUrl": '
f'"http://127.0.0.1:{port}{project_prefix}"' in configured,
"VS Code wrap should configure the project-scoped proxy URL",
)
assert_true(
'"github.copilot.advanced.debug.overrideAuthType": "token"' in configured,
"VS Code wrap should configure token auth",
)
assert_true(
"synthetic-e2e-token" not in configured, "Settings must not contain credentials"
)
assert_true("model" not in configured.lower(), "Settings must not override model selection")
assert_true(
str(settings_path) in output, "Wrap output should identify the Linux settings path"
)
health = wait_for_http(f"http://127.0.0.1:{port}/health", timeout=15).json()
assert_true(
health.get("config", {}).get("openai_api_url")
== f"http://127.0.0.1:{MOCK_UPSTREAM_PORT}/v1",
"VS Code proxy should use the mocked Copilot upstream",
)
finally:
stop_process(proc)
assert_true(settings_path.read_bytes() != original, "Ctrl+C should leave managed settings")
run(["headroom", "unwrap", "vscode"], env=env, cwd=project_dir, timeout=60)
assert_true(
settings_path.read_bytes() == original,
"VS Code unwrap should restore the original settings byte-for-byte",
)
def verify_cline_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Smoke test: `wrap cline --prepare-only` exits clean.
@ -880,14 +968,14 @@ def main() -> None:
path.mkdir(parents=True, exist_ok=True)
create_shims(shim_dir)
mock_server, mock_thread = start_mock_server(19001)
mock_server, mock_thread = start_mock_server(MOCK_UPSTREAM_PORT)
base_env = os.environ.copy()
base_env.update(
{
"HOME": str(home_dir),
"PATH": f"{shim_dir}{os.pathsep}{base_env['PATH']}",
"HEADROOM_E2E_LOG_DIR": str(log_dir),
"OPENAI_TARGET_API_URL": "http://127.0.0.1:19001/v1",
"OPENAI_TARGET_API_URL": f"http://127.0.0.1:{MOCK_UPSTREAM_PORT}/v1",
}
)
@ -897,6 +985,7 @@ def main() -> None:
verify_codex_wrap(base_env, project_dir, log_dir, mock_server)
verify_aider_wrap(base_env, project_dir, log_dir)
verify_cursor_wrap(base_env, project_dir)
verify_vscode_wrap(base_env, project_dir)
verify_cline_wrap(base_env, project_dir)
verify_continue_wrap(base_env, project_dir)
verify_goose_wrap(base_env, project_dir)

View file

@ -3,6 +3,7 @@
Usage:
headroom wrap claude # Start proxy + claude
headroom wrap copilot -- --model ... # Start proxy + launch GitHub Copilot CLI
headroom wrap vscode # Transparently proxy VS Code Copilot
headroom wrap codex # Start proxy + OpenAI Codex CLI
headroom wrap aider # Start proxy + aider
headroom wrap openclaude # Start proxy + OpenClaude
@ -87,6 +88,12 @@ from headroom.providers.codex.threads import retag_to_headroom, retag_to_native
from headroom.providers.copilot import (
build_launch_env as _build_copilot_launch_env,
)
from headroom.providers.copilot import (
configure_vscode_proxy_settings,
remove_vscode_proxy_settings,
vscode_proxy_url,
vscode_settings_path,
)
from headroom.providers.copilot import (
copilot_model_from_args as _copilot_model_from_args_impl,
)
@ -2691,6 +2698,9 @@ def _run_proxy_only_watcher(
print_setup_lines: Callable[[int], None],
anthropic_api_url: str | None = None,
openai_api_url: str | None = None,
copilot_api_token: str | None = None,
copilot_refresh_oauth_token: str | None = None,
copilot_api_token_expires_at: float | None = None,
) -> None:
"""Shared scaffolding for proxy-only wrap subcommands (no child binary launch).
@ -2706,8 +2716,16 @@ def _run_proxy_only_watcher(
proxy_holder: list[subprocess.Popen | None] = [None]
port_holder: list[int] = [port]
cleanup = _make_cleanup(proxy_holder, port_holder)
signal.signal(signal.SIGINT, cleanup)
signal.signal(signal.SIGTERM, cleanup)
def _signal_shutdown(signum: int, frame: Any) -> None:
cleanup(signum, frame)
# cleanup alone leaves the watcher loop alive long enough to observe
# the intentionally terminated proxy and report a false crash. Raise
# into its normal Ctrl-C path so shutdown exits successfully.
raise KeyboardInterrupt
signal.signal(signal.SIGINT, _signal_shutdown)
signal.signal(signal.SIGTERM, _signal_shutdown)
try:
_print_wrap_banner(agent_label)
@ -2720,6 +2738,9 @@ def _run_proxy_only_watcher(
agent_type=agent_type,
anthropic_api_url=anthropic_api_url,
openai_api_url=openai_api_url,
copilot_api_token=copilot_api_token,
copilot_refresh_oauth_token=copilot_refresh_oauth_token,
copilot_api_token_expires_at=copilot_api_token_expires_at,
)
if actual_port != port:
_unregister_proxy_client(port)
@ -4228,6 +4249,7 @@ def wrap(ctx: click.Context) -> None:
headroom wrap claude # Claude Code (Anthropic)
headroom wrap codex # OpenAI Codex CLI
headroom wrap copilot -- --model claude-sonnet-4-20250514
headroom wrap vscode # VS Code Copilot (preserves model picker)
headroom wrap aider # Aider
headroom wrap openclaude # OpenClaude
headroom wrap vibe # Mistral Vibe
@ -5115,6 +5137,87 @@ def copilot(
# =============================================================================
@wrap.command("vscode")
@click.option("--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port")
@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
@click.option(
"--settings-file",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Override settings.json path (Insiders, VSCodium, portable profiles)",
)
@click.option(
"--configure/--no-configure",
default=True,
help="Safely add/update Headroom's transparent Copilot proxy settings",
)
def vscode_copilot(
port: int,
memory: bool,
settings_file: Path | None,
configure: bool,
) -> None:
"""Run Headroom for GitHub Copilot inside Visual Studio Code.
Transparently overrides Copilot's proxy endpoint, preserving the model
selected in VS Code. It does not edit Codex settings.
"""
resolution = _require_copilot_subscription_resolution()
target_settings = settings_file or vscode_settings_path()
def _print_setup(actual_port: int) -> None:
if configure:
action = configure_vscode_proxy_settings(
target_settings,
vscode_proxy_url(actual_port, _project_name_from_cwd()),
)
click.echo(f" VS Code Copilot proxy settings {action}: {target_settings}")
click.echo(
" Keep using Copilot's normal model picker; the selected model is preserved."
)
return
click.echo(" Add these user settings to VS Code:")
click.echo(
f' "github.copilot.advanced.debug.overrideProxyUrl": "{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",
port=port,
no_proxy=False,
learn=False,
memory=memory,
agent_type="copilot",
print_setup_lines=_print_setup,
openai_api_url=resolution.api_url,
copilot_api_token=resolution.token,
copilot_refresh_oauth_token=resolution.refresh_oauth_token,
copilot_api_token_expires_at=resolution.api_token_expires_at,
)
@unwrap.command("vscode")
@click.option(
"--settings-file",
type=click.Path(path_type=Path, dir_okay=False),
default=None,
help="Override settings.json path",
)
def unwrap_vscode_copilot(settings_file: Path | None) -> None:
"""Remove only Headroom's transparent VS Code Copilot proxy settings."""
target_settings = settings_file or vscode_settings_path()
if remove_vscode_proxy_settings(target_settings):
click.echo(f"Removed Headroom Copilot proxy settings from {target_settings}")
else:
click.echo(f"No Headroom Copilot proxy settings found in {target_settings}")
# =============================================================================
# GitHub Copilot CLI (unwrap)
# =============================================================================
@unwrap.command("copilot")
@click.option(
"--port", "-p", default=8787, type=click.IntRange(1, 65535), help="Proxy port (default: 8787)"

View file

@ -18,7 +18,7 @@ from pathlib import Path
from typing import Any
from urllib import error as urllib_error
from urllib import request as urllib_request
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from headroom import paths
from headroom._subprocess import run
@ -523,19 +523,15 @@ def start_copilot_device_authorization(
"""Start the GitHub Copilot OAuth device-code flow."""
urls = _github_oauth_urls(domain)
body = json.dumps(
{
"client_id": COPILOT_CHAT_OAUTH_CLIENT_ID,
"scope": "read:user",
},
separators=(",", ":"),
).encode("utf-8")
body = urlencode({"client_id": COPILOT_CHAT_OAUTH_CLIENT_ID, "scope": "read:user"}).encode(
"utf-8"
)
request = urllib_request.Request(
urls["device_code"],
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": _DEFAULT_USER_AGENT,
},
method="POST",
@ -561,20 +557,19 @@ def poll_copilot_device_authorization(
deadline = time.time() + max(1, expires_in)
poll_interval = max(1, interval)
while time.time() < deadline:
body = json.dumps(
body = urlencode(
{
"client_id": COPILOT_CHAT_OAUTH_CLIENT_ID,
"device_code": device_code,
"grant_type": _DEVICE_CODE_GRANT_TYPE,
},
separators=(",", ":"),
}
).encode("utf-8")
request = urllib_request.Request(
urls["access_token"],
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": _DEFAULT_USER_AGENT,
},
method="POST",

View file

@ -1,5 +1,12 @@
"""Copilot-specific provider helpers."""
from .vscode import (
configure_vscode_proxy_settings,
remove_vscode_proxy_settings,
vscode_proxy_url,
vscode_settings_path,
vscode_user_dir,
)
from .wrap import (
build_launch_env,
copilot_model_from_args,
@ -28,4 +35,9 @@ __all__ = [
"resolve_provider_type",
"strip_auto_model_args",
"validate_configuration",
"configure_vscode_proxy_settings",
"remove_vscode_proxy_settings",
"vscode_proxy_url",
"vscode_settings_path",
"vscode_user_dir",
]

View file

@ -0,0 +1,190 @@
"""Transparent VS Code Copilot proxy configuration helpers."""
from __future__ import annotations
import json
import os
import re
import sys
from collections.abc import Mapping
from pathlib import Path
import click
from headroom import fsutil
from headroom.proxy.project_context import with_project_prefix
_MARKER_START = "// --- Headroom Copilot proxy ---"
_MARKER_END = "// --- end Headroom Copilot proxy ---"
_PROXY_KEY = "github.copilot.advanced.debug.overrideProxyUrl"
_AUTH_KEY = "github.copilot.advanced.debug.overrideAuthType"
def _read_settings(path: Path) -> str:
"""Read UTF-8 settings without normalizing Windows line endings or a BOM."""
with path.open(encoding="utf-8", newline="") as settings_file:
return settings_file.read()
def vscode_user_dir(
*, platform: str | None = None, environ: Mapping[str, str] | None = None
) -> Path:
"""Return stable VS Code's user-data directory on macOS, Windows, or Linux."""
env = environ if environ is not None else os.environ
current_platform = platform or sys.platform
home = Path(env.get("HOME") or env.get("USERPROFILE") or Path.home())
if current_platform == "darwin":
return home / "Library" / "Application Support" / "Code" / "User"
if current_platform == "win32":
appdata = env.get("APPDATA")
if not appdata:
raise click.ClickException("APPDATA is not set; pass --settings-file explicitly.")
return Path(appdata) / "Code" / "User"
return Path(env.get("XDG_CONFIG_HOME") or home / ".config") / "Code" / "User"
def vscode_settings_path(
*, platform: str | None = None, environ: Mapping[str, str] | None = None
) -> Path:
return vscode_user_dir(platform=platform, environ=environ) / "settings.json"
def vscode_proxy_url(port: int, project: str | None = None) -> str:
"""Return the transparent Copilot endpoint override for a Headroom proxy."""
return str(with_project_prefix(f"http://127.0.0.1:{port}", project))
def _strip_jsonc_comments(value: str) -> str:
"""Strip JSONC comments while respecting quoted strings."""
result: list[str] = []
index = 0
in_string = False
escaped = False
while index < len(value):
char = value[index]
if in_string:
result.append(char)
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
index += 1
continue
if char == '"':
in_string = True
result.append(char)
index += 1
continue
if value.startswith("//", index):
newline = value.find("\n", index)
if newline < 0:
break
result.append("\n")
index = newline + 1
continue
if value.startswith("/*", index):
end = value.find("*/", index + 2)
if end < 0:
raise click.ClickException(
"VS Code settings contain an unterminated block comment."
)
result.extend("\n" for char in value[index : end + 2] if char == "\n")
index = end + 2
continue
result.append(char)
index += 1
return "".join(result)
def _validate_settings(raw: str, path: Path) -> None:
candidate = _strip_jsonc_comments(raw)
if candidate.startswith("\ufeff"):
candidate = candidate[1:]
candidate = re.sub(r",\s*([}\]])", r"\1", candidate)
try:
parsed = json.loads(candidate)
except json.JSONDecodeError as exc:
raise click.ClickException(
f"Could not safely parse {path}: {exc}. Headroom did not overwrite it."
) from exc
if not isinstance(parsed, dict):
raise click.ClickException(f"{path} must contain a JSON object; refusing to overwrite it.")
def _managed_block(proxy_url: str, *, owns_preceding_comma: bool, line_sep: str) -> str:
marker = _MARKER_START + (" (comma-added)" if owns_preceding_comma else "")
return (
f"\t{marker}{line_sep}"
f"\t{json.dumps(_PROXY_KEY)}: {json.dumps(proxy_url)},{line_sep}"
f'\t{json.dumps(_AUTH_KEY)}: "token"{line_sep}'
f"\t{_MARKER_END}"
)
def remove_vscode_proxy_settings(path: Path) -> bool:
"""Remove Headroom's marker-owned settings without reformatting user content."""
if not path.exists():
return False
raw = _read_settings(path)
start_count = raw.count(_MARKER_START)
end_count = raw.count(_MARKER_END)
start = raw.find(_MARKER_START)
end = raw.find(_MARKER_END)
if start < 0 and end < 0:
return False
if start_count != 1 or end_count != 1 or end < start:
raise click.ClickException(f"Incomplete Headroom marker block in {path}; refusing to edit.")
line_start = raw.rfind("\n", 0, start) + 1
line_end = raw.find("\n", end)
line_end = len(raw) if line_end < 0 else line_end + 1
prefix = raw[:line_start]
suffix = raw[line_end:]
# The installer owns a preceding comma only when it had to add one.
marker_line = raw[line_start : raw.find("\n", start)]
trimmed = prefix.rstrip()
if "(comma-added)" in marker_line and trimmed.endswith(","):
prefix = trimmed[:-1] + prefix[len(trimmed) :]
updated = prefix + suffix
_validate_settings(updated, path)
fsutil.write_text(path, updated)
return True
def configure_vscode_proxy_settings(path: Path, proxy_url: str) -> str:
"""Add/update transparent routing while preserving all non-Headroom text."""
path.parent.mkdir(parents=True, exist_ok=True)
raw = _read_settings(path) if path.exists() else "{}\n"
_validate_settings(raw, path)
had_managed_block = _MARKER_START in raw or _MARKER_END in raw
if had_managed_block:
remove_vscode_proxy_settings(path)
raw = _read_settings(path)
elif _PROXY_KEY in raw or _AUTH_KEY in raw:
raise click.ClickException(
f"{path} already configures a Copilot endpoint override outside Headroom's "
"managed block; refusing to replace it. Remove it or use --no-configure."
)
close = raw.rfind("}")
if close < 0:
raise click.ClickException(f"Could not locate the root object in {path}.")
before = raw[:close].rstrip()
after = raw[close:]
inner = _strip_jsonc_comments(before).rstrip()
needs_comma = not inner.endswith("{") and not inner.endswith(",")
separator = "," if needs_comma else ""
line_sep = "\r\n" if "\r\n" in raw else "\n"
newline = "" if before.endswith(("\n", "\r")) else line_sep
updated = (
before
+ separator
+ newline
+ _managed_block(proxy_url, owns_preceding_comma=needs_comma, line_sep=line_sep)
+ line_sep
+ after
)
_validate_settings(updated, path)
fsutil.write_text(path, updated)
return "updated" if had_managed_block else "added"

View file

@ -15,8 +15,7 @@ from __future__ import annotations
import errno
import json
import os
import subprocess
import sys
import signal
from pathlib import Path
from typing import Any
@ -211,6 +210,53 @@ def test_run_proxy_only_watcher_keyboardinterrupt_shuts_down_cleanly(
assert "Shutting down..." in inv.output
def test_run_proxy_only_watcher_signal_handler_uses_clean_shutdown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The installed SIGINT handler must not misreport its own proxy stop as a crash."""
handlers: dict[int, Any] = {}
cleanup_calls = {"n": 0}
class _FakeProc:
def poll(self) -> None:
return None
def capture_handler(sig: int, handler: Any) -> None:
handlers[sig] = handler
def trigger_sigint(_seconds: float) -> None:
handlers[signal.SIGINT](signal.SIGINT, None)
def cleanup(*_args: Any) -> None:
cleanup_calls["n"] += 1
monkeypatch.setattr(wrap_mod, "_ensure_proxy", lambda *a, **kw: (_FakeProc(), 8787))
monkeypatch.setattr(wrap_mod.time, "sleep", trigger_sigint)
monkeypatch.setattr(wrap_mod, "_make_cleanup", lambda holder, port: cleanup)
monkeypatch.setattr(wrap_mod.signal, "signal", capture_handler)
runner = CliRunner()
@click.command()
def _cmd() -> None:
wrap_mod._run_proxy_only_watcher(
agent_label="vscode copilot",
port=8787,
no_proxy=False,
learn=False,
memory=False,
agent_type="copilot",
print_setup_lines=lambda _port: None,
)
inv = runner.invoke(_cmd)
assert inv.exit_code == 0, inv.output
assert "Shutting down..." in inv.output
assert "Proxy process exited unexpectedly" not in inv.output
assert cleanup_calls["n"] >= 2 # signal handler plus finally (idempotent)
def test_run_proxy_only_watcher_unexpected_exception_returns_exit_1(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -505,13 +551,13 @@ class TestProxyClientRefCounting:
assert proc.terminated is False
def test_dead_client_marker_is_pruned_and_not_counted(self, clients_dir: Path) -> None:
def test_dead_client_marker_is_pruned_and_not_counted(
self, clients_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A marker for a dead PID is pruned from disk and never counted."""
# Spawn and reap a child so its PID is reliably dead (not a zombie).
child = subprocess.Popen([sys.executable, "-c", "pass"])
child.wait()
dead_pid = child.pid
dead_pid = 358784
marker = self._write_marker(clients_dir, dead_pid)
monkeypatch.setattr(wrap_mod, "_pid_alive", lambda pid: pid != dead_pid)
live = wrap_mod._live_proxy_clients(self.PORT, exclude_self=True)

View file

@ -0,0 +1,81 @@
"""CLI coverage for transparent VS Code Copilot setup and undo."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from headroom.cli.main import main
from headroom.copilot_auth import CopilotSubscriptionTokenResolution
def _resolution() -> CopilotSubscriptionTokenResolution:
return CopilotSubscriptionTokenResolution(
token="copilot-token",
source="test",
confidence="test",
api_url="https://api.githubcopilot.com",
token_fingerprint="sha256:test",
)
def test_wrap_vscode_configures_actual_port_and_seeds_subscription(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
captured = {}
def fake_watcher(**kwargs): # noqa: ANN003, ANN202
captured.update(kwargs)
kwargs["print_setup_lines"](9999)
with (
patch(
"headroom.cli.wrap._require_copilot_subscription_resolution", return_value=_resolution()
),
patch("headroom.cli.wrap._run_proxy_only_watcher", side_effect=fake_watcher),
):
result = CliRunner().invoke(main, ["wrap", "vscode", "--settings-file", str(path)])
assert result.exit_code == 0, result.output
settings = path.read_text(encoding="utf-8")
assert "http://127.0.0.1:9999/" in settings
assert "model" not in settings.lower()
assert "normal model picker" in result.output
assert captured["openai_api_url"] == "https://api.githubcopilot.com"
assert captured["copilot_api_token"] == "copilot-token"
def test_wrap_vscode_no_configure_prints_transparent_settings(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
def fake_watcher(**kwargs): # noqa: ANN003, ANN202
kwargs["print_setup_lines"](8787)
with (
patch(
"headroom.cli.wrap._require_copilot_subscription_resolution", return_value=_resolution()
),
patch("headroom.cli.wrap._run_proxy_only_watcher", side_effect=fake_watcher),
):
result = CliRunner().invoke(
main,
["wrap", "vscode", "--no-configure", "--settings-file", str(path)],
)
assert result.exit_code == 0, result.output
assert not path.exists()
assert "overrideProxyUrl" in result.output
assert "overrideAuthType" in result.output
def test_unwrap_vscode_removes_only_managed_settings(tmp_path: Path) -> None:
path = tmp_path / "settings.json"
original = '{\n "editor.fontSize": 14\n}\n'
path.write_text(original, encoding="utf-8")
from headroom.providers.copilot.vscode import configure_vscode_proxy_settings
configure_vscode_proxy_settings(path, "http://127.0.0.1:8787")
result = CliRunner().invoke(main, ["unwrap", "vscode", "--settings-file", str(path)])
assert result.exit_code == 0, result.output
assert path.read_text(encoding="utf-8") == original

View file

@ -12,6 +12,42 @@ import pytest
from headroom import copilot_auth
def test_device_authorization_uses_form_encoded_request(monkeypatch: pytest.MonkeyPatch) -> None:
import io
captured = {}
def fake_urlopen(request, timeout): # noqa: ANN001, ANN202
captured["request"] = request
return io.BytesIO(b'{"device_code":"d","user_code":"u"}')
monkeypatch.setattr(copilot_auth.urllib_request, "urlopen", fake_urlopen)
copilot_auth.start_copilot_device_authorization()
request = captured["request"]
assert request.headers["Content-type"] == "application/x-www-form-urlencoded"
assert b"client_id=" in request.data
assert not request.data.startswith(b"{")
def test_device_poll_uses_form_encoded_request(monkeypatch: pytest.MonkeyPatch) -> None:
import io
captured = {}
def fake_urlopen(request, timeout): # noqa: ANN001, ANN202
captured["request"] = request
return io.BytesIO(b'{"access_token":"gho-test"}')
monkeypatch.setattr(copilot_auth.urllib_request, "urlopen", fake_urlopen)
assert copilot_auth.poll_copilot_device_authorization("device-test") == "gho-test"
request = captured["request"]
assert request.headers["Content-type"] == "application/x-www-form-urlencoded"
assert b"device_code=device-test" in request.data
assert not request.data.startswith(b"{")
@pytest.fixture(autouse=True)
def _isolated_copilot_auth(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Keep Copilot auth tests away from user secret stores and real auth files."""

View file

@ -0,0 +1,104 @@
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.overrideAuthType": "token"' 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"}',
):
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