mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
## Description
Treat a non-zero `claude --version` exit as an unknown Claude Code
version, even if the failing command prints a version-shaped string to
stdout or stderr.
This is a follow-up to the Remote Control gate work for #1779/#1883. The
callers rely on `None` to use the self-qualified "2.1.196+ / unknown"
warning path; accepting a version from a failed command can produce a
false exact-version warning.
## 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)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/providers/claude/runtime.py`: return `None` from
`detect_claude_code_version` when the `claude --version` subprocess has
a non-zero return code.
- `tests/test_issue_1779_remote_control_gate.py`: add a regression test
where a failing process still prints `2.1.196 (Claude Code)` and must be
treated as unknown.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uv run pytest tests/test_issue_1779_remote_control_gate.py -q
50 passed
$ uvx ruff==0.15.17 check headroom/providers/claude/runtime.py tests/test_issue_1779_remote_control_gate.py --output-format concise
All checks passed!
$ uvx ruff==0.15.17 format --check headroom/providers/claude/runtime.py tests/test_issue_1779_remote_control_gate.py
2 files already formatted
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12/3.13 test environment, local
checkout of this PR branch.
- Exact command / steps: ran the focused Remote Control gate test file,
including the new regression that stubs `claude --version` as
`returncode=1` with version-shaped stdout.
- Observed result: `detect_claude_code_version("claude")` returns `None`
for the failed command, preserving the unknown-version path; existing
parser/gate tests still pass.
- Not tested: an actual failing Claude Code binary invocation on a user
machine; the subprocess behavior is covered by the regression stub.
## 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
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] 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
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
265 lines
10 KiB
Python
265 lines
10 KiB
Python
"""Issue #1779: Remote Control is *silently* disabled behind the proxy.
|
|
|
|
Claude Code v2.1.196 added a client-side eligibility check that deterministically
|
|
disables first-party Remote Control (`/remote-control` / `/rc`) whenever
|
|
`ANTHROPIC_BASE_URL` points at a non-`api.anthropic.com` host — which Headroom
|
|
always does. The gate is upstream, so Headroom's fix is an *accurate* warning
|
|
that:
|
|
|
|
* states the disable as a fact on v2.1.196+ (never the old hedged "may"),
|
|
* fires only for subscription sessions that ever had RC (never API-key / cloud),
|
|
* fires only when the installed version is at/after the gate, or unknown,
|
|
* co-reports the sibling base-URL gates #746 and #1158.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from headroom.providers.claude.runtime import (
|
|
REMOTE_CONTROL_GATED_MIN_VERSION,
|
|
REMOTE_CONTROL_SIBLING_GATE_NOTE,
|
|
detect_claude_code_version,
|
|
is_custom_anthropic_base_url,
|
|
parse_claude_code_version,
|
|
remote_control_applies_to_auth,
|
|
remote_control_gate_active,
|
|
remote_control_gate_message,
|
|
remote_control_sibling_gate_note,
|
|
)
|
|
|
|
_CUSTOM = "http://127.0.0.1:8787"
|
|
_NATIVE = "https://api.anthropic.com"
|
|
_GATED = REMOTE_CONTROL_GATED_MIN_VERSION # (2, 1, 196)
|
|
_OLD = (2, 1, 195)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message accuracy — deterministic wording, not "may"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_message_is_accurate_not_hedged() -> None:
|
|
msg = remote_control_gate_message("ANTHROPIC_BASE_URL in shell", version=_GATED)
|
|
# Deterministic: names the exact version and says it "disables" /rc.
|
|
assert "2.1.196" in msg
|
|
assert "disables" in msg
|
|
assert "/remote-control (/rc)" in msg
|
|
# The old hedged phrasing is gone.
|
|
assert "may hide" not in msg
|
|
assert "run Claude without Headroom for sessions that need Remote Control" in msg
|
|
|
|
|
|
def test_message_unknown_version_states_threshold() -> None:
|
|
msg = remote_control_gate_message("ANTHROPIC_BASE_URL in shell", version=None)
|
|
# Without a detected version we state the threshold and let the user
|
|
# self-identify — no false claim about their specific build.
|
|
assert "2.1.196+" in msg
|
|
assert "/rc" in msg
|
|
assert "may hide" not in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth gating — never warn a PAYG / cloud user (RC was never theirs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"env_key",
|
|
[
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_AUTH_TOKEN",
|
|
"CLAUDE_CODE_USE_BEDROCK",
|
|
"CLAUDE_CODE_USE_VERTEX",
|
|
"CLAUDE_CODE_USE_FOUNDRY",
|
|
],
|
|
)
|
|
def test_non_subscription_auth_never_applies(env_key: str) -> None:
|
|
assert remote_control_applies_to_auth({env_key: "something"}) is False
|
|
# And therefore the whole gate is inactive even on a gated version / custom URL.
|
|
assert remote_control_gate_active(_CUSTOM, {env_key: "something"}, _GATED) is False
|
|
|
|
|
|
def test_subscription_auth_applies() -> None:
|
|
assert remote_control_applies_to_auth({}) is True
|
|
assert remote_control_applies_to_auth({"PATH": "/usr/bin"}) is True
|
|
|
|
|
|
def test_blank_api_key_is_not_treated_as_payg() -> None:
|
|
# An empty / whitespace value is "unset" — a subscription session.
|
|
assert remote_control_applies_to_auth({"ANTHROPIC_API_KEY": " "}) is True
|
|
assert remote_control_gate_active(_CUSTOM, {"ANTHROPIC_API_KEY": ""}, _GATED) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Version gating — no false alarm on pre-2.1.196 builds
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_gate_active_on_gated_version() -> None:
|
|
assert remote_control_gate_active(_CUSTOM, {}, _GATED) is True
|
|
assert remote_control_gate_active(_CUSTOM, {}, (2, 2, 0)) is True
|
|
|
|
|
|
def test_gate_inactive_on_pre_gate_version() -> None:
|
|
# Older Claude Code does not gate RC on the base URL — warning would be false.
|
|
assert remote_control_gate_active(_CUSTOM, {}, _OLD) is False
|
|
assert remote_control_gate_active(_CUSTOM, {}, (1, 0, 0)) is False
|
|
|
|
|
|
def test_gate_active_when_version_unknown() -> None:
|
|
# Unknown version → warn conservatively (the message self-qualifies).
|
|
assert remote_control_gate_active(_CUSTOM, {}, None) is True
|
|
|
|
|
|
def test_gate_inactive_on_native_base_url() -> None:
|
|
assert remote_control_gate_active(_NATIVE, {}, _GATED) is False
|
|
assert remote_control_gate_active(None, {}, _GATED) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Version parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text,expected",
|
|
[
|
|
("2.1.196 (Claude Code)", (2, 1, 196)),
|
|
("claude-code/2.1.200", (2, 1, 200)),
|
|
("v2.0.0", (2, 0, 0)),
|
|
(" 2.1.196\n", (2, 1, 196)),
|
|
("no version here", None),
|
|
("", None),
|
|
(None, None),
|
|
],
|
|
)
|
|
def test_parse_claude_code_version(text, expected) -> None:
|
|
assert parse_claude_code_version(text) == expected
|
|
|
|
|
|
def test_detect_claude_code_version_missing_binary_is_none() -> None:
|
|
# A binary that does not exist must never raise — best-effort → None.
|
|
assert detect_claude_code_version("definitely-not-a-real-binary-xyz") is None
|
|
|
|
|
|
def test_detect_claude_code_version_tolerates_proc_without_stdout(monkeypatch) -> None:
|
|
# Regression (CI test failure on PR #1779): a stubbed subprocess result — a
|
|
# SimpleNamespace with only returncode, no stdout/stderr — must not raise
|
|
# AttributeError. detect is best-effort → returns None (version unknown).
|
|
from types import SimpleNamespace
|
|
|
|
import headroom._subprocess as _sub
|
|
|
|
monkeypatch.setattr(_sub, "run", lambda *a, **k: SimpleNamespace(returncode=0))
|
|
assert detect_claude_code_version("claude") is None
|
|
|
|
|
|
def test_detect_claude_code_version_parses_wrapper_output(monkeypatch) -> None:
|
|
from types import SimpleNamespace
|
|
|
|
import headroom._subprocess as _sub
|
|
|
|
monkeypatch.setattr(
|
|
_sub,
|
|
"run",
|
|
lambda *a, **k: SimpleNamespace(returncode=0, stdout="2.1.196 (Claude Code)\n", stderr=""),
|
|
)
|
|
assert detect_claude_code_version("claude") == (2, 1, 196)
|
|
|
|
|
|
def test_detect_claude_code_version_nonzero_exit_is_none(monkeypatch) -> None:
|
|
# Review follow-up (PR #1883, @JerrettDavis): a non-zero exit is a detection
|
|
# failure even when the failing command still prints a version-shaped string.
|
|
# Trusting it would emit a false *exact*-version Remote Control warning
|
|
# instead of the self-qualified "2.1.196+ / unknown" path. Must return None.
|
|
from types import SimpleNamespace
|
|
|
|
import headroom._subprocess as _sub
|
|
|
|
monkeypatch.setattr(
|
|
_sub,
|
|
"run",
|
|
lambda *a, **k: SimpleNamespace(returncode=1, stdout="2.1.196 (Claude Code)\n", stderr=""),
|
|
)
|
|
assert detect_claude_code_version("claude") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sibling co-report (#746 / #1158)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sibling_gate_note_co_reports_746_and_1158() -> None:
|
|
assert "#746" in REMOTE_CONTROL_SIBLING_GATE_NOTE
|
|
assert "#1158" in REMOTE_CONTROL_SIBLING_GATE_NOTE
|
|
assert "--1m" in REMOTE_CONTROL_SIBLING_GATE_NOTE
|
|
|
|
|
|
def test_sibling_note_defaults_claim_active_and_advise_1m() -> None:
|
|
note = remote_control_sibling_gate_note(tool_search_active=True, context_1m_enabled=False)
|
|
assert "#746" in note and "#1158" in note
|
|
assert "keeps it on for this session" in note
|
|
assert "restore with `headroom wrap claude --1m`" in note
|
|
|
|
|
|
def test_sibling_note_does_not_claim_disabled_tool_search_is_on() -> None:
|
|
# Accuracy under opt-outs: --tool-search false means deferral is OFF — the
|
|
# note must say so, not repeat the default "keeps it on" claim.
|
|
note = remote_control_sibling_gate_note(tool_search_active=False, context_1m_enabled=False)
|
|
assert "OFF for this session" in note
|
|
assert "keeps it on" not in note
|
|
|
|
|
|
def test_sibling_note_does_not_advise_1m_already_passed() -> None:
|
|
# Accuracy under opt-ins: with --1m in effect, don't advise adding it.
|
|
note = remote_control_sibling_gate_note(tool_search_active=True, context_1m_enabled=True)
|
|
assert "already restored via --1m" in note
|
|
assert "restore with `headroom wrap claude --1m`" not in note
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_custom_anthropic_base_url — string/host edges (Stage-4 matrix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
# Native host in every spelling: scheme, http-vs-https, trailing slash,
|
|
# port, case, and scheme-less — all NOT custom (host-equality only).
|
|
("https://api.anthropic.com", False),
|
|
("http://api.anthropic.com", False),
|
|
("https://api.anthropic.com/", False),
|
|
("https://api.anthropic.com:8443", False),
|
|
("https://API.ANTHROPIC.COM", False),
|
|
("API.ANTHROPIC.COM", False),
|
|
("api.anthropic.com:443", False),
|
|
# Lookalike suffix must NOT pass — exact host match, no endswith.
|
|
("https://api.anthropic.com.evil.com", True),
|
|
# Custom hosts, with and without scheme (scheme-less used to be a
|
|
# silent false-negative: urlparse read the host as a path/scheme).
|
|
("http://127.0.0.1:8787", True),
|
|
("127.0.0.1:8787", True),
|
|
("myproxy.local:8080", True),
|
|
("evil.com", True),
|
|
("https://gateway.internal.example", True),
|
|
# Valid IPv6 loopback literal — a real custom host.
|
|
("http://[::1]:8787", True),
|
|
# Unset / blank — not custom (nothing overrides the default endpoint).
|
|
("", False),
|
|
(" ", False),
|
|
(None, False),
|
|
# Malformed values must degrade to "no host -> not custom", never
|
|
# raise: urlparse throws ValueError("Invalid IPv6 URL") on stray
|
|
# brackets, and these strings are user-editable (settings.json /
|
|
# shell). The routing check flags unusable URLs separately.
|
|
("http://[", False),
|
|
("[", False),
|
|
("http://[::1:8787", False),
|
|
("http://:8080", False),
|
|
("http://", False),
|
|
],
|
|
)
|
|
def test_is_custom_anthropic_base_url_host_edges(value, expected) -> None:
|
|
assert is_custom_anthropic_base_url(value) is expected
|