mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
816cb85fa8
commit
4bf7f92417
8 changed files with 176 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
25
tests/test_issue_1601_remote_control_gate.py
Normal file
25
tests/test_issue_1601_remote_control_gate.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue