fix(claude): surface Remote Control proxy incompatibility (#1610)

## Description

Claude Code hides Remote Control when it sees a custom
`ANTHROPIC_BASE_URL`, so `headroom wrap claude` can make the menu
disappear even though normal API requests still route through Headroom.
The reported proxy logs show no Remote Control registration, session
bootstrap, or device-attestation request at all, which means the
decision happens inside Claude before Headroom can forward anything.

This change makes that client-side incompatibility explicit in
Headroom's Claude launch flow, `headroom doctor`, and troubleshooting
docs. API proxying and the existing `ENABLE_TOOL_SEARCH` compatibility
shim stay unchanged; users who need Remote Control get a direct
instruction to launch Claude without the Headroom proxy for that
session.

Closes #1601

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] 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 a Claude-specific helper and warning text for the Remote Control
custom-base incompatibility.
- Surface that warning from `headroom wrap claude` when Claude is
launched through `ANTHROPIC_BASE_URL`.
- Add a separate `headroom doctor` warning for Claude Remote Control
availability, while keeping Claude API-routing status independent.
- Document the limitation and workaround next to the existing Claude
custom-endpoint troubleshooting guidance.
- Add focused regression tests for gated and non-gated Claude routing
states, plus preservation coverage for `ENABLE_TOOL_SEARCH`.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_issue_1601_remote_control_gate.py tests/test_cli_doctor.py
tests/test_cli/test_wrap_claude_vertex_proxy_env.py -q`)
- [x] Unit tests pass (`uv run pytest
tests/test_issue_746_tool_search.py
tests/test_cli/test_init_enable_tool_search.py -q`)
- [x] Linting passes (`uv run ruff check headroom/cli/wrap.py
tests/test_cli_doctor.py
tests/test_cli/test_wrap_claude_vertex_proxy_env.py`)
- [x] Formatting passes (`uv run ruff format --check
headroom/cli/wrap.py tests/test_cli_doctor.py
tests/test_cli/test_wrap_claude_vertex_proxy_env.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for the bugfix
- [ ] Manual testing performed

### Test Output

```text
rtk uv run pytest tests/test_issue_1601_remote_control_gate.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py -q
============================= test session starts =============================
collected 62 items
62 passed, 1 warning

rtk uv run pytest tests/test_issue_746_tool_search.py tests/test_cli/test_init_enable_tool_search.py -q
============================= test session starts =============================
collected 33 items
33 passed, 1 warning

rtk uv run ruff check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py
All checks passed!

rtk uv run ruff format --check headroom/cli/wrap.py tests/test_cli_doctor.py tests/test_cli/test_wrap_claude_vertex_proxy_env.py
3 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python via `uv`, focused Claude CLI and doctor
tests.
- Exact command / steps: with Claude settings or shell environment
containing `ANTHROPIC_BASE_URL=http://127.0.0.1:8787`, run the focused
helper and doctor tests, then run the existing `ENABLE_TOOL_SEARCH`
preservation tests.
- Observed result: Headroom surfaces a Claude Remote Control warning for
custom `ANTHROPIC_BASE_URL`, while Claude API routing and
`ENABLE_TOOL_SEARCH` behavior stay intact.
- Not tested: live Claude Remote Control UI automation. The issue
evidence says Claude hides the menu before any request reaches Headroom,
so this PR proves Headroom's launch, diagnostics, and docs behavior.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

`CHANGELOG.md` stays untouched because this repo's release pipeline
generates changelog entries from conventional commits.

This is a visibility fix, not a proxy transport restore. The issue
evidence shows Claude never sends a Remote Control request while the
custom-base gate is active, so the surviving slice is launch-time
warning, doctor warning, and documentation.

PR `#1600` is adjacent and non-blocking because `#1601` reproduces from
process-env `ANTHROPIC_BASE_URL` alone.

This intentionally changes `headroom doctor` for fully routed Claude
sessions from an all-pass result to one warnings-only result, because
the proxied Claude setup is operational for API traffic but still
incompatible with Remote Control.
This commit is contained in:
Rod Boev 2026-07-02 00:19:25 -04:00 committed by GitHub
parent 816cb85fa8
commit 4bf7f92417
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 176 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -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",
]

View file

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

View file

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

View file

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

View file

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