From 13a310a00de8e967ebe09502c6b715ef577c5926 Mon Sep 17 00:00:00 2001 From: JD Davis Date: Mon, 3 Aug 2026 22:14:13 -0500 Subject: [PATCH] feat(claude): support Claude Code in VS Code (#2752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Add first-class Headroom support for the official Claude Code extension in VS Code. The new wrapper starts the local proxy, configures the Claude Code user settings consumed by the embedded extension process, preserves authentication and model selection, and provides a conflict-safe reversible unwrap lifecycle. Closes # ## Type of Change - [ ] 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-claude` and `headroom unwrap vscode-claude`. - Configure project-scoped `ANTHROPIC_BASE_URL` plus `ENABLE_TOOL_SEARCH=true` in Claude Code user settings while preserving existing values. - Respect `CLAUDE_CONFIG_DIR`, macOS/Linux home paths, Windows `USERPROFILE`, custom `--settings-file`, and `--no-configure`. - Add durable Headroom-owned restore state and refuse malformed settings or conflicting user edits. - Add unit, CLI, and Docker-harness e2e coverage for configuration, real proxy forwarding, and restoration. - Document setup, remote development, undo, and troubleshooting. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ UV_NO_SYNC=1 uv run pytest -q tests/test_provider_claude_vscode_config.py tests/test_cli/test_wrap_vscode_claude.py tests/test_cli/test_wrap_vscode.py tests/test_cli/test_wrap_claude_base_url.py tests/test_provider_copilot_vscode_config.py tests/test_copilot_auth.py 160 passed in 0.45s $ UV_NO_SYNC=1 uv run ruff check . All checks passed! $ UV_NO_SYNC=1 uv run mypy headroom Success: no issues found in 512 source files $ npm run build # from docs/ Compiled successfully; generated 155 static pages ``` ## Real Behavior Proof - Environment: macOS, Python 3.13 editable install, isolated temporary HOME and Claude settings, local mock Anthropic Messages upstream. - Exact command / steps: invoked the new `verify_vscode_claude_wrap` e2e function, which launched real `headroom wrap vscode-claude`, waited for proxy readiness, POSTed an Anthropic `/v1/messages` request through the generated project-scoped URL, stopped the wrapper, then ran `headroom unwrap vscode-claude`. - Observed result: HTTP 200 with the mock Claude response through Headroom; generated settings retained unrelated values and enabled tool deferral; unwrap restored the original Claude settings. - Not tested: real Anthropic account traffic or the full Docker image locally because Docker Desktop was unavailable. The same e2e function is wired into the existing Docker wrap CI job. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project 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 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 adds CLI configuration and proxy routing without changing VS Code UI. ## Additional Notes The wrapper deliberately leaves the endpoint configured when stopped so requests fail closed instead of silently bypassing Headroom. `headroom unwrap vscode-claude` restores the exact prior managed values and preserves unrelated settings. --------- Co-authored-by: JD Davis --- README.md | 28 +++ docs/content/docs/meta.json | 1 + docs/content/docs/proxy.mdx | 9 + docs/content/docs/quickstart.mdx | 7 + docs/content/docs/vscode-claude-code.mdx | 127 ++++++++++++++ e2e/wrap/run.py | 77 +++++++++ headroom/cli/wrap.py | 117 ++++++++++++- headroom/providers/claude/__init__.py | 10 ++ headroom/providers/claude/vscode.py | 170 ++++++++++++++++++ tests/test_cli/test_wrap_helpers.py | 27 ++- tests/test_cli/test_wrap_vscode_claude.py | 62 +++++++ tests/test_provider_claude_vscode_config.py | 182 ++++++++++++++++++++ 12 files changed, 810 insertions(+), 7 deletions(-) create mode 100644 docs/content/docs/vscode-claude-code.mdx create mode 100644 headroom/providers/claude/vscode.py create mode 100644 tests/test_cli/test_wrap_vscode_claude.py create mode 100644 tests/test_provider_claude_vscode_config.py diff --git a/README.md b/README.md index cff50434d..0126679e7 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,34 @@ 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. +### Claude Code in Visual Studio Code + +The official Claude Code extension embeds Claude Code and reads the same user +settings as the CLI. Install Headroom's proxy dependencies, then run the wrapper +from the project you plan to open in VS Code: + +```bash +pip install "headroom-ai[proxy]" +headroom wrap vscode-claude +``` + +On the first run, reload the VS Code window. Keep the wrapper terminal running +while you use the Claude Code panel; inspect the dashboard or proxy log printed +at startup to see requests and savings. +Headroom preserves your Anthropic authentication and selected model. + +Press `Ctrl+C` to stop the proxy. Restart the same command before using Claude +Code again, or completely restore the settings that existed before setup: + +```bash +headroom unwrap vscode-claude +``` + +See the +[VS Code Claude Code guide](https://headroom-docs.vercel.app/docs/vscode-claude-code) +for verification, configuration paths, custom profiles, remote development, 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 a35f135cf..6a45b0b4c 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-claude-code", "vscode-copilot", "opencode", "grok-build", diff --git a/docs/content/docs/proxy.mdx b/docs/content/docs/proxy.mdx index aa9c1dc61..b443be7bd 100644 --- a/docs/content/docs/proxy.mdx +++ b/docs/content/docs/proxy.mdx @@ -567,6 +567,9 @@ Use `headroom wrap` to launch supported CLI agents through the local proxy: # Claude Code headroom wrap claude +# Claude Code extension in VS Code (configures settings, then starts the proxy) +headroom wrap vscode-claude + # OpenAI Codex headroom wrap codex @@ -588,6 +591,12 @@ Grok Build reads model endpoints from `~/.grok/config.toml`. `headroom wrap grok injects or updates `[model.grok-build] base_url` to point at the local proxy, then run `grok` from the same project directory. See [Grok Build Integration](/docs/grok-build). +The official Claude Code extension reads Claude Code's user settings rather than +the terminal environment. Use `headroom wrap vscode-claude`, reload VS Code after +the first run, and keep the wrapper running. See the +[VS Code Claude Code guide](/docs/vscode-claude-code) for verification and undo +steps. + For environment-driven clients, you can also set the base URL manually: ```bash diff --git a/docs/content/docs/quickstart.mdx b/docs/content/docs/quickstart.mdx index 053a837d4..36615f526 100644 --- a/docs/content/docs/quickstart.mdx +++ b/docs/content/docs/quickstart.mdx @@ -203,6 +203,13 @@ Transforms: ['smart_crusher', 'cache_aligner'] ## Alternative: proxy mode (zero code changes) + +Run `headroom wrap vscode-claude`, reload the VS Code window once, and keep the +wrapper running while you use the official Claude Code extension. See the +[complete VS Code Claude Code guide](/docs/vscode-claude-code) for verification, +undo steps, custom profiles, and remote development. + + If you do not want to change any code, run Headroom as a proxy and point your existing client at it: ```bash diff --git a/docs/content/docs/vscode-claude-code.mdx b/docs/content/docs/vscode-claude-code.mdx new file mode 100644 index 000000000..ae57056ce --- /dev/null +++ b/docs/content/docs/vscode-claude-code.mdx @@ -0,0 +1,127 @@ +--- +title: Use Headroom with Claude Code in VS Code +description: Route the official Claude Code extension through Headroom's local compression proxy. +--- + +The official Claude Code extension for VS Code embeds Claude Code. Headroom can +route its Anthropic API requests through the same local compression proxy used by +`headroom wrap claude`, without changing your Anthropic sign-in or selected model. + + +Use `headroom wrap claude`. This page is specifically for Anthropic's official +Claude Code extension inside VS Code. + + +## Requirements + +- VS Code 1.98 or newer +- Anthropic's official Claude Code extension, signed in and working +- Headroom with proxy dependencies: `pip install "headroom-ai[proxy]"` +- Loopback access to `127.0.0.1` from the VS Code extension host + +Confirm that Claude Code works normally in VS Code before adding Headroom. This +makes authentication or extension problems easier to distinguish from proxy +configuration problems. + +## Quick start + +1. Open a terminal in the project you use with Claude Code. +2. Start Headroom: + +```bash +headroom wrap vscode-claude +``` + +Headroom starts its proxy and adds two entries under `env` in the Claude Code +user settings file: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/p/your-project", + "ENABLE_TOOL_SEARCH": "true" + } +} +``` + +`ANTHROPIC_BASE_URL` changes the endpoint, not the selected model. +`ENABLE_TOOL_SEARCH` keeps Claude Code's on-demand tool loading enabled when it +uses a custom endpoint. Existing settings and prior values for both variables are +preserved for restoration. Headroom does not store or replace your Anthropic +credentials. + +3. After the first configuration, run **Developer: Reload Window** from the VS +Code Command Palette. +4. Keep the wrapper terminal running and use the Claude Code panel normally. + +## Verify that it is working + +While the wrapper is running: + +1. Open `http://127.0.0.1:8787/health`; it should report a healthy proxy. +2. Send a message in the Claude Code panel. +3. Open the dashboard or proxy log whose locations are printed by the wrapper. + Confirm that the request appears there; savings are recorded with each + completed request. + +If the health check succeeds but no request appears in the dashboard or proxy +log, reload the VS Code window and confirm that the extension host can reach the +same `127.0.0.1` as Headroom. + +## Settings location + +The default user settings file is `~/.claude/settings.json` on macOS and Linux, +or `%USERPROFILE%\.claude\settings.json` on Windows. `CLAUDE_CONFIG_DIR` is +respected when set. To target another profile explicitly: + +```bash +headroom wrap vscode-claude --settings-file /path/to/.claude/settings.json +``` + +Use `--no-configure` to print the settings without editing a file. + +The proxy URL includes the current directory as the project attribution name. +Run the wrapper from the intended project directory. If you select another port, +for example `--port 8788`, Headroom writes that same port to the settings file. + +## Stop and undo + +Press `Ctrl+C` to stop the proxy. The endpoint remains configured so requests +fail closed rather than silently bypassing Headroom while it is stopped. Restart +it with `headroom wrap vscode-claude` before using Claude Code again. + +Restore the values that existed before Headroom configured the extension: + +```bash +headroom unwrap vscode-claude +``` + +Headroom records only the two values it owns in a sidecar next to the Claude +settings file. It refuses malformed settings or conflicting edits rather than +overwriting them. Unrelated Claude settings are preserved. + +If you used `--settings-file` during setup, pass the same option when undoing it: + +```bash +headroom unwrap vscode-claude --settings-file /path/to/.claude/settings.json +``` + +## Remote development + +For Dev Containers, SSH, or WSL, `127.0.0.1` must refer to the environment where +the Claude Code process runs. Run Headroom there or forward the selected port, +and pass that environment's Claude settings file with `--settings-file` when +automatic discovery does not match it. + +## Troubleshooting + +- Check `http://127.0.0.1:8787/health` while the wrapper is running. +- Run `headroom wrap vscode-claude --port 8788` if port 8787 is occupied. +- Reload the VS Code window after changing Claude Code settings. +- Keep the wrapper process running for the entire Claude Code session. A stopped + proxy intentionally does not fall back to a direct Anthropic connection. +- If configuration reports a conflict, inspect `~/.claude/settings.json`; Headroom + will not replace a managed value that changed after setup. +- If you use `CLAUDE_CONFIG_DIR`, launch Headroom from an environment where it is + set to the same value used by Claude Code. +- This integration is for the Claude Code extension, not the Claude desktop app. diff --git a/e2e/wrap/run.py b/e2e/wrap/run.py index 08bc41efb..0120adc00 100644 --- a/e2e/wrap/run.py +++ b/e2e/wrap/run.py @@ -37,6 +37,7 @@ OPENHANDS_PORT = 28895 OPENCODE_PORT = 28896 CLAUDE_PORT = 28897 VSCODE_PORT = 28898 +VSCODE_CLAUDE_PORT = 28899 MOCK_UPSTREAM_PORT = 19001 @@ -174,6 +175,20 @@ class MockOpenAIHandler(BaseHTTPRequestHandler): }, ) return + if self.path == "/v1/messages": + self._write_json( + 200, + { + "id": "msg_e2e", + "type": "message", + "role": "assistant", + "model": payload.get("model", "claude-sonnet-4-6"), + "content": [{"type": "text", "text": "mock Claude response"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 12, "output_tokens": 4}, + }, + ) + return self._write_json(404, {"error": {"message": "not found"}}) @@ -804,6 +819,67 @@ def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None: ) +def verify_vscode_claude_wrap(base_env: dict[str, str], project_dir: Path) -> None: + """Exercise Claude Code's settings, proxy request, and restore lifecycle.""" + port = VSCODE_CLAUDE_PORT + settings_path = Path(base_env["HOME"]) / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + original = {"env": {"KEEP": "yes"}, "permissions": {"allow": ["Read"]}} + settings_path.write_text(json.dumps(original), encoding="utf-8") + + env = base_env.copy() + env["ANTHROPIC_TARGET_API_URL"] = f"http://127.0.0.1:{MOCK_UPSTREAM_PORT}" + proc = subprocess.Popen( + ["headroom", "wrap", "vscode-claude", "--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 = json.loads(settings_path.read_text(encoding="utf-8")) + proxy_url = configured["env"]["ANTHROPIC_BASE_URL"] + assert_true( + proxy_url == f"http://127.0.0.1:{port}{project_prefix}", + "VS Code Claude wrap should configure the project-scoped Anthropic URL", + ) + assert_true( + configured["env"]["ENABLE_TOOL_SEARCH"] == "true", + "VS Code Claude wrap should retain Claude Code tool deferral", + ) + assert_true(configured["env"]["KEEP"] == "yes", "Existing Claude env must remain") + assert_true(str(settings_path) in output, "Wrap output should identify Claude settings") + + response = httpx.post( + f"{proxy_url}/v1/messages", + headers={"x-api-key": "synthetic-anthropic-key", "anthropic-version": "2023-06-01"}, + json={ + "model": "claude-sonnet-4-6", + "max_tokens": 16, + "messages": [{"role": "user", "content": "Reply briefly"}], + }, + timeout=30, + ) + assert_true(response.status_code == 200, "Claude request should traverse Headroom") + assert_true( + response.json()["content"][0]["text"] == "mock Claude response", + "Claude response should return through Headroom", + ) + finally: + stop_process(proc) + + run(["headroom", "unwrap", "vscode-claude"], env=env, cwd=project_dir, timeout=60) + assert_true( + json.loads(settings_path.read_text(encoding="utf-8")) == original, + "VS Code Claude unwrap should restore existing Claude settings", + ) + + def verify_cline_wrap(base_env: dict[str, str], project_dir: Path) -> None: """Smoke test: `wrap cline --prepare-only` exits clean. @@ -986,6 +1062,7 @@ def main() -> None: verify_aider_wrap(base_env, project_dir, log_dir) verify_cursor_wrap(base_env, project_dir) verify_vscode_wrap(base_env, project_dir) + verify_vscode_claude_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 28c460aaa..af22dc45c 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -4,6 +4,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 vscode-claude # Transparently proxy VS Code Claude Code headroom wrap codex # Start proxy + OpenAI Codex CLI headroom wrap aider # Start proxy + aider headroom wrap openclaude # Start proxy + OpenClaude @@ -73,11 +74,15 @@ from headroom.providers.claude import ( REMOTE_CONTROL_BASE_URL_ENV, TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV, + claude_user_settings_path, + configure_vscode_claude_settings, detect_claude_code_version, remote_control_applies_to_auth, remote_control_gate_active, remote_control_gate_message, remote_control_sibling_gate_note, + remove_vscode_claude_settings, + vscode_claude_proxy_url, ) from headroom.providers.claude import ( proxy_base_url as _claude_proxy_base_url, @@ -2726,6 +2731,12 @@ def _run_proxy_only_watcher( signal.signal(signal.SIGINT, _signal_shutdown) signal.signal(signal.SIGTERM, _signal_shutdown) + # Windows exposes Ctrl+Break as SIGBREAK rather than SIGINT. Test runners, + # IDE terminals, and process supervisors commonly use Ctrl+Break to target + # a newly created process group, so route it through the same graceful + # cleanup path as an interactive Ctrl+C. + if sys.platform == "win32" and hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _signal_shutdown) try: _print_wrap_banner(agent_label) @@ -3935,15 +3946,28 @@ def _make_cleanup(proxy_proc_holder: list, port: int | list[int] = 8787) -> Any: p = port[0] if isinstance(port, list) else port _unregister_proxy_client(p) proc = proxy_proc_holder[0] if proxy_proc_holder else None - if proc and proc.poll() is None: + if proc: if _other_clients_exist(): # Other clients still using the proxy — leave it running. return - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + # On Windows the proxy launcher can exit while its detached + # serving child remains alive (the native runtime uses a child + # process). The detachment is intentional so an ungraceful + # terminal close cannot disrupt other wrappers, but a graceful + # Ctrl+C from the last wrapper must still stop the listener. + if sys.platform == "win32" and _check_proxy(p): + stop_status = _stop_local_proxy_for_unwrap(p) + if stop_status not in {"stopped", "not_running"}: + click.echo( + f" Warning: proxy on port {p} remained running " + f"after shutdown ({stop_status})." + ) return cleanup @@ -4250,6 +4274,7 @@ def wrap(ctx: click.Context) -> None: 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 vscode-claude # VS Code Claude Code extension headroom wrap aider # Aider headroom wrap openclaude # OpenClaude headroom wrap vibe # Mistral Vibe @@ -5213,6 +5238,86 @@ def unwrap_vscode_copilot(settings_file: Path | None) -> None: click.echo(f"No Headroom Copilot proxy settings found in {target_settings}") +# ============================================================================= +# Claude Code for VS Code +# ============================================================================= + + +@wrap.command("vscode-claude") +@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 Claude Code user settings.json path", +) +@click.option( + "--configure/--no-configure", + default=True, + help="Safely add/update Claude Code's proxy environment settings", +) +def vscode_claude( + port: int, + memory: bool, + settings_file: Path | None, + configure: bool, +) -> None: + """Route VS Code's official Claude Code extension through Headroom. + + Run this from your project, reload VS Code after first setup, and keep this + command running while using Claude Code. Authentication and model selection + remain unchanged. Run `headroom unwrap vscode-claude` to restore settings. + """ + target_settings = settings_file or claude_user_settings_path() + + def _print_setup(actual_port: int) -> None: + proxy_url = vscode_claude_proxy_url(actual_port, _project_name_from_cwd()) + if configure: + action = configure_vscode_claude_settings(target_settings, proxy_url) + click.echo(f" VS Code Claude Code proxy settings {action}: {target_settings}") + click.echo(" Next: Reload VS Code, then use the Claude Code panel.") + click.echo(" Keep this command running. Press Ctrl+C to stop the proxy.") + click.echo(" Authentication and the selected Claude model are preserved.") + click.echo(" Undo later with: headroom unwrap vscode-claude") + click.echo(" Guide: https://headroom-docs.vercel.app/docs/vscode-claude-code") + return + click.echo(f" Add these values under 'env' in {target_settings}:") + click.echo(f' "ANTHROPIC_BASE_URL": "{proxy_url}",') + click.echo(f' "{_TOOL_SEARCH_ENV}": "{_TOOL_SEARCH_DEFAULT}"') + + _run_proxy_only_watcher( + agent_label="VS CODE CLAUDE", + port=port, + no_proxy=False, + learn=False, + memory=memory, + agent_type="claude", + print_setup_lines=_print_setup, + ) + + +@unwrap.command("vscode-claude") +@click.option( + "--settings-file", + type=click.Path(path_type=Path, dir_okay=False), + default=None, + help="Override Claude Code user settings.json path", +) +def unwrap_vscode_claude(settings_file: Path | None) -> None: + """Restore settings saved by `headroom wrap vscode-claude`. + + Reload the VS Code window afterward. If setup used --settings-file, pass the + same path here. + """ + target_settings = settings_file or claude_user_settings_path() + if remove_vscode_claude_settings(target_settings): + click.echo(f"Restored Claude Code settings in {target_settings}") + click.echo("Reload the VS Code window to apply the restored settings.") + else: + click.echo(f"No Headroom VS Code Claude settings found for {target_settings}") + + # ============================================================================= # GitHub Copilot CLI (unwrap) # ============================================================================= diff --git a/headroom/providers/claude/__init__.py b/headroom/providers/claude/__init__.py index e15e1b2c3..9fad1fbfb 100644 --- a/headroom/providers/claude/__init__.py +++ b/headroom/providers/claude/__init__.py @@ -17,8 +17,18 @@ from .runtime import ( remote_control_gate_message, remote_control_sibling_gate_note, ) +from .vscode import ( + claude_user_settings_path, + configure_vscode_claude_settings, + remove_vscode_claude_settings, + vscode_claude_proxy_url, +) __all__ = [ + "claude_user_settings_path", + "configure_vscode_claude_settings", + "remove_vscode_claude_settings", + "vscode_claude_proxy_url", "DEFAULT_API_URL", "REMOTE_CONTROL_BASE_URL_ENV", "REMOTE_CONTROL_GATED_MIN_VERSION", diff --git a/headroom/providers/claude/vscode.py b/headroom/providers/claude/vscode.py new file mode 100644 index 000000000..8f7e62486 --- /dev/null +++ b/headroom/providers/claude/vscode.py @@ -0,0 +1,170 @@ +"""Persistent configuration for Claude Code's VS Code extension.""" + +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import click + +from headroom import fsutil +from headroom.proxy.project_context import with_project_prefix + +_BASE_URL_KEY = "ANTHROPIC_BASE_URL" +_TOOL_SEARCH_KEY = "ENABLE_TOOL_SEARCH" +_MANAGED_KEYS = (_BASE_URL_KEY, _TOOL_SEARCH_KEY) +_STATE_FILENAME = ".headroom-vscode-claude.json" +_STATE_VERSION = 1 + + +def claude_user_settings_path( + environ: Mapping[str, str] | None = None, *, platform: str | None = None +) -> Path: + """Return the Claude Code user settings path, respecting CLAUDE_CONFIG_DIR.""" + env = environ if environ is not None else os.environ + config_dir = env.get("CLAUDE_CONFIG_DIR") + if config_dir: + return Path(config_dir).expanduser() / "settings.json" + current_platform = platform or sys.platform + home_var = "USERPROFILE" if current_platform == "win32" else "HOME" + home = Path(env.get(home_var) or Path.home()) + return home / ".claude" / "settings.json" + + +def vscode_claude_proxy_url(port: int, project: str | None = None) -> str: + """Return the project-scoped Anthropic endpoint for Claude Code in VS Code.""" + return str(with_project_prefix(f"http://127.0.0.1:{port}", project)) + + +def _state_path(settings_path: Path) -> Path: + return settings_path.with_name(_STATE_FILENAME) + + +def _read_object(path: Path, *, label: str) -> dict[str, Any]: + try: + raw = fsutil.read_text(path) + except OSError as exc: + raise click.ClickException(f"Could not read {label} {path}: {exc}") from exc + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise click.ClickException( + f"{label.capitalize()} {path} is not valid JSON ({exc}); refusing to overwrite it." + ) from exc + if not isinstance(payload, dict): + raise click.ClickException( + f"{label.capitalize()} {path} must contain a JSON object; refusing to overwrite it." + ) + return payload + + +def _read_settings(path: Path) -> dict[str, Any]: + return _read_object(path, label="Claude settings") if path.exists() else {} + + +def _env_map(payload: dict[str, Any], path: Path) -> dict[str, Any]: + env = payload.get("env") + if env is None: + return {} + if not isinstance(env, dict): + raise click.ClickException( + f"Claude settings {path} has a non-object 'env' value; refusing to overwrite it." + ) + return dict(env) + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fsutil.write_text(path, json.dumps(payload, indent=2) + "\n") + + +def configure_vscode_claude_settings(path: Path, proxy_url: str) -> str: + """Route Claude Code's VS Code process through Headroom, reversibly.""" + payload = _read_settings(path) + env = _env_map(payload, path) + state_path = _state_path(path) + managed = {_BASE_URL_KEY: proxy_url, _TOOL_SEARCH_KEY: "true"} + + if state_path.exists(): + state = _read_object(state_path, label="Headroom state") + if state.get("version") != _STATE_VERSION or not isinstance(state.get("previous"), dict): + raise click.ClickException( + f"Headroom state {state_path} is unsupported or incomplete; refusing to edit." + ) + old_managed = state.get("managed") + if not isinstance(old_managed, dict) or any( + env.get(key) != old_managed.get(key) for key in _MANAGED_KEYS + ): + raise click.ClickException( + "Claude settings changed one of Headroom's managed values. Run " + "`headroom unwrap vscode-claude` or resolve the conflict before retrying." + ) + action = "updated" + else: + state = { + "version": _STATE_VERSION, + "settings_existed": path.exists(), + "previous": { + key: {"present": key in env, "value": env.get(key)} for key in _MANAGED_KEYS + }, + } + action = "added" + + env.update(managed) + payload["env"] = env + state["managed"] = managed + _write_json(path, payload) + _write_json(state_path, state) + return action + + +def remove_vscode_claude_settings(path: Path) -> bool: + """Restore the values saved before Headroom configured the VS Code extension.""" + state_path = _state_path(path) + if not state_path.exists(): + return False + state = _read_object(state_path, label="Headroom state") + previous = state.get("previous") + managed = state.get("managed") + if state.get("version") != _STATE_VERSION or not isinstance(previous, dict): + raise click.ClickException( + f"Headroom state {state_path} is unsupported or incomplete; refusing to edit." + ) + if not isinstance(managed, dict): + raise click.ClickException(f"Headroom state {state_path} has no managed values.") + + payload = _read_settings(path) + env = _env_map(payload, path) + if any(env.get(key) != managed.get(key) for key in _MANAGED_KEYS): + raise click.ClickException( + "Claude settings changed one of Headroom's managed values; refusing to overwrite " + f"the user's change. Resolve the conflict in {path}, then retry." + ) + + for key in _MANAGED_KEYS: + saved = previous.get(key) + if not isinstance(saved, dict) or not isinstance(saved.get("present"), bool): + raise click.ClickException( + f"Headroom state {state_path} is incomplete; refusing to edit." + ) + if saved["present"]: + env[key] = saved.get("value") + else: + env.pop(key, None) + if env: + payload["env"] = env + else: + payload.pop("env", None) + + if payload or state.get("settings_existed"): + _write_json(path, payload) + elif path.exists(): + path.unlink() + state_path.unlink() + return True diff --git a/tests/test_cli/test_wrap_helpers.py b/tests/test_cli/test_wrap_helpers.py index b2311b653..a1366b47e 100644 --- a/tests/test_cli/test_wrap_helpers.py +++ b/tests/test_cli/test_wrap_helpers.py @@ -213,7 +213,7 @@ def test_run_proxy_only_watcher_keyboardinterrupt_shuts_down_cleanly( 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.""" + """Windows console stop handlers must use the clean shutdown path.""" handlers: dict[int, Any] = {} cleanup_calls = {"n": 0} @@ -235,6 +235,9 @@ def test_run_proxy_only_watcher_signal_handler_uses_clean_shutdown( 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) + monkeypatch.setattr(wrap_mod.sys, "platform", "win32") + sigbreak = 999 + monkeypatch.setattr(wrap_mod.signal, "SIGBREAK", sigbreak, raising=False) runner = CliRunner() @@ -254,6 +257,7 @@ def test_run_proxy_only_watcher_signal_handler_uses_clean_shutdown( assert inv.exit_code == 0, inv.output assert "Shutting down..." in inv.output assert "Proxy process exited unexpectedly" not in inv.output + assert sigbreak in handlers assert cleanup_calls["n"] >= 2 # signal handler plus finally (idempotent) @@ -538,6 +542,27 @@ class TestProxyClientRefCounting: # Our own marker is removed before we count. assert wrap_mod._live_proxy_clients(self.PORT, exclude_self=False) == [] + def test_cleanup_stops_detached_windows_serving_child( + self, clients_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Ctrl+C must stop the listener even when its launcher already exited.""" + wrap_mod._register_proxy_client(self.PORT) + proc = _FakeProxyProc() + proc.poll = lambda: 0 # type: ignore[method-assign] + stopped: list[int] = [] + monkeypatch.setattr(wrap_mod.sys, "platform", "win32") + monkeypatch.setattr(wrap_mod, "_check_proxy", lambda port: port == self.PORT) + monkeypatch.setattr( + wrap_mod, + "_stop_local_proxy_for_unwrap", + lambda port: stopped.append(port) or "stopped", + ) + + wrap_mod._make_cleanup([proc], self.PORT)() + + assert not proc.terminated + assert stopped == [self.PORT] + def test_cleanup_leaves_proxy_running_when_other_client_alive(self, clients_dir: Path) -> None: """A second live client (here: the test's parent) keeps the proxy up.""" wrap_mod._register_proxy_client(self.PORT) diff --git a/tests/test_cli/test_wrap_vscode_claude.py b/tests/test_cli/test_wrap_vscode_claude.py new file mode 100644 index 000000000..ec3a37662 --- /dev/null +++ b/tests/test_cli/test_wrap_vscode_claude.py @@ -0,0 +1,62 @@ +"""CLI coverage for Claude Code inside VS Code.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from headroom.cli.main import main + + +def test_wrap_vscode_claude_configures_actual_port(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._run_proxy_only_watcher", side_effect=fake_watcher): + result = CliRunner().invoke(main, ["wrap", "vscode-claude", "--settings-file", str(path)]) + + assert result.exit_code == 0, result.output + env = json.loads(path.read_text(encoding="utf-8"))["env"] + assert env["ANTHROPIC_BASE_URL"].startswith("http://127.0.0.1:9999/p/") + assert env["ENABLE_TOOL_SEARCH"] == "true" + assert "Reload VS Code" in result.output + assert captured["agent_type"] == "claude" + + +def test_wrap_vscode_claude_no_configure_prints_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._run_proxy_only_watcher", side_effect=fake_watcher): + result = CliRunner().invoke( + main, + ["wrap", "vscode-claude", "--no-configure", "--settings-file", str(path)], + ) + + assert result.exit_code == 0, result.output + assert not path.exists() + assert "ANTHROPIC_BASE_URL" in result.output + assert "ENABLE_TOOL_SEARCH" in result.output + + +def test_unwrap_vscode_claude_restores_previous_settings(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + original = {"env": {"KEEP": "1"}, "permissions": {"allow": ["Read"]}} + path.write_text(json.dumps(original), encoding="utf-8") + + from headroom.providers.claude.vscode import configure_vscode_claude_settings + + configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo") + result = CliRunner().invoke(main, ["unwrap", "vscode-claude", "--settings-file", str(path)]) + + assert result.exit_code == 0, result.output + assert json.loads(path.read_text(encoding="utf-8")) == original diff --git a/tests/test_provider_claude_vscode_config.py b/tests/test_provider_claude_vscode_config.py new file mode 100644 index 000000000..61f9e73de --- /dev/null +++ b/tests/test_provider_claude_vscode_config.py @@ -0,0 +1,182 @@ +"""Tests for reversible Claude Code VS Code configuration.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import click +import pytest + +from headroom.providers.claude.vscode import ( + claude_user_settings_path, + configure_vscode_claude_settings, + remove_vscode_claude_settings, + vscode_claude_proxy_url, +) + + +def test_settings_path_honors_claude_config_dir(tmp_path: Path) -> None: + assert claude_user_settings_path({"CLAUDE_CONFIG_DIR": str(tmp_path)}) == ( + tmp_path / "settings.json" + ) + + +def test_settings_path_uses_windows_profile() -> None: + path = claude_user_settings_path( + {"HOME": "/wrong", "USERPROFILE": r"C:\\Users\\claude"}, platform="win32" + ) + assert path == Path(r"C:\\Users\\claude") / ".claude" / "settings.json" + + +def test_proxy_url_is_project_scoped() -> None: + assert vscode_claude_proxy_url(8787, "my project").endswith("/p/my%20project") + + +def test_configure_and_remove_preserve_unrelated_and_previous_values(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "permissions": {"allow": ["Read"]}, + "env": { + "KEEP": "yes", + "ANTHROPIC_BASE_URL": "https://gateway.example", + "ENABLE_TOOL_SEARCH": "false", + }, + } + ), + encoding="utf-8", + ) + + assert configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo") == "added" + configured = json.loads(path.read_text(encoding="utf-8")) + assert configured["env"] == { + "KEEP": "yes", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/p/demo", + "ENABLE_TOOL_SEARCH": "true", + } + assert configured["permissions"] == {"allow": ["Read"]} + + assert remove_vscode_claude_settings(path) + restored = json.loads(path.read_text(encoding="utf-8")) + assert restored["env"] == { + "KEEP": "yes", + "ANTHROPIC_BASE_URL": "https://gateway.example", + "ENABLE_TOOL_SEARCH": "false", + } + assert restored["permissions"] == {"allow": ["Read"]} + assert not (tmp_path / ".headroom-vscode-claude.json").exists() + + +def test_reconfigure_updates_port_without_losing_original_values(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text('{"env":{"ANTHROPIC_BASE_URL":"https://original.example"}}', encoding="utf-8") + + configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo") + assert configure_vscode_claude_settings(path, "http://127.0.0.1:9999/p/demo") == "updated" + assert remove_vscode_claude_settings(path) + assert json.loads(path.read_text(encoding="utf-8"))["env"] == { + "ANTHROPIC_BASE_URL": "https://original.example" + } + + +def test_remove_deletes_settings_created_only_for_headroom(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo") + assert path.exists() + assert remove_vscode_claude_settings(path) + assert not path.exists() + + +def test_configure_refuses_malformed_settings(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text("{broken", encoding="utf-8") + with pytest.raises(click.ClickException, match="not valid JSON"): + configure_vscode_claude_settings(path, "http://127.0.0.1:8787") + assert path.read_text(encoding="utf-8") == "{broken" + + +@pytest.mark.parametrize("contents", ["[]", '{"env": []}']) +def test_configure_refuses_unsafe_settings_shapes(tmp_path: Path, contents: str) -> None: + path = tmp_path / "settings.json" + path.write_text(contents, encoding="utf-8") + with pytest.raises(click.ClickException, match="refusing to overwrite"): + configure_vscode_claude_settings(path, "http://127.0.0.1:8787") + assert path.read_text(encoding="utf-8") == contents + + +def test_configure_refuses_unreadable_settings(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text("{}", encoding="utf-8") + with ( + patch("headroom.providers.claude.vscode.fsutil.read_text", side_effect=OSError("denied")), + pytest.raises(click.ClickException, match="Could not read Claude settings"), + ): + configure_vscode_claude_settings(path, "http://127.0.0.1:8787") + + +def test_empty_existing_settings_is_restored_as_existing_file(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text("", encoding="utf-8") + configure_vscode_claude_settings(path, "http://127.0.0.1:8787") + assert remove_vscode_claude_settings(path) + assert json.loads(path.read_text(encoding="utf-8")) == {} + + +def test_remove_without_headroom_state_is_noop(tmp_path: Path) -> None: + assert not remove_vscode_claude_settings(tmp_path / "settings.json") + + +@pytest.mark.parametrize( + ("state_update", "message"), + [ + ({"version": 2}, "unsupported or incomplete"), + ({"managed": None}, "has no managed values"), + ({"previous": {"ANTHROPIC_BASE_URL": None}}, "is incomplete"), + ], +) +def test_remove_refuses_incomplete_state( + tmp_path: Path, state_update: dict[str, object], message: str +) -> None: + path = tmp_path / "settings.json" + configure_vscode_claude_settings(path, "http://127.0.0.1:8787") + state_path = tmp_path / ".headroom-vscode-claude.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state.update(state_update) + state_path.write_text(json.dumps(state), encoding="utf-8") + with pytest.raises(click.ClickException, match=message): + remove_vscode_claude_settings(path) + + +def test_reconfigure_refuses_incomplete_or_conflicting_state(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + proxy_url = "http://127.0.0.1:8787" + configure_vscode_claude_settings(path, proxy_url) + state_path = tmp_path / ".headroom-vscode-claude.json" + state_path.write_text("{}", encoding="utf-8") + with pytest.raises(click.ClickException, match="unsupported or incomplete"): + configure_vscode_claude_settings(path, proxy_url) + + state_path.unlink() + configure_vscode_claude_settings(path, proxy_url) + payload = json.loads(path.read_text(encoding="utf-8")) + payload["env"]["ENABLE_TOOL_SEARCH"] = "false" + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(click.ClickException, match="managed values"): + configure_vscode_claude_settings(path, proxy_url) + + +def test_remove_refuses_to_overwrite_changed_managed_value(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + configure_vscode_claude_settings(path, "http://127.0.0.1:8787/p/demo") + payload = json.loads(path.read_text(encoding="utf-8")) + payload["env"]["ANTHROPIC_BASE_URL"] = "https://user-change.example" + path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(click.ClickException, match="refusing to overwrite"): + remove_vscode_claude_settings(path) + assert json.loads(path.read_text(encoding="utf-8"))["env"]["ANTHROPIC_BASE_URL"] == ( + "https://user-change.example" + )