feat(claude): support Claude Code in VS Code (#2752)

## 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 <jd@jds-macbook-air.tail2a279.ts.net>
This commit is contained in:
JD Davis 2026-08-03 22:14:13 -05:00 committed by GitHub
parent 08fce29b47
commit 13a310a00d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 810 additions and 7 deletions

View file

@ -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) 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. 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 ## When to use · When to skip
**Great fit if you…** **Great fit if you…**

View file

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

View file

@ -567,6 +567,9 @@ Use `headroom wrap` to launch supported CLI agents through the local proxy:
# Claude Code # Claude Code
headroom wrap claude headroom wrap claude
# Claude Code extension in VS Code (configures settings, then starts the proxy)
headroom wrap vscode-claude
# OpenAI Codex # OpenAI Codex
headroom wrap 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 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). 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: For environment-driven clients, you can also set the base URL manually:
```bash ```bash

View file

@ -203,6 +203,13 @@ Transforms: ['smart_crusher', 'cache_aligner']
## Alternative: proxy mode (zero code changes) ## Alternative: proxy mode (zero code changes)
<Callout type="info" title="Using Claude Code inside VS Code?">
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.
</Callout>
If you do not want to change any code, run Headroom as a proxy and point your existing client at it: If you do not want to change any code, run Headroom as a proxy and point your existing client at it:
```bash ```bash

View file

@ -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.
<Callout type="info" title="Using Claude Code in a terminal instead?">
Use `headroom wrap claude`. This page is specifically for Anthropic's official
Claude Code extension inside VS Code.
</Callout>
## 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.

View file

@ -37,6 +37,7 @@ OPENHANDS_PORT = 28895
OPENCODE_PORT = 28896 OPENCODE_PORT = 28896
CLAUDE_PORT = 28897 CLAUDE_PORT = 28897
VSCODE_PORT = 28898 VSCODE_PORT = 28898
VSCODE_CLAUDE_PORT = 28899
MOCK_UPSTREAM_PORT = 19001 MOCK_UPSTREAM_PORT = 19001
@ -174,6 +175,20 @@ class MockOpenAIHandler(BaseHTTPRequestHandler):
}, },
) )
return 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"}}) 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: def verify_cline_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Smoke test: `wrap cline --prepare-only` exits clean. """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_aider_wrap(base_env, project_dir, log_dir)
verify_cursor_wrap(base_env, project_dir) verify_cursor_wrap(base_env, project_dir)
verify_vscode_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_cline_wrap(base_env, project_dir)
verify_continue_wrap(base_env, project_dir) verify_continue_wrap(base_env, project_dir)
verify_goose_wrap(base_env, project_dir) verify_goose_wrap(base_env, project_dir)

View file

@ -4,6 +4,7 @@ Usage:
headroom wrap claude # Start proxy + claude headroom wrap claude # Start proxy + claude
headroom wrap copilot -- --model ... # Start proxy + launch GitHub Copilot CLI headroom wrap copilot -- --model ... # Start proxy + launch GitHub Copilot CLI
headroom wrap vscode # Transparently proxy VS Code Copilot 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 codex # Start proxy + OpenAI Codex CLI
headroom wrap aider # Start proxy + aider headroom wrap aider # Start proxy + aider
headroom wrap openclaude # Start proxy + OpenClaude headroom wrap openclaude # Start proxy + OpenClaude
@ -73,11 +74,15 @@ from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV, REMOTE_CONTROL_BASE_URL_ENV,
TOOL_SEARCH_DEFAULT, TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV, TOOL_SEARCH_ENV,
claude_user_settings_path,
configure_vscode_claude_settings,
detect_claude_code_version, detect_claude_code_version,
remote_control_applies_to_auth, remote_control_applies_to_auth,
remote_control_gate_active, remote_control_gate_active,
remote_control_gate_message, remote_control_gate_message,
remote_control_sibling_gate_note, remote_control_sibling_gate_note,
remove_vscode_claude_settings,
vscode_claude_proxy_url,
) )
from headroom.providers.claude import ( from headroom.providers.claude import (
proxy_base_url as _claude_proxy_base_url, 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.SIGINT, _signal_shutdown)
signal.signal(signal.SIGTERM, _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: try:
_print_wrap_banner(agent_label) _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 p = port[0] if isinstance(port, list) else port
_unregister_proxy_client(p) _unregister_proxy_client(p)
proc = proxy_proc_holder[0] if proxy_proc_holder else None proc = proxy_proc_holder[0] if proxy_proc_holder else None
if proc and proc.poll() is None: if proc:
if _other_clients_exist(): if _other_clients_exist():
# Other clients still using the proxy — leave it running. # Other clients still using the proxy — leave it running.
return return
proc.terminate() if proc.poll() is None:
try: proc.terminate()
proc.wait(timeout=5) try:
except subprocess.TimeoutExpired: proc.wait(timeout=5)
proc.kill() 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 return cleanup
@ -4250,6 +4274,7 @@ def wrap(ctx: click.Context) -> None:
headroom wrap codex # OpenAI Codex CLI headroom wrap codex # OpenAI Codex CLI
headroom wrap copilot -- --model claude-sonnet-4-20250514 headroom wrap copilot -- --model claude-sonnet-4-20250514
headroom wrap vscode # VS Code Copilot (preserves model picker) headroom wrap vscode # VS Code Copilot (preserves model picker)
headroom wrap vscode-claude # VS Code Claude Code extension
headroom wrap aider # Aider headroom wrap aider # Aider
headroom wrap openclaude # OpenClaude headroom wrap openclaude # OpenClaude
headroom wrap vibe # Mistral Vibe 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}") 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) # GitHub Copilot CLI (unwrap)
# ============================================================================= # =============================================================================

View file

@ -17,8 +17,18 @@ from .runtime import (
remote_control_gate_message, remote_control_gate_message,
remote_control_sibling_gate_note, 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__ = [ __all__ = [
"claude_user_settings_path",
"configure_vscode_claude_settings",
"remove_vscode_claude_settings",
"vscode_claude_proxy_url",
"DEFAULT_API_URL", "DEFAULT_API_URL",
"REMOTE_CONTROL_BASE_URL_ENV", "REMOTE_CONTROL_BASE_URL_ENV",
"REMOTE_CONTROL_GATED_MIN_VERSION", "REMOTE_CONTROL_GATED_MIN_VERSION",

View file

@ -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

View file

@ -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( def test_run_proxy_only_watcher_signal_handler_uses_clean_shutdown(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> 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] = {} handlers: dict[int, Any] = {}
cleanup_calls = {"n": 0} 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.time, "sleep", trigger_sigint)
monkeypatch.setattr(wrap_mod, "_make_cleanup", lambda holder, port: cleanup) monkeypatch.setattr(wrap_mod, "_make_cleanup", lambda holder, port: cleanup)
monkeypatch.setattr(wrap_mod.signal, "signal", capture_handler) 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() 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 inv.exit_code == 0, inv.output
assert "Shutting down..." in inv.output assert "Shutting down..." in inv.output
assert "Proxy process exited unexpectedly" not 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) assert cleanup_calls["n"] >= 2 # signal handler plus finally (idempotent)
@ -538,6 +542,27 @@ class TestProxyClientRefCounting:
# Our own marker is removed before we count. # Our own marker is removed before we count.
assert wrap_mod._live_proxy_clients(self.PORT, exclude_self=False) == [] 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: 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.""" """A second live client (here: the test's parent) keeps the proxy up."""
wrap_mod._register_proxy_client(self.PORT) wrap_mod._register_proxy_client(self.PORT)

View file

@ -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

View file

@ -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"
)