mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
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:
parent
56b3e4c1b1
commit
007446c73a
12 changed files with 864 additions and 25 deletions
19
README.md
19
README.md
|
|
@ -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…**
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"litellm",
|
||||
"claude-code-vertex",
|
||||
"claude-code-azure-foundry",
|
||||
"vscode-copilot",
|
||||
"opencode",
|
||||
"grok-build",
|
||||
"mcp",
|
||||
|
|
|
|||
163
docs/content/docs/vscode-copilot.mdx
Normal file
163
docs/content/docs/vscode-copilot.mdx
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
190
headroom/providers/copilot/vscode.py
Normal file
190
headroom/providers/copilot/vscode.py
Normal 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"
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
81
tests/test_cli/test_wrap_vscode.py
Normal file
81
tests/test_cli/test_wrap_vscode.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
104
tests/test_provider_copilot_vscode_config.py
Normal file
104
tests/test_provider_copilot_vscode_config.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue