diff --git a/e2e/wrap/run.py b/e2e/wrap/run.py index 0120adc00..c28a8b1a9 100644 --- a/e2e/wrap/run.py +++ b/e2e/wrap/run.py @@ -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") diff --git a/headroom/cli/init.py b/headroom/cli/init.py index 8a153ee22..2b6f4bf4c 100644 --- a/headroom/cli/init.py +++ b/headroom/cli/init.py @@ -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 {} diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 8f1971a13..ee91a14a3 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -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( diff --git a/headroom/providers/claude/vscode.py b/headroom/providers/claude/vscode.py index 8f7e62486..79f9f3889 100644 --- a/headroom/providers/claude/vscode.py +++ b/headroom/providers/claude/vscode.py @@ -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") diff --git a/headroom/providers/copilot/vscode.py b/headroom/providers/copilot/vscode.py index 2a0fbc85b..354a4efe5 100644 --- a/headroom/providers/copilot/vscode.py +++ b/headroom/providers/copilot/vscode.py @@ -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." diff --git a/headroom/providers/openai_responses.py b/headroom/providers/openai_responses.py index e09adc42d..2e10b030f 100644 --- a/headroom/providers/openai_responses.py +++ b/headroom/providers/openai_responses.py @@ -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")), diff --git a/headroom/providers/route_specs.py b/headroom/providers/route_specs.py index 2aaabc9a4..296269443 100644 --- a/headroom/providers/route_specs.py +++ b/headroom/providers/route_specs.py @@ -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"), ) diff --git a/tests/test_cli/test_init_enable_tool_search.py b/tests/test_cli/test_init_enable_tool_search.py index de65e56ed..6606163ae 100644 --- a/tests/test_cli/test_init_enable_tool_search.py +++ b/tests/test_cli/test_init_enable_tool_search.py @@ -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( diff --git a/tests/test_cli/test_wrap_claude_base_url.py b/tests/test_cli/test_wrap_claude_base_url.py index 5c252877e..591ba9c1d 100644 --- a/tests/test_cli/test_wrap_claude_base_url.py +++ b/tests/test_cli/test_wrap_claude_base_url.py @@ -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) diff --git a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py index 69c8a03f9..e4ec5ea14 100644 --- a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py +++ b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py @@ -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( diff --git a/tests/test_cli/test_wrap_vscode.py b/tests/test_cli/test_wrap_vscode.py index 6d3496991..fccab5cac 100644 --- a/tests/test_cli/test_wrap_vscode.py +++ b/tests/test_cli/test_wrap_vscode.py @@ -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 diff --git a/tests/test_cli/test_wrap_vscode_claude.py b/tests/test_cli/test_wrap_vscode_claude.py index ec3a37662..e57029240 100644 --- a/tests/test_cli/test_wrap_vscode_claude.py +++ b/tests/test_cli/test_wrap_vscode_claude.py @@ -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" diff --git a/tests/test_provider_claude_vscode_config.py b/tests/test_provider_claude_vscode_config.py index 61f9e73de..1e470a3f9 100644 --- a/tests/test_provider_claude_vscode_config.py +++ b/tests/test_provider_claude_vscode_config.py @@ -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) diff --git a/tests/test_provider_copilot_vscode_config.py b/tests/test_provider_copilot_vscode_config.py index c3075bb83..992888e22 100644 --- a/tests/test_provider_copilot_vscode_config.py +++ b/tests/test_provider_copilot_vscode_config.py @@ -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"): diff --git a/tests/test_provider_openai_responses.py b/tests/test_provider_openai_responses.py index 439166ae9..45a88a0fa 100644 --- a/tests/test_provider_openai_responses.py +++ b/tests/test_provider_openai_responses.py @@ -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( diff --git a/tests/test_provider_proxy_routes.py b/tests/test_provider_proxy_routes.py index 2319e3571..22698365d 100644 --- a/tests/test_provider_proxy_routes.py +++ b/tests/test_provider_proxy_routes.py @@ -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"] == ( diff --git a/tests/test_provider_route_specs.py b/tests/test_provider_route_specs.py index 52aa8e988..f6a57db0a 100644 --- a/tests/test_provider_route_specs.py +++ b/tests/test_provider_route_specs.py @@ -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(