diff --git a/docs/content/docs/troubleshooting.mdx b/docs/content/docs/troubleshooting.mdx index 6fd2cf865..fc4d958f6 100644 --- a/docs/content/docs/troubleshooting.mdx +++ b/docs/content/docs/troubleshooting.mdx @@ -183,6 +183,16 @@ When deferral is off, the proxy log also prints a one-time hint naming the fix. See [issue #746](https://github.com/chopratejas/headroom/issues/746) for the full analysis. +## Remote Control unavailable through custom ANTHROPIC_BASE_URL + +**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent. + +**Cause**: This is a Claude-side gate. Headroom only receives normal API traffic and can still compress it, but Claude evaluates Remote Control availability before proxy traffic reaches the server. + +**Fix**: Use Headroom for normal proxied API sessions, and launch Claude directly (without `ANTHROPIC_BASE_URL`) when you need Claude Remote Control. + +`ENABLE_TOOL_SEARCH` is unaffected and can stay enabled for context-window savings while routing through Headroom. + ## Compression Too Aggressive **Symptom**: LLM responses are missing information that was in tool outputs. diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index 491e7cd0c..e045aa0a1 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -26,6 +26,11 @@ from headroom.install.health import probe_json from headroom.install.paths import claude_settings_path, codex_config_path from headroom.install.state import list_manifests from headroom.paths import savings_path +from headroom.providers.claude import ( + REMOTE_CONTROL_BASE_URL_ENV, + is_custom_anthropic_base_url, + remote_control_gate_message, +) from .main import get_version, main @@ -148,6 +153,41 @@ def check_claude_routing(settings_path: Path, port: int) -> CheckResult: return _classify_routing_url(name, base_url, port, source=str(settings_path)) +def check_claude_remote_control_gate( + settings_path: Path, environ: Mapping[str, str] +) -> CheckResult | None: + """Warn once when Claude custom-base routing hides Remote Control.""" + name = "claude remote control" + settings_base_url = "" + if settings_path.exists(): + try: + payload = json.loads(settings_path.read_text(encoding="utf-8")) + env_block = payload.get("env") + if isinstance(env_block, dict): + settings_base_url = str(env_block.get("ANTHROPIC_BASE_URL", "") or "") + except (OSError, ValueError): + settings_base_url = "" + if is_custom_anthropic_base_url(settings_base_url): + remote_message = remote_control_gate_message(f"{REMOTE_CONTROL_BASE_URL_ENV} from settings") + return CheckResult( + name=name, + status=WARN, + summary=remote_message, + hint=remote_message, + ) + + env_base_url = environ.get("ANTHROPIC_BASE_URL", "") + if is_custom_anthropic_base_url(env_base_url): + remote_message = remote_control_gate_message(f"{REMOTE_CONTROL_BASE_URL_ENV} in shell") + return CheckResult( + name=name, + status=WARN, + summary=remote_message, + hint=remote_message, + ) + return None + + def check_codex_routing(config_path: Path, port: int) -> CheckResult: """Is Codex configured to route through the proxy? @@ -384,6 +424,9 @@ def doctor(port: int, emit_json: bool) -> None: check_savings(stats, savings_path()), check_budget(stats), ] + remote_control_gate_check = check_claude_remote_control_gate(claude_settings_path(), os.environ) + if remote_control_gate_check is not None: + checks.append(remote_control_gate_check) deployments = check_deployments(list_manifests()) if deployments is not None: checks.append(deployments) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 77e53f4d6..1f22f2ff3 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -56,8 +56,11 @@ from headroom.copilot_auth import ( ) from headroom.providers.aider import build_launch_env as _build_aider_launch_env from headroom.providers.claude import ( + REMOTE_CONTROL_BASE_URL_ENV, TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV, + is_custom_anthropic_base_url, + remote_control_gate_message, ) from headroom.providers.claude import ( proxy_base_url as _claude_proxy_base_url, @@ -3730,6 +3733,13 @@ def claude( ) else: click.echo(f" ANTHROPIC_BASE_URL={proxy_url}") + if is_custom_anthropic_base_url(proxy_url): + click.echo( + " " + + remote_control_gate_message( + f"the wrapped Claude session's {REMOTE_CONTROL_BASE_URL_ENV}" + ) + ) if claude_args: click.echo(f" Extra args: {' '.join(claude_args)}") _print_telemetry_notice() diff --git a/headroom/providers/claude/__init__.py b/headroom/providers/claude/__init__.py index cdff9c83c..b91fb20c1 100644 --- a/headroom/providers/claude/__init__.py +++ b/headroom/providers/claude/__init__.py @@ -2,14 +2,20 @@ from .runtime import ( DEFAULT_API_URL, + REMOTE_CONTROL_BASE_URL_ENV, TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV, + is_custom_anthropic_base_url, proxy_base_url, + remote_control_gate_message, ) __all__ = [ "DEFAULT_API_URL", + "REMOTE_CONTROL_BASE_URL_ENV", "TOOL_SEARCH_DEFAULT", "TOOL_SEARCH_ENV", + "is_custom_anthropic_base_url", + "remote_control_gate_message", "proxy_base_url", ] diff --git a/headroom/providers/claude/runtime.py b/headroom/providers/claude/runtime.py index 45b65781c..84da9236e 100644 --- a/headroom/providers/claude/runtime.py +++ b/headroom/providers/claude/runtime.py @@ -2,6 +2,8 @@ from __future__ import annotations +from urllib.parse import urlparse + DEFAULT_API_URL = "https://api.anthropic.com" # GH #746: Claude Code stops deferring MCP/system tool schemas (materializing @@ -11,6 +13,30 @@ DEFAULT_API_URL = "https://api.anthropic.com" # single source of truth shared by `wrap`, `init`, and `install`. TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH" TOOL_SEARCH_DEFAULT = "true" +REMOTE_CONTROL_BASE_URL_ENV = "ANTHROPIC_BASE_URL" +REMOTE_CONTROL_FEATURE = "Remote Control" +REMOTE_CONTROL_DISABLED_MESSAGE = ( + f"{REMOTE_CONTROL_FEATURE}: " + "Claude Code may hide the Remote Control menu while " + f"{REMOTE_CONTROL_BASE_URL_ENV} points at a custom endpoint " + "({source}); " + "launch Claude without Headroom for sessions that need this feature." +) + + +def remote_control_gate_message(source: str) -> str: + """Return the shared Remote Control compatibility message for Claude warning paths.""" + source_clean = source.strip() or "this endpoint" + return REMOTE_CONTROL_DISABLED_MESSAGE.format(source=source_clean) + + +def is_custom_anthropic_base_url(value: str | None) -> bool: + """Return whether ANTHROPIC_BASE_URL is custom from Claude's Remote Control gate view.""" + raw = (value or "").strip() + if not raw: + return False + host = (urlparse(raw).hostname or "").strip().lower() + return host not in {"", "api.anthropic.com"} def proxy_base_url(port: int) -> str: 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 33f2eb239..58c730144 100644 --- a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py +++ b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py @@ -34,6 +34,7 @@ def runner() -> CliRunner: def _clear_claude_mode_env(monkeypatch: pytest.MonkeyPatch) -> None: for key in ( + "ANTHROPIC_BASE_URL", "ANTHROPIC_VERTEX_BASE_URL", "ANTHROPIC_FOUNDRY_BASE_URL", "ANTHROPIC_FOUNDRY_RESOURCE", @@ -97,6 +98,16 @@ def _invoke_wrap_claude( return captured, result.output +def test_wrap_claude_plain_mode_warns_about_remote_control_gate( + runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + captured, output = _invoke_wrap_claude(runner, monkeypatch, env={}) + + assert captured["child_cmd"] == ["/usr/bin/claude"] + assert "Remote Control" in output + assert "wrapped Claude session's ANTHROPIC_BASE_URL" in output + + def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirect( runner: CliRunner, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 040053e03..3d26f563a 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -15,6 +15,7 @@ from headroom.cli.doctor import ( SKIP, WARN, check_budget, + check_claude_remote_control_gate, check_claude_routing, check_codex_routing, check_deployments, @@ -24,6 +25,7 @@ from headroom.cli.doctor import ( check_version_drift, ) from headroom.cli.main import main +from headroom.providers.claude.runtime import remote_control_gate_message LIVEZ_OK = { "service": "headroom-proxy", @@ -118,6 +120,46 @@ class TestClaudeRouting: assert "gateway.corp.example" in result.summary +class TestClaudeRemoteControlGate: + def test_settings_custom_base_warns(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}), + encoding="utf-8", + ) + result = check_claude_remote_control_gate(path, {}) + assert result is not None + assert result.status == WARN + assert remote_control_gate_message("ANTHROPIC_BASE_URL from settings") in result.summary + + def test_shell_env_custom_base_warns(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text("{}", encoding="utf-8") + result = check_claude_remote_control_gate( + path, {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"} + ) + assert result is not None + assert result.status == WARN + assert remote_control_gate_message("ANTHROPIC_BASE_URL in shell") in result.summary + + def test_no_custom_base_no_warning(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://api.anthropic.com"}}), + encoding="utf-8", + ) + assert check_claude_remote_control_gate(path, {}) is None + + def test_settings_check_still_routes(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}), + encoding="utf-8", + ) + result = check_claude_routing(path, 8787) + assert result.status == PASS + + class TestCodexRouting: def test_missing_file_warns(self, tmp_path): assert check_codex_routing(tmp_path / "config.toml", 8787).status == WARN @@ -287,7 +329,7 @@ class TestDoctorCommand: result = runner.invoke(main, ["doctor"]) assert result.exit_code == 1 - def test_all_pass_exits_0(self, runner, isolated, monkeypatch): + def test_remote_control_warning_exits_1(self, runner, isolated, monkeypatch): monkeypatch.setattr(doctor_mod, "probe_json", self._probe(LIVEZ_OK, STATS_OK)) monkeypatch.setattr(doctor_mod, "get_version", lambda: "0.26.0") (isolated / "settings.json").write_text( @@ -301,8 +343,8 @@ class TestDoctorCommand: result = runner.invoke( main, ["doctor"], env={"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"} ) - assert result.exit_code == 0, result.output - assert "all checks passed" in result.output + assert result.exit_code == 1, result.output + assert "Remote Control" in result.output def test_json_output_parses(self, runner, isolated, monkeypatch): monkeypatch.setattr(doctor_mod, "probe_json", self._probe(LIVEZ_OK, STATS_OK)) diff --git a/tests/test_issue_1601_remote_control_gate.py b/tests/test_issue_1601_remote_control_gate.py new file mode 100644 index 000000000..7a6fbf779 --- /dev/null +++ b/tests/test_issue_1601_remote_control_gate.py @@ -0,0 +1,25 @@ +"""Issue #1601: Claude Remote Control is unavailable with a custom ANTHROPIC_BASE_URL.""" + +from __future__ import annotations + +from headroom.providers.claude.runtime import ( + REMOTE_CONTROL_BASE_URL_ENV, + is_custom_anthropic_base_url, + remote_control_gate_message, +) + + +def test_custom_anthropic_base_url_is_remote_control_gated() -> None: + assert is_custom_anthropic_base_url("http://127.0.0.1:8787") + assert is_custom_anthropic_base_url("https://gateway.internal.example") + + +def test_native_anthropic_base_url_is_not_remote_control_gated() -> None: + assert not is_custom_anthropic_base_url("https://api.anthropic.com") + + +def test_remote_control_gate_message_mentions_warning_and_source() -> None: + message = remote_control_gate_message(source=REMOTE_CONTROL_BASE_URL_ENV) + assert "Remote Control" in message + assert REMOTE_CONTROL_BASE_URL_ENV in message + assert "launch Claude without Headroom for sessions that need this feature" in message