diff --git a/README.md b/README.md index 266c9914f..cff50434d 100644 --- a/README.md +++ b/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…** diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index f05e8c7fc..a35f135cf 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -37,6 +37,7 @@ "litellm", "claude-code-vertex", "claude-code-azure-foundry", + "vscode-copilot", "opencode", "grok-build", "mcp", diff --git a/docs/content/docs/vscode-copilot.mdx b/docs/content/docs/vscode-copilot.mdx new file mode 100644 index 000000000..a552a946d --- /dev/null +++ b/docs/content/docs/vscode-copilot.mdx @@ -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// + -> 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. diff --git a/e2e/wrap/run.py b/e2e/wrap/run.py index 4546ffa8d..08bc41efb 100644 --- a/e2e/wrap/run.py +++ b/e2e/wrap/run.py @@ -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) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 45dfa623e..28c460aaa 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -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)" diff --git a/headroom/copilot_auth.py b/headroom/copilot_auth.py index 0249f4e6c..8861238bf 100644 --- a/headroom/copilot_auth.py +++ b/headroom/copilot_auth.py @@ -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", diff --git a/headroom/providers/copilot/__init__.py b/headroom/providers/copilot/__init__.py index df53f3397..d35a375a4 100644 --- a/headroom/providers/copilot/__init__.py +++ b/headroom/providers/copilot/__init__.py @@ -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", ] diff --git a/headroom/providers/copilot/vscode.py b/headroom/providers/copilot/vscode.py new file mode 100644 index 000000000..2a0fbc85b --- /dev/null +++ b/headroom/providers/copilot/vscode.py @@ -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" diff --git a/tests/test_cli/test_wrap_helpers.py b/tests/test_cli/test_wrap_helpers.py index 965b44937..b2311b653 100644 --- a/tests/test_cli/test_wrap_helpers.py +++ b/tests/test_cli/test_wrap_helpers.py @@ -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) diff --git a/tests/test_cli/test_wrap_vscode.py b/tests/test_cli/test_wrap_vscode.py new file mode 100644 index 000000000..6d3496991 --- /dev/null +++ b/tests/test_cli/test_wrap_vscode.py @@ -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 diff --git a/tests/test_copilot_auth.py b/tests/test_copilot_auth.py index 57806351b..7035d69ba 100644 --- a/tests/test_copilot_auth.py +++ b/tests/test_copilot_auth.py @@ -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.""" diff --git a/tests/test_provider_copilot_vscode_config.py b/tests/test_provider_copilot_vscode_config.py new file mode 100644 index 000000000..c3075bb83 --- /dev/null +++ b/tests/test_provider_copilot_vscode_config.py @@ -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