fix(vscode): persist compatible Claude modes and route Copilot CAPI (#2986)

## Description

Fixes #2492, #2028, and #2827.

Claude daemon workers consume project settings rather than reliably
inheriting wrapper environment state, while the Claude VS Code webview
cannot render deferred-tool response blocks. Separately, recent Copilot
Chat versions use the whole CAPI override for generation; the legacy
proxy override alone only sends model discovery through Headroom.

This PR carries both integrations through to the actual consumers
instead of only changing their launch-time surface configuration.

## Type of Change

- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
- [ ] Build / CI

## Changes Made

- Persist the resolved Claude ENABLE_TOOL_SEARCH value into project
settings for daemon workers and restore it transactionally after wrap
exits.
- Use compatibility-safe Foundry and Claude VS Code defaults while
preserving explicit user choices.
- Configure both Copilot overrideProxyUrl and overrideCapiUrl in the
reversible managed VS Code settings block.
- Route Copilot unprefixed POST /chat/completions and HTTP /responses
requests through the real compression handlers.
- Keep /responses out of the Codex WebSocket aliases because Copilot and
Codex use different WebSocket wire protocols.
- Extend wrap E2E assertions for both the Claude webview mode and
Copilot CAPI routing.

## Testing

- [x] 127 combined Claude, Copilot, route-integration, and MCP
dependency-contract tests pass.
- [x] Ruff check passes on all changed Python files.
- [x] Ruff format check passes.
- [x] Python compilation and git diff --check pass.

## Runtime Safety

Standalone Claude CLI defaults remain unchanged. Explicit Claude
tool-search values retain precedence, and project settings are restored
through the existing cleanup path. Copilot model/session helper
endpoints continue through generic passthrough, while only validated
HTTP generation paths receive explicit compression routes. Existing
Codex WebSocket behavior is unchanged.

## Review Readiness

- [x] Current main and MCP v1 compatibility retained
- [x] Worker-facing Claude persistence covered
- [x] Reversible Copilot and Claude settings behavior covered
- [x] Copilot generation routes covered at registration and proxy
integration layers
- [x] Ready for review
This commit is contained in:
JD Davis
2026-08-13 15:06:41 -05:00
committed by GitHub
parent eafdf11a2c
commit 1aa701adaa
17 changed files with 162 additions and 13 deletions
+7 -2
View File
@@ -790,6 +790,11 @@ def verify_vscode_wrap(base_env: dict[str, str], project_dir: Path) -> None:
f'"http://127.0.0.1:{port}{project_prefix}"' in configured,
"VS Code wrap should configure the project-scoped proxy URL",
)
assert_true(
f'"github.copilot.advanced.debug.overrideCapiUrl": '
f'"http://127.0.0.1:{port}{project_prefix}"' in configured,
"VS Code wrap should route Copilot Chat generation through Headroom",
)
assert_true(
'"github.copilot.advanced.debug.overrideAuthType": "token"' in configured,
"VS Code wrap should configure token auth",
@@ -849,8 +854,8 @@ def verify_vscode_claude_wrap(base_env: dict[str, str], project_dir: Path) -> No
"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",
configured["env"]["ENABLE_TOOL_SEARCH"] == "false",
"VS Code Claude wrap should disable tool deferral for webview compatibility",
)
assert_true(configured["env"]["KEEP"] == "yes", "Existing Claude env must remain")
assert_true(str(settings_path) in output, "Wrap output should identify Claude settings")
+7 -1
View File
@@ -41,6 +41,7 @@ from headroom.install.runtime import (
from headroom.install.state import ManifestError, load_manifest, save_manifest
from headroom.install.supervisors import start_supervisor
from headroom.providers.claude import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV
from headroom.providers.claude.runtime import TOOL_SEARCH_FOUNDRY_DEFAULT
from headroom.providers.codex.install import codex_uses_chatgpt_auth
from headroom.providers.codex.threads import retag_to_headroom
@@ -181,7 +182,12 @@ def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None:
# all into its context window — overflowing it (breaks sub-agent spawns,
# forces constant compaction). Keep deferral on; respect a user-set value.
# Shares the TOOL_SEARCH_* constants with `wrap` and `install`.
env_map.setdefault(TOOL_SEARCH_ENV, TOOL_SEARCH_DEFAULT)
tool_search_default = (
TOOL_SEARCH_FOUNDRY_DEFAULT
if os.environ.get("CLAUDE_CODE_USE_FOUNDRY")
else TOOL_SEARCH_DEFAULT
)
env_map.setdefault(TOOL_SEARCH_ENV, tool_search_default)
payload["env"] = env_map
hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}
+49 -2
View File
@@ -451,6 +451,8 @@ def _resolved_tool_search_mode(flag_value: str | None) -> str:
existing = os.environ.get(_TOOL_SEARCH_ENV)
if existing is not None:
probe[_TOOL_SEARCH_ENV] = existing
if os.environ.get("CLAUDE_CODE_USE_FOUNDRY"):
probe["CLAUDE_CODE_USE_FOUNDRY"] = os.environ["CLAUDE_CODE_USE_FOUNDRY"]
written = _configure_tool_search_env(probe, flag_value)
return written if written is not None else probe.get(_TOOL_SEARCH_ENV, "")
@@ -1478,6 +1480,36 @@ def _write_claude_wrap_base_url(
return previous
def _write_claude_wrap_tool_search(value: str, *, settings_path: Path | None = None) -> str | None:
"""Persist the resolved tool-search mode for daemon-spawned workers.
Claude Code workers read project settings afresh rather than inheriting
the parent process environment (#2492). Keep this separate from the proxy
URL crash marker: a stale tool-search mode cannot route traffic to a dead
process, and is restored transactionally when the wrap session exits.
"""
path = settings_path or (Path.cwd() / ".claude" / "settings.local.json")
payload = _read_settings_for_write(path)
env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {}
previous = env_map.get(_TOOL_SEARCH_ENV)
env_map[_TOOL_SEARCH_ENV] = value
payload["env"] = env_map
path.parent.mkdir(parents=True, exist_ok=True)
_write_text(path, json.dumps(payload, indent=2) + "\n")
return previous
def _restore_claude_wrap_tool_search(
previous: str | None, *, settings_path: Path | None = None
) -> None:
"""Restore the project-local tool-search value written for this session."""
_restore_claude_wrap_base_url(
previous,
settings_path=settings_path,
_key_override=_TOOL_SEARCH_ENV,
)
def _restore_claude_wrap_base_url(
previous: str | None,
*,
@@ -4703,6 +4735,8 @@ def claude(
proxy_holder: list[subprocess.Popen | None] = [None]
_saved_base_url: list[str | None] = [None] # previous settings.json value for restore
_tool_search_not_written = object()
_saved_tool_search: list[object | str | None] = [_tool_search_not_written]
_settings_foundry: list[bool] = [False]
port_holder: list[int] = [port]
_settings_vertex: list[bool] = [False]
@@ -4934,6 +4968,11 @@ def claude(
# Issue #746: keep Claude Code's on-demand tool loading on through the
# proxy so tool schemas are not eagerly materialized into local context.
_tool_search_value = _configure_tool_search_env(env, tool_search)
_resolved_tool_search_value = env.get(_TOOL_SEARCH_ENV, "")
_saved_tool_search[0] = _write_claude_wrap_tool_search(
_resolved_tool_search_value,
settings_path=_wrap_settings_path,
)
if _tool_search_value is not None:
# Describe what the written value actually does: --tool-search
# false/0/no/off turns deferral OFF, and the banner must say so
@@ -4979,6 +5018,11 @@ def claude(
click.echo(f" Error: {e}")
raise SystemExit(1) from e
finally:
if _saved_tool_search[0] is not _tool_search_not_written:
_restore_claude_wrap_tool_search(
cast(str | None, _saved_tool_search[0]),
settings_path=_wrap_settings_path,
)
_restore_claude_wrap_base_url(
_saved_base_url[0],
foundry_mode=_settings_foundry[0],
@@ -5435,8 +5479,8 @@ def vscode_copilot(
) -> 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.
Transparently overrides Copilot's proxy and CAPI endpoints, 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()
@@ -5456,6 +5500,9 @@ def vscode_copilot(
click.echo(
f' "github.copilot.advanced.debug.overrideProxyUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",'
)
click.echo(
f' "github.copilot.advanced.debug.overrideCapiUrl": "{vscode_proxy_url(actual_port, _project_name_from_cwd())}",'
)
click.echo(' "github.copilot.advanced.debug.overrideAuthType": "token"')
_run_proxy_only_watcher(
+5 -1
View File
@@ -89,7 +89,11 @@ def configure_vscode_claude_settings(path: Path, proxy_url: str) -> str:
payload = _read_settings(path)
env = _env_map(payload, path)
state_path = _state_path(path)
managed = {_BASE_URL_KEY: proxy_url, _TOOL_SEARCH_KEY: "true"}
# Claude Code's VS Code webview cannot render the server_tool_use /
# tool_search_tool_result blocks emitted by deferred tool search (#2028).
# Keep it disabled for this surface; the standalone CLI retains its own
# configurable/default-on policy.
managed = {_BASE_URL_KEY: proxy_url, _TOOL_SEARCH_KEY: "false"}
if state_path.exists():
state = _read_object(state_path, label="Headroom state")
+3 -1
View File
@@ -17,6 +17,7 @@ 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"
_CAPI_KEY = "github.copilot.advanced.debug.overrideCapiUrl"
_AUTH_KEY = "github.copilot.advanced.debug.overrideAuthType"
@@ -118,6 +119,7 @@ def _managed_block(proxy_url: str, *, owns_preceding_comma: bool, line_sep: str)
return (
f"\t{marker}{line_sep}"
f"\t{json.dumps(_PROXY_KEY)}: {json.dumps(proxy_url)},{line_sep}"
f"\t{json.dumps(_CAPI_KEY)}: {json.dumps(proxy_url)},{line_sep}"
f'\t{json.dumps(_AUTH_KEY)}: "token"{line_sep}'
f"\t{_MARKER_END}"
)
@@ -161,7 +163,7 @@ def configure_vscode_proxy_settings(path: Path, proxy_url: str) -> str:
if had_managed_block:
remove_vscode_proxy_settings(path)
raw = _read_settings(path)
elif _PROXY_KEY in raw or _AUTH_KEY in raw:
elif _PROXY_KEY in raw or _CAPI_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."
+11 -1
View File
@@ -32,9 +32,19 @@ OPENAI_RESPONSES_ROOT_PATHS: tuple[str, ...] = (
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
# Copilot Chat derives this unprefixed path from overrideCapiUrl. Without an
# explicit root route it falls through to uncompressed generic passthrough.
"/responses",
)
OPENAI_RESPONSES_WEBSOCKET_PATHS: tuple[str, ...] = OPENAI_RESPONSES_ROOT_PATHS
# The Codex websocket relay speaks a different protocol; do not register the
# Copilot HTTP alias as a websocket route without separate wire validation.
OPENAI_RESPONSES_WEBSOCKET_PATHS: tuple[str, ...] = (
"/v1/responses",
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
)
OPENAI_RESPONSES_SUBPATH_ROUTES: tuple[OpenAIResponsesSubpathRoute, ...] = (
OpenAIResponsesSubpathRoute("/v1/responses/{sub_path:path}", ("GET", "POST", "DELETE")),
+3
View File
@@ -105,6 +105,9 @@ ANTHROPIC_BATCH_ROUTES: tuple[ProviderHandlerRoute, ...] = (
OPENAI_HANDLER_ROUTES: tuple[ProviderHandlerRoute, ...] = (
ProviderHandlerRoute("POST", "/v1/chat/completions", "handle_openai_chat"),
# Copilot Chat derives this unprefixed path from overrideCapiUrl. Route it
# through the real handler; the generic catch-all would bypass compression.
ProviderHandlerRoute("POST", "/chat/completions", "handle_openai_chat"),
)
+12 -1
View File
@@ -16,7 +16,8 @@ from headroom.cli import init as init_cli
from headroom.providers.claude import install as claude_install
def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path) -> None:
def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("CLAUDE_CODE_USE_FOUNDRY", raising=False)
settings = tmp_path / "settings.json"
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
@@ -25,6 +26,16 @@ def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path) -> None:
assert env["ENABLE_TOOL_SEARCH"] == "true"
def test_ensure_claude_hooks_disables_tool_search_for_foundry(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("CLAUDE_CODE_USE_FOUNDRY", "1")
settings = tmp_path / "settings.json"
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
env = json.loads(settings.read_text(encoding="utf-8"))["env"]
assert env["ENABLE_TOOL_SEARCH"] == "false"
def test_ensure_claude_hooks_respects_user_tool_search_value(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
settings.write_text(
@@ -34,6 +34,29 @@ def test_write_preserves_other_env_keys(tmp_path: Path) -> None:
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
def test_tool_search_write_and_restore_reaches_daemon_worker_settings(tmp_path: Path) -> None:
path = _settings(tmp_path)
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({"env": {"ENABLE_TOOL_SEARCH": "true", "KEEP": "1"}}),
encoding="utf-8",
)
previous = wrap_cli._write_claude_wrap_tool_search("false", settings_path=path)
assert previous == "true"
assert json.loads(path.read_text(encoding="utf-8"))["env"] == {
"ENABLE_TOOL_SEARCH": "false",
"KEEP": "1",
}
wrap_cli._restore_claude_wrap_tool_search(previous, settings_path=path)
assert json.loads(path.read_text(encoding="utf-8"))["env"] == {
"ENABLE_TOOL_SEARCH": "true",
"KEEP": "1",
}
def test_write_returns_none_when_key_absent(tmp_path: Path) -> None:
path = _settings(tmp_path)
prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
@@ -78,6 +78,13 @@ def _invoke_wrap_claude(
monkeypatch.setattr(wrap_mod, "_write_claude_wrap_base_url", fake_write_base_url)
monkeypatch.setattr(wrap_mod, "_restore_claude_wrap_base_url", lambda *_args, **_kwargs: None)
def fake_write_tool_search(value: str, **kwargs: object) -> None:
captured["write_tool_search_value"] = value
captured["write_tool_search_kwargs"] = kwargs
monkeypatch.setattr(wrap_mod, "_write_claude_wrap_tool_search", fake_write_tool_search)
monkeypatch.setattr(wrap_mod, "_restore_claude_wrap_tool_search", lambda *_a, **_k: None)
monkeypatch.setattr(wrap_mod, "_print_telemetry_notice", lambda: None)
def fake_ensure_proxy(*args: object, **kwargs: object) -> tuple[None, int]:
@@ -209,6 +216,24 @@ def test_wrap_claude_tool_search_banner_line_still_accurate_when_active(
assert "on-demand tool loading kept on" in output
assert "keeps it on for this session" in output
assert "DISABLED per your setting" not in output
assert _captured["write_tool_search_value"] == "true"
def test_wrap_claude_foundry_persists_disabled_tool_search_for_workers(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
captured, output = _invoke_wrap_claude(
runner,
monkeypatch,
env={
"CLAUDE_CODE_USE_FOUNDRY": "1",
"ANTHROPIC_FOUNDRY_BASE_URL": "https://tenant.services.ai.azure.com/anthropic",
},
)
assert captured["child_env"]["ENABLE_TOOL_SEARCH"] == "false"
assert captured["write_tool_search_value"] == "false"
assert "on-demand tool loading DISABLED" in output
def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirect(
+1
View File
@@ -66,6 +66,7 @@ def test_wrap_vscode_no_configure_prints_transparent_settings(tmp_path: Path) ->
assert result.exit_code == 0, result.output
assert not path.exists()
assert "overrideProxyUrl" in result.output
assert "overrideCapiUrl" in result.output
assert "overrideAuthType" in result.output
+1 -1
View File
@@ -25,7 +25,7 @@ def test_wrap_vscode_claude_configures_actual_port(tmp_path: Path) -> None:
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 env["ENABLE_TOOL_SEARCH"] == "false"
assert "Reload VS Code" in result.output
assert captured["agent_type"] == "claude"
+2 -2
View File
@@ -55,7 +55,7 @@ def test_configure_and_remove_preserve_unrelated_and_previous_values(tmp_path: P
assert configured["env"] == {
"KEEP": "yes",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787/p/demo",
"ENABLE_TOOL_SEARCH": "true",
"ENABLE_TOOL_SEARCH": "false",
}
assert configured["permissions"] == {"allow": ["Read"]}
@@ -162,7 +162,7 @@ def test_reconfigure_refuses_incomplete_or_conflicting_state(tmp_path: Path) ->
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"
payload["env"]["ENABLE_TOOL_SEARCH"] = "true"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(click.ClickException, match="managed values"):
configure_vscode_claude_settings(path, proxy_url)
@@ -50,6 +50,7 @@ def test_configure_update_and_remove_preserve_jsonc_verbatim(tmp_path: Path) ->
assert "editor.fontSize" in configured
assert "user comment" in configured
assert '"github.copilot.advanced.debug.overrideProxyUrl"' in configured
assert '"github.copilot.advanced.debug.overrideCapiUrl"' in configured
assert '"github.copilot.advanced.debug.overrideAuthType": "token"' in configured
assert configure_vscode_proxy_settings(path, "http://127.0.0.1:9999") == "updated"
@@ -66,6 +67,7 @@ def test_configure_refuses_malformed_and_unmanaged_override(tmp_path: Path) -> N
for original in (
"{broken",
'{"github.copilot.advanced.debug.overrideProxyUrl":"http://other"}',
'{"github.copilot.advanced.debug.overrideCapiUrl":"http://other"}',
):
path.write_text(original, encoding="utf-8")
with pytest.raises(click.ClickException, match="did not overwrite|refusing"):
+8 -1
View File
@@ -23,8 +23,15 @@ def test_openai_responses_route_aliases_are_explicit() -> None:
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
"/responses",
)
assert OPENAI_RESPONSES_WEBSOCKET_PATHS == OPENAI_RESPONSES_ROOT_PATHS
assert OPENAI_RESPONSES_WEBSOCKET_PATHS == (
"/v1/responses",
"/v1/codex/responses",
"/backend-api/responses",
"/backend-api/codex/responses",
)
assert "/responses" not in OPENAI_RESPONSES_WEBSOCKET_PATHS
assert OPENAI_RESPONSES_SUBPATH_ROUTES == (
OpenAIResponsesSubpathRoute("/v1/responses/{sub_path:path}", ("GET", "POST", "DELETE")),
OpenAIResponsesSubpathRoute(
+2
View File
@@ -496,7 +496,9 @@ def test_provider_specific_routes_delegate_to_expected_proxy_handlers(monkeypatc
"handle_anthropic_batch_passthrough"
)
assert client.post("/v1/chat/completions").json()["handler"] == "handle_openai_chat"
assert client.post("/chat/completions").json()["handler"] == "handle_openai_chat"
assert client.post("/v1/responses").json()["handler"] == "handle_openai_responses"
assert client.post("/responses").json()["handler"] == "handle_openai_responses"
assert client.post("/v1/codex/responses").json()["handler"] == "handle_openai_responses"
assert client.post("/backend-api/responses").json()["handler"] == "handle_openai_responses"
assert client.post("/backend-api/codex/responses").json()["handler"] == (
+1
View File
@@ -82,6 +82,7 @@ def test_direct_handler_routes_model_endpoint_intent() -> None:
)
assert OPENAI_HANDLER_ROUTES == (
ProviderHandlerRoute("POST", "/v1/chat/completions", "handle_openai_chat"),
ProviderHandlerRoute("POST", "/chat/completions", "handle_openai_chat"),
)
assert GEMINI_HANDLER_ROUTES == (
ProviderHandlerRoute(