fix(wrap): surface Claude Remote Control base-URL gate accurately (#1… (#1883)

…779)

Claude Code 2.1.196 deterministically disables first-party Remote
Control (/remote-control, /rc) behind a custom ANTHROPIC_BASE_URL, which
Headroom always sets. Make the wrap/doctor warning accurate (state the
disable as fact, name the /rc command, detect the installed version),
suppress it for auth modes that never had RC (API key,
Bedrock/Vertex/Foundry) and for builds older than 2.1.196, co-report the
sibling #746/#1158 gates session-accurately, and fix
is_custom_anthropic_base_url host handling (scheme-less hosts, malformed
URLs). UX/notice-only; no request bytes touched.

## Description

<!-- Briefly explain the change and why it is needed. -->

Closes #

## Type of Change

- [ ] 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

- 

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

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

## Checklist

- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
This commit is contained in:
Ayush Kumar Jha 2026-07-13 23:31:37 +05:30 committed by GitHub
parent c3db8e47f8
commit daeff69a75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 832 additions and 41 deletions

View file

@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes ### Bug Fixes
* **wrap/doctor:** make the Claude Remote Control gate warning accurate and stop it firing for users who never had the feature ([#1779](https://github.com/headroomlabs-ai/headroom/issues/1779)). Claude Code 2.1.196 added a client-side 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 old notice hedged ("may hide the Remote Control menu"); it now states the disable as fact, names the `/rc` command, and detects the installed Claude Code version so the wording is exact (`2.1.196` when known, `2.1.196+` when not). The gate is upstream and RC's control-plane talks to `claude.ai` (not the API host), so Headroom cannot restore it — the warning tells you to run Claude without Headroom for RC sessions. The warning is suppressed for auth modes that never had Remote Control (API-key/PAYG via `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`, and Bedrock/Vertex/Foundry cloud IAM) and on Claude Code builds older than 2.1.196 where RC is unaffected by a custom base URL. Both the `headroom wrap claude` launch banner and `headroom doctor` co-report the sibling base-URL gates Headroom *does* restore — on-demand tool loading (#746, automatic) and the 1M context window (#1158, via `--1m`) — and the wrap-side co-report is session-accurate: it says "already restored via --1m" when the flag is in effect and reports tool deferral OFF (not falsely "kept on") when the user chose `--tool-search false`/`ENABLE_TOOL_SEARCH=false`; the `ENABLE_TOOL_SEARCH=...` banner line got the same accuracy fix. `is_custom_anthropic_base_url` now recognizes scheme-less values (`myproxy.local:8080`, `127.0.0.1:8787`) as custom hosts and degrades gracefully on malformed URLs instead of crashing `doctor`. `doctor` resolves the Claude Code version lazily, so runs with no custom base URL never pay the `claude --version` subprocess. No request bytes are touched (cache-safe); this is UX/notice-only.
* **install:** default the docker image to `ghcr.io/headroomlabs-ai/headroom:latest` instead of the dead `ghcr.io/chopratejas/headroom:latest`. After the repo moved to the `headroomlabs-ai` org, GHCR did not redirect the old package, so `headroom install` / `headroom init` and the install scripts pulled a frozen `0.27.0` image while current releases publish to the new path ([#1867](https://github.com/headroomlabs-ai/headroom/issues/1867)). * **install:** default the docker image to `ghcr.io/headroomlabs-ai/headroom:latest` instead of the dead `ghcr.io/chopratejas/headroom:latest`. After the repo moved to the `headroomlabs-ai` org, GHCR did not redirect the old package, so `headroom install` / `headroom init` and the install scripts pulled a frozen `0.27.0` image while current releases publish to the new path ([#1867](https://github.com/headroomlabs-ai/headroom/issues/1867)).
* **transforms/content-router:** stop a profile-derived `read_protection_window` kwarg from weakening an explicit `--protect-tool-results` guarantee. `ContentRouter.apply()` computes `read_protection_window` from `protect_recent_reads_fraction`, where `0.0` (the sentinel `--protect-tool-results` sets) means "protect all excluded-tool output regardless of conversation depth" per #1374's documented contract — but the method then unconditionally overwrote that window with a `read_protection_window` kwarg whenever one was present. `proxy_pipeline_kwargs()` supplies that kwarg on every request from the active `AgentSavingsProfile.protect_recent` (the default `coding` profile sets `protect_recent=2`), so in practice only the last 2 messages ever kept read-protection and older excluded-tool output silently fell through to lossy compression. The runtime kwarg may now only narrow the window when `protect_recent_reads_fraction > 0`; it can no longer shrink the "protect everything" guarantee set by `--protect-tool-results`. * **transforms/content-router:** stop a profile-derived `read_protection_window` kwarg from weakening an explicit `--protect-tool-results` guarantee. `ContentRouter.apply()` computes `read_protection_window` from `protect_recent_reads_fraction`, where `0.0` (the sentinel `--protect-tool-results` sets) means "protect all excluded-tool output regardless of conversation depth" per #1374's documented contract — but the method then unconditionally overwrote that window with a `read_protection_window` kwarg whenever one was present. `proxy_pipeline_kwargs()` supplies that kwarg on every request from the active `AgentSavingsProfile.protect_recent` (the default `coding` profile sets `protect_recent=2`), so in practice only the last 2 messages ever kept read-protection and older excluded-tool output silently fell through to lossy compression. The runtime kwarg may now only narrow the window when `protect_recent_reads_fraction > 0`; it can no longer shrink the "protect everything" guarantee set by `--protect-tool-results`.
* **memory:** include the Ollama server URL in the embedder cache key so a second backend can't get an embedder bound to the wrong server. `_create_embedder` cached by `(backend, model)` only, but the Ollama embedder is constructed with `base_url=config.ollama_base_url`. Two configs in the same process that shared a backend and model but pointed at different Ollama servers (e.g. a per-project storage router) collided on one cache slot, so the second silently reused the first's embedder and embedded against the wrong host. The cache key now also includes `ollama_base_url`. * **memory:** include the Ollama server URL in the embedder cache key so a second backend can't get an embedder bound to the wrong server. `_create_embedder` cached by `(backend, model)` only, but the Ollama embedder is constructed with `base_url=config.ollama_base_url`. Two configs in the same process that shared a backend and model but pointed at different Ollama servers (e.g. a per-project storage router) collided on one cache slot, so the second silently reused the first's embedder and embedded against the wrong host. The cache key now also includes `ollama_base_url`.

View file

@ -14,7 +14,7 @@ from __future__ import annotations
import json import json
import os import os
import re import re
from collections.abc import Mapping from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -29,7 +29,11 @@ from headroom.install.state import list_manifests
from headroom.paths import savings_path from headroom.paths import savings_path
from headroom.providers.claude import ( from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV, REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
detect_claude_code_version,
is_custom_anthropic_base_url, is_custom_anthropic_base_url,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message, remote_control_gate_message,
) )
@ -168,37 +172,71 @@ def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
def check_claude_remote_control_gate( def check_claude_remote_control_gate(
settings_path: Path, environ: Mapping[str, str] settings_path: Path,
environ: Mapping[str, str],
*,
version: tuple[int, int, int] | None = None,
version_resolver: Callable[[], tuple[int, int, int] | None] | None = None,
) -> CheckResult | None: ) -> CheckResult | None:
"""Warn once when Claude custom-base routing hides Remote Control.""" """Warn once when Claude custom-base routing hides Remote Control (issue #1779).
Fires only for a session that could ever have had Remote Control a
subscription auth mode (not API-key/cloud IAM) on a Claude Code build at/after
the gate version, or an unknown version. Auth signals are read from the shell
``environ`` overlaid on the settings-file ``env`` block, so an API key
configured in either place suppresses the warning.
``version`` is the detected Claude Code version (``None`` = unknown); tests
pass it directly so the check stays pure. ``version_resolver`` lets the
``doctor`` entrypoint defer the ``claude --version`` subprocess until the
cheap gates (custom base URL + subscription auth) have passed most doctor
runs never pay it. An explicit ``version`` wins over the resolver; the
resolver is called at most once.
"""
name = "claude remote control" name = "claude remote control"
settings_env: dict[str, object] = {}
settings_base_url = "" settings_base_url = ""
if settings_path.exists(): if settings_path.exists():
try: try:
payload = json.loads(settings_path.read_text(encoding="utf-8")) payload = json.loads(settings_path.read_text(encoding="utf-8"))
env_block = payload.get("env") env_block = payload.get("env")
if isinstance(env_block, dict): if isinstance(env_block, dict):
settings_env = env_block
settings_base_url = str(env_block.get("ANTHROPIC_BASE_URL", "") or "") settings_base_url = str(env_block.get("ANTHROPIC_BASE_URL", "") or "")
except (OSError, ValueError): except (OSError, ValueError):
settings_env = {}
settings_base_url = "" 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,
)
# Shell env wins over settings env, matching Claude Code's own precedence.
effective_env: dict[str, object] = {**settings_env, **dict(environ)}
env_base_url = environ.get("ANTHROPIC_BASE_URL", "") 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") resolved_version = version
return CheckResult( version_resolved = version is not None or version_resolver is None
name=name,
status=WARN, for base_url, source in (
summary=remote_message, (settings_base_url, "from settings"),
hint=remote_message, (env_base_url, "in shell"),
) ):
# Cheap gates first so the version subprocess only runs when a warning
# is actually plausible for this environment.
if not is_custom_anthropic_base_url(base_url):
continue
if not remote_control_applies_to_auth(effective_env):
return None
if not version_resolved and version_resolver is not None:
resolved_version = version_resolver()
version_resolved = True
if remote_control_gate_active(base_url, effective_env, resolved_version):
remote_message = remote_control_gate_message(
f"{REMOTE_CONTROL_BASE_URL_ENV} {source}", version=resolved_version
)
return CheckResult(
name=name,
status=WARN,
summary=remote_message,
hint=REMOTE_CONTROL_SIBLING_GATE_NOTE,
)
return None return None
@ -473,7 +511,12 @@ def doctor(port: int, emit_json: bool) -> None:
check_savings(stats, savings_path()), check_savings(stats, savings_path()),
check_budget(stats), check_budget(stats),
] ]
remote_control_gate_check = check_claude_remote_control_gate(claude_settings_path(), os.environ) # Lazy resolver: `claude --version` is a Node CLI subprocess (seconds of
# cold start, 10s worst-case timeout) — only pay for it when the RC gate
# is actually plausible (custom base URL + subscription auth).
remote_control_gate_check = check_claude_remote_control_gate(
claude_settings_path(), os.environ, version_resolver=detect_claude_code_version
)
if remote_control_gate_check is not None: if remote_control_gate_check is not None:
checks.append(remote_control_gate_check) checks.append(remote_control_gate_check)
deployments = check_deployments(list_manifests()) deployments = check_deployments(list_manifests())

View file

@ -61,8 +61,11 @@ from headroom.providers.claude import (
REMOTE_CONTROL_BASE_URL_ENV, REMOTE_CONTROL_BASE_URL_ENV,
TOOL_SEARCH_DEFAULT, TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV, TOOL_SEARCH_ENV,
is_custom_anthropic_base_url, detect_claude_code_version,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message, remote_control_gate_message,
remote_control_sibling_gate_note,
) )
from headroom.providers.claude import ( from headroom.providers.claude import (
proxy_base_url as _claude_proxy_base_url, proxy_base_url as _claude_proxy_base_url,
@ -241,6 +244,32 @@ def _configure_tool_search_env(env: dict[str, str], flag_value: str | None) -> s
return _TOOL_SEARCH_DEFAULT return _TOOL_SEARCH_DEFAULT
# ENABLE_TOOL_SEARCH modes that turn deferral OFF. Everything else Claude Code
# accepts (true/1/yes/on/auto/auto:N) keeps on-demand tool loading active.
_TOOL_SEARCH_FALSY = {"false", "0", "no", "off"}
def _resolved_tool_search_mode(flag_value: str | None) -> str:
"""Predict the ``ENABLE_TOOL_SEARCH`` value the launched process will get.
Runs :func:`_configure_tool_search_env` against a throwaway copy of the
relevant environment, so messages printed *before* the real injection (the
Remote Control sibling note, issue #1779) apply the exact same precedence
(flag > existing non-blank env > default) and can never drift from it.
"""
probe: dict[str, str] = {}
existing = os.environ.get(_TOOL_SEARCH_ENV)
if existing is not None:
probe[_TOOL_SEARCH_ENV] = existing
written = _configure_tool_search_env(probe, flag_value)
return written if written is not None else probe.get(_TOOL_SEARCH_ENV, "")
def _tool_search_mode_is_active(value: str) -> bool:
"""Whether an ``ENABLE_TOOL_SEARCH`` mode keeps tool deferral on (#746)."""
return value.strip().lower() not in _TOOL_SEARCH_FALSY
def _live_wrap_module() -> Any: def _live_wrap_module() -> Any:
"""Return the current live wrap module instance.""" """Return the current live wrap module instance."""
return cast(Any, sys.modules[__name__]) return cast(Any, sys.modules[__name__])
@ -4070,11 +4099,37 @@ def claude(
) )
else: else:
click.echo(f" ANTHROPIC_BASE_URL={proxy_url}") click.echo(f" ANTHROPIC_BASE_URL={proxy_url}")
if is_custom_anthropic_base_url(proxy_url): # Issue #1779: Claude Code 2.1.196+ deterministically disables
# first-party Remote Control (/rc) behind a custom ANTHROPIC_BASE_URL.
# Warn accurately — but only for subscription sessions that ever had
# RC (skip API-key/cloud auth) and only when the installed version is
# at/after the gate (or unknown). The gate is upstream; Headroom
# cannot restore RC, so this is a launch-time notice, not a fix.
# Detecting the version shells out to `claude --version`, so skip that
# subprocess for auth modes we would never warn about anyway.
_cc_version = (
detect_claude_code_version(claude_bin)
if remote_control_applies_to_auth(os.environ)
else None
)
if remote_control_gate_active(proxy_url, os.environ, _cc_version):
click.echo( click.echo(
" " " "
+ remote_control_gate_message( + remote_control_gate_message(
f"the wrapped Claude session's {REMOTE_CONTROL_BASE_URL_ENV}" f"the wrapped Claude session's {REMOTE_CONTROL_BASE_URL_ENV}",
version=_cc_version,
)
)
# Session-accurate sibling co-report: reflect what THIS launch
# actually does with #746/#1158 (never claim deferral is on for
# a --tool-search false session, never advise --1m twice).
click.echo(
" "
+ remote_control_sibling_gate_note(
tool_search_active=_tool_search_mode_is_active(
_resolved_tool_search_mode(tool_search)
),
context_1m_enabled=context_1m,
) )
) )
if claude_args: if claude_args:
@ -4130,9 +4185,16 @@ def claude(
# proxy so tool schemas are not eagerly materialized into local context. # proxy so tool schemas are not eagerly materialized into local context.
_tool_search_value = _configure_tool_search_env(env, tool_search) _tool_search_value = _configure_tool_search_env(env, tool_search)
if _tool_search_value is not None: 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
# rather than repeat "kept on" (issue #1779 accuracy rule).
_tool_search_state = (
"on-demand tool loading kept on"
if _tool_search_mode_is_active(_tool_search_value)
else "on-demand tool loading DISABLED per your setting"
)
click.echo( click.echo(
f" {_TOOL_SEARCH_ENV}={_tool_search_value} " f" {_TOOL_SEARCH_ENV}={_tool_search_value} ({_tool_search_state}; issue #746)"
"(on-demand tool loading kept on; issue #746)"
) )
elif verbose: elif verbose:
click.echo( click.echo(

View file

@ -3,19 +3,35 @@
from .runtime import ( from .runtime import (
DEFAULT_API_URL, DEFAULT_API_URL,
REMOTE_CONTROL_BASE_URL_ENV, REMOTE_CONTROL_BASE_URL_ENV,
REMOTE_CONTROL_GATED_MIN_VERSION,
REMOTE_CONTROL_NON_SUBSCRIPTION_ENV,
REMOTE_CONTROL_SIBLING_GATE_NOTE,
TOOL_SEARCH_DEFAULT, TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV, TOOL_SEARCH_ENV,
detect_claude_code_version,
is_custom_anthropic_base_url, is_custom_anthropic_base_url,
parse_claude_code_version,
proxy_base_url, proxy_base_url,
remote_control_applies_to_auth,
remote_control_gate_active,
remote_control_gate_message, remote_control_gate_message,
remote_control_sibling_gate_note,
) )
__all__ = [ __all__ = [
"DEFAULT_API_URL", "DEFAULT_API_URL",
"REMOTE_CONTROL_BASE_URL_ENV", "REMOTE_CONTROL_BASE_URL_ENV",
"REMOTE_CONTROL_GATED_MIN_VERSION",
"REMOTE_CONTROL_NON_SUBSCRIPTION_ENV",
"REMOTE_CONTROL_SIBLING_GATE_NOTE",
"TOOL_SEARCH_DEFAULT", "TOOL_SEARCH_DEFAULT",
"TOOL_SEARCH_ENV", "TOOL_SEARCH_ENV",
"detect_claude_code_version",
"is_custom_anthropic_base_url", "is_custom_anthropic_base_url",
"remote_control_gate_message", "parse_claude_code_version",
"proxy_base_url", "proxy_base_url",
"remote_control_applies_to_auth",
"remote_control_gate_active",
"remote_control_gate_message",
"remote_control_sibling_gate_note",
] ]

View file

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import re
from collections.abc import Mapping
from urllib.parse import urlparse from urllib.parse import urlparse
DEFAULT_API_URL = "https://api.anthropic.com" DEFAULT_API_URL = "https://api.anthropic.com"
@ -15,28 +17,241 @@ TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
TOOL_SEARCH_DEFAULT = "true" TOOL_SEARCH_DEFAULT = "true"
REMOTE_CONTROL_BASE_URL_ENV = "ANTHROPIC_BASE_URL" REMOTE_CONTROL_BASE_URL_ENV = "ANTHROPIC_BASE_URL"
REMOTE_CONTROL_FEATURE = "Remote Control" REMOTE_CONTROL_FEATURE = "Remote Control"
REMOTE_CONTROL_DISABLED_MESSAGE = (
f"{REMOTE_CONTROL_FEATURE}: " # GH #1779: Claude Code v2.1.196 added a client-side eligibility check that
"Claude Code may hide the Remote Control menu while " # DISABLES first-party Remote Control (`/remote-control` / `/rc`, which mirrors a
f"{REMOTE_CONTROL_BASE_URL_ENV} points at a custom endpoint " # local CLI session to claude.ai/code and the mobile apps) whenever
"({source}); " # ANTHROPIC_BASE_URL points at a non-`api.anthropic.com` host. Headroom routes
"launch Claude without Headroom for sessions that need this feature." # through http://127.0.0.1:<port>, so on this version and newer the disable is
# DETERMINISTIC (not "may") — the `/rc` command simply vanishes. The gate is
# upstream in the Claude Code binary and RC's control-plane talks to claude.ai,
# not the API host, so Headroom cannot force it back on; the honest fix is an
# accurate warning at launch/doctor time. This is the same base-URL gating
# family as #746 (on-demand tool loading) and #1158 (1M context window), both of
# which Headroom *can* restore (see the sibling-gate note below).
REMOTE_CONTROL_GATED_MIN_VERSION = (2, 1, 196)
# Auth-mode signals that mean Remote Control was NEVER available for this
# session, so its gate warning must not fire (issue #1779). RC mirrors a local
# CLI session to a claude.ai account — a Claude Pro/Max *subscription* feature.
# API-key (PAYG) callers and cloud IAM/ADC callers (Bedrock / Vertex / Foundry)
# have no claude.ai session to mirror and never saw the `/rc` command. Presence
# of any of these (non-empty) in the effective environment means "not a
# subscription session — stay silent."
REMOTE_CONTROL_NON_SUBSCRIPTION_ENV = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
)
# Co-reported alongside the RC gate so the user sees the whole base-URL gating
# family in one place (issue #1779). Unlike RC, Headroom *does* restore these two
# siblings — #746 by default, #1158 on request — which is the point of showing
# them together: RC is the one member of the family Headroom cannot fix.
# This constant describes DEFAULT behaviour and is the right form for `doctor`,
# which cannot see the wrap launch flags. `wrap` knows its flags and must use
# :func:`remote_control_sibling_gate_note` instead, so the note never claims
# tool deferral is on for a session where the user turned it off, nor tells a
# user to pass `--1m` they already passed.
REMOTE_CONTROL_SIBLING_GATE_NOTE = (
"Same base-URL gate also affects on-demand tool loading "
"(#746 — `headroom wrap claude` keeps it on by default) and the 1M context "
"window (#1158 — opt in with `headroom wrap claude --1m`)."
) )
def remote_control_gate_message(source: str) -> str: def remote_control_sibling_gate_note(*, tool_search_active: bool, context_1m_enabled: bool) -> str:
"""Return the shared Remote Control compatibility message for Claude warning paths.""" """Session-accurate sibling-gate co-report for the wrap launch banner.
Unlike the flag-blind :data:`REMOTE_CONTROL_SIBLING_GATE_NOTE`, this
reflects what THIS session actually does (issue #1779 accuracy rule: never
show the user a claim the session contradicts):
* ``tool_search_active`` whether the resolved ``ENABLE_TOOL_SEARCH`` mode
keeps deferral on (#746). ``False`` when the user chose a falsy mode.
* ``context_1m_enabled`` whether ``--1m`` was passed (#1158); if so, don't
advise adding a flag that is already in effect.
"""
tool_part = (
"#746 — Headroom keeps it on for this session"
if tool_search_active
else "#746 — OFF for this session per your --tool-search/ENABLE_TOOL_SEARCH setting"
)
context_part = (
"#1158 — already restored via --1m"
if context_1m_enabled
else "#1158 — restore with `headroom wrap claude --1m`"
)
return (
"Same base-URL gate also affects on-demand tool loading "
f"({tool_part}) and the 1M context window ({context_part})."
)
_CLAUDE_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
def _version_str(version: tuple[int, int, int]) -> str:
return ".".join(str(part) for part in version)
def remote_control_gate_message(source: str, *, version: tuple[int, int, int] | None = None) -> str:
"""Return the Remote Control gate message for Claude warning paths.
Accuracy matters here (issue #1779): on Claude Code
:data:`REMOTE_CONTROL_GATED_MIN_VERSION` and newer the disable is
deterministic, so the wording states it as fact never "may".
* ``version`` known and gated name the exact version and state the
deterministic disable.
* ``version`` unknown (``None``) state the version threshold and let the
user self-identify, without falsely asserting their build.
Callers gate on :func:`remote_control_gate_active` first, so a version known
to be *older* than the threshold never reaches this function.
"""
source_clean = source.strip() or "this endpoint" source_clean = source.strip() or "this endpoint"
return REMOTE_CONTROL_DISABLED_MESSAGE.format(source=source_clean) min_ver = _version_str(REMOTE_CONTROL_GATED_MIN_VERSION)
if version is not None and version >= REMOTE_CONTROL_GATED_MIN_VERSION:
lead = (
f"Claude Code {_version_str(version)} disables the "
"/remote-control (/rc) command while "
f"{REMOTE_CONTROL_BASE_URL_ENV} points at a custom endpoint "
f"({source_clean})."
)
else:
lead = (
f"Claude Code {min_ver}+ disables the /remote-control (/rc) command "
f"while {REMOTE_CONTROL_BASE_URL_ENV} points at a custom endpoint "
f"({source_clean}); if your Claude Code is {min_ver} or newer, /rc "
"is unavailable in this session."
)
return (
f"{REMOTE_CONTROL_FEATURE}: {lead} "
"Headroom cannot override this client-side gate — run Claude without "
"Headroom for sessions that need Remote Control."
)
def is_custom_anthropic_base_url(value: str | None) -> bool: def is_custom_anthropic_base_url(value: str | None) -> bool:
"""Return whether ANTHROPIC_BASE_URL is custom from Claude's Remote Control gate view.""" """Return whether ANTHROPIC_BASE_URL is custom from Claude's Remote Control gate view.
Host-equality only (issue #1779): the scheme, port, path, and trailing
slash are ignored, and matching is exact a lookalike such as
``api.anthropic.com.evil.com`` is custom. Scheme-less values
(``myproxy.local:8080``, ``127.0.0.1:8787``) are re-parsed as a network
location; ``urlparse`` alone reads them as a path (or treats the host as a
URL *scheme*), yielding no hostname which silently classified every
scheme-less custom host as "not custom" and suppressed the warning.
"""
raw = (value or "").strip() raw = (value or "").strip()
if not raw: if not raw:
return False return False
host = (urlparse(raw).hostname or "").strip().lower() return _gate_view_host(raw) not in {"", "api.anthropic.com"}
return host not in {"", "api.anthropic.com"}
def _gate_view_host(raw: str) -> str:
"""Best-effort host extraction for the Remote Control gate view.
``urlparse`` raises ``ValueError`` on bracket-malformed input (e.g. the
typo'd IPv6 literal ``http://[::1:8787``). These values are user-editable
(shell env / settings.json), and the doctor path must degrade to "no host"
rather than crash (issue #1779). Host-less results classify as not-custom;
the routing check separately flags unusable URLs, so nothing is hidden.
"""
try:
host = (urlparse(raw).hostname or "").strip().lower()
except ValueError:
return ""
if not host and "://" not in raw:
try:
host = (urlparse(f"//{raw}").hostname or "").strip().lower()
except ValueError:
return ""
return host
def remote_control_applies_to_auth(environ: Mapping[str, object]) -> bool:
"""Return whether this auth mode is one that ever had Remote Control.
``False`` for API-key (PAYG) and cloud IAM/ADC sessions they never saw the
``/rc`` command, so the gate warning must stay silent for them (issue
#1779). See :data:`REMOTE_CONTROL_NON_SUBSCRIPTION_ENV`.
"""
return not any(
str(environ.get(key) or "").strip() for key in REMOTE_CONTROL_NON_SUBSCRIPTION_ENV
)
def parse_claude_code_version(text: str | None) -> tuple[int, int, int] | None:
"""Parse a ``MAJOR.MINOR.PATCH`` version out of ``claude --version`` output.
``claude --version`` prints e.g. ``2.1.196 (Claude Code)``. Returns the first
dotted triple found, or ``None`` when nothing parses (unknown version).
"""
match = _CLAUDE_VERSION_RE.search(text or "")
if match is None:
return None
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
def detect_claude_code_version(claude_bin: str | None = None) -> tuple[int, int, int] | None:
"""Best-effort detection of the installed Claude Code version.
Runs ``claude --version`` and parses it. Returns ``None`` on any failure
(binary missing, non-zero exit, timeout, unparseable or absent output) so
callers fall back to the version-unknown wording rather than crash. Never
raises. Uses the shared ``headroom._subprocess`` wrapper, which forces
``encoding="utf-8"`` under ``text=True`` (the repo's Windows-cp1252 guard).
"""
import shutil
import subprocess
from headroom._subprocess import run
binary = claude_bin or shutil.which("claude")
if not binary:
return None
try:
proc = run([binary, "--version"], capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return None
# getattr, not attribute access: a stubbed CompletedProcess (e.g. a test's
# SimpleNamespace) may lack stdout/stderr — degrade to "unknown", never raise.
stdout = getattr(proc, "stdout", "") or ""
stderr = getattr(proc, "stderr", "") or ""
return parse_claude_code_version(f"{stdout} {stderr}")
def remote_control_gate_active(
base_url: str | None,
environ: Mapping[str, object],
version: tuple[int, int, int] | None,
) -> bool:
"""Whether to surface the Remote Control gate warning for this session.
``True`` only when ALL hold (issue #1779):
* ``base_url`` is a custom (non-``api.anthropic.com``) endpoint the gate's
trigger,
* the auth mode is one that ever had Remote Control (not API-key / cloud
IAM) so PAYG users never see a warning for a feature they never had,
* the Claude Code version is at or above
:data:`REMOTE_CONTROL_GATED_MIN_VERSION`, **or** unknown (``None``).
Returns ``False`` when the version is known to be *older* than the gate on
those builds Remote Control is unaffected by a custom base URL, so warning
would be a false alarm.
"""
if not is_custom_anthropic_base_url(base_url):
return False
if not remote_control_applies_to_auth(environ):
return False
if version is not None and version < REMOTE_CONTROL_GATED_MIN_VERSION:
return False
return True
def proxy_base_url(port: int) -> str: def proxy_base_url(port: int) -> str:

View file

@ -41,6 +41,16 @@ def _clear_claude_mode_env(monkeypatch: pytest.MonkeyPatch) -> None:
"CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY", "CLAUDE_CODE_USE_FOUNDRY",
"VERTEX_TARGET_API_URL", "VERTEX_TARGET_API_URL",
# Issue #1779: these put Claude Code on a non-subscription auth path, so
# the Remote Control gate warning must not fire. Clear them so the
# plain-mode RC-warning assertion is deterministic regardless of the
# ambient environment the test runs in.
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_USE_BEDROCK",
# The RC sibling note reflects the resolved ENABLE_TOOL_SEARCH mode;
# clear any ambient value so the default-session assertions hold.
"ENABLE_TOOL_SEARCH",
): ):
monkeypatch.delenv(key, raising=False) monkeypatch.delenv(key, raising=False)
@ -50,6 +60,7 @@ def _invoke_wrap_claude(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
*, *,
env: dict[str, str], env: dict[str, str],
extra_args: tuple[str, ...] = (),
) -> tuple[dict[str, Any], str]: ) -> tuple[dict[str, Any], str]:
captured: dict[str, Any] = {} captured: dict[str, Any] = {}
@ -80,6 +91,10 @@ def _invoke_wrap_claude(
return _Completed() return _Completed()
monkeypatch.setattr(wrap_mod, "_ensure_proxy", fake_ensure_proxy) monkeypatch.setattr(wrap_mod, "_ensure_proxy", fake_ensure_proxy)
# Issue #1779: pin the detected Claude Code version to the gated release so
# the plain-mode RC warning is deterministic without shelling out to a real
# `claude --version` (which would otherwise hit the child-launch fake_run).
monkeypatch.setattr(wrap_mod, "detect_claude_code_version", lambda *_a, **_k: (2, 1, 196))
monkeypatch.setattr(wrap_mod.subprocess, "run", fake_run) monkeypatch.setattr(wrap_mod.subprocess, "run", fake_run)
result = runner.invoke( result = runner.invoke(
@ -91,6 +106,7 @@ def _invoke_wrap_claude(
"--no-mcp", "--no-mcp",
"--no-tokensave", "--no-tokensave",
"--no-serena", "--no-serena",
*extra_args,
], ],
env=env, env=env,
) )
@ -107,6 +123,53 @@ def test_wrap_claude_plain_mode_warns_about_remote_control_gate(
assert captured["child_cmd"] == ["/usr/bin/claude"] assert captured["child_cmd"] == ["/usr/bin/claude"]
assert "Remote Control" in output assert "Remote Control" in output
assert "wrapped Claude session's ANTHROPIC_BASE_URL" in output assert "wrapped Claude session's ANTHROPIC_BASE_URL" in output
# Issue #1779: the warning is accurate (deterministic, names /rc) and
# co-reports the sibling base-URL gates (#746 / #1158).
assert "2.1.196" in output
assert "/rc" in output
assert "may hide" not in output
assert "#746" in output and "#1158" in output
def test_wrap_claude_plain_mode_api_key_auth_skips_remote_control_warning(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
# Issue #1779: an API-key (PAYG) session never had Remote Control, so the
# gate warning must not fire even in plain proxy mode.
_captured, output = _invoke_wrap_claude(
runner, monkeypatch, env={"ANTHROPIC_API_KEY": "sk-ant-api-xxx"}
)
assert "Remote Control" not in output
def test_wrap_claude_sibling_note_accurate_under_1m_and_tool_search_optouts(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
# Issue #1779 accuracy under opt-ins: with --1m the note must not advise
# adding --1m again, and with --tool-search false it must not claim
# deferral is kept on — nor may the #746 banner line say "kept on".
_captured, output = _invoke_wrap_claude(
runner,
monkeypatch,
env={},
extra_args=("--1m", "--tool-search", "false"),
)
assert "already restored via --1m" in output
assert "restore with `headroom wrap claude --1m`" not in output
assert "OFF for this session" in output
assert "DISABLED per your setting" in output
assert "kept on" not in output
def test_wrap_claude_tool_search_banner_line_still_accurate_when_active(
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
) -> None:
# Default session: deferral is on, and both the #746 banner line and the
# RC sibling note say so.
_captured, output = _invoke_wrap_claude(runner, monkeypatch, env={})
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
def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirect( def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirect(
@ -114,7 +177,7 @@ def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirec
) -> None: ) -> None:
custom_vertex_url = "https://vertex-gateway.internal/custom/v1" custom_vertex_url = "https://vertex-gateway.internal/custom/v1"
captured, _output = _invoke_wrap_claude( captured, output = _invoke_wrap_claude(
runner, runner,
monkeypatch, monkeypatch,
env={ env={
@ -123,6 +186,10 @@ def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirec
}, },
) )
# Issue #1779: Vertex sessions authenticate with cloud IAM and never had
# Remote Control — the RC gate warning must not fire in this mode.
assert "Remote Control" not in output
ensure_kwargs = captured["ensure_kwargs"] ensure_kwargs = captured["ensure_kwargs"]
child_env = captured["child_env"] child_env = captured["child_env"]
write_kwargs = captured["write_base_url_kwargs"] write_kwargs = captured["write_base_url_kwargs"]
@ -189,7 +256,7 @@ def test_wrap_claude_foundry_proxy_env_behavior_is_unchanged(
) -> None: ) -> None:
foundry_url = "https://my-resource.services.ai.azure.com/anthropic" foundry_url = "https://my-resource.services.ai.azure.com/anthropic"
captured, _output = _invoke_wrap_claude( captured, output = _invoke_wrap_claude(
runner, runner,
monkeypatch, monkeypatch,
env={ env={
@ -198,6 +265,10 @@ def test_wrap_claude_foundry_proxy_env_behavior_is_unchanged(
}, },
) )
# Issue #1779: Foundry sessions authenticate with Azure credentials and
# never had Remote Control — the RC gate warning must not fire.
assert "Remote Control" not in output
ensure_kwargs = captured["ensure_kwargs"] ensure_kwargs = captured["ensure_kwargs"]
child_env = captured["child_env"] child_env = captured["child_env"]
assert ensure_kwargs["anthropic_api_url"] == foundry_url assert ensure_kwargs["anthropic_api_url"] == foundry_url

View file

@ -172,6 +172,138 @@ class TestClaudeRemoteControlGate:
) )
assert check_claude_remote_control_gate(path, {}) is None assert check_claude_remote_control_gate(path, {}) is None
def test_api_key_auth_suppresses_warning(self, tmp_path):
# Issue #1779: a PAYG / API-key session never had Remote Control, so the
# gate warning must not fire even behind a custom base URL.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {"ANTHROPIC_API_KEY": "sk-ant-api-x"}) is None
def test_settings_api_key_suppresses_warning(self, tmp_path):
# An API key configured in settings.json (not just the shell) also means
# a non-subscription session — stay silent.
path = tmp_path / "settings.json"
path.write_text(
json.dumps(
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787",
"ANTHROPIC_API_KEY": "sk-ant-api-x",
}
}
),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {}) is None
def test_version_resolver_not_called_without_custom_base(self, tmp_path):
# The `claude --version` subprocess is expensive (Node CLI cold start);
# the check must not invoke the resolver when no custom base URL exists.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://api.anthropic.com"}}),
encoding="utf-8",
)
def boom() -> tuple[int, int, int]:
raise AssertionError("resolver must not run when no custom base URL")
assert check_claude_remote_control_gate(path, {}, version_resolver=boom) is None
def test_version_resolver_not_called_for_api_key_auth(self, tmp_path):
# PAYG sessions are suppressed before version matters — no subprocess.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
def boom() -> tuple[int, int, int]:
raise AssertionError("resolver must not run for API-key auth")
assert (
check_claude_remote_control_gate(
path, {"ANTHROPIC_API_KEY": "sk-ant-api-x"}, version_resolver=boom
)
is None
)
def test_version_resolver_called_once_and_honored(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",
)
calls: list[int] = []
def resolver() -> tuple[int, int, int]:
calls.append(1)
return (2, 1, 196)
# Shell env ALSO custom so both loop sources are live — still one call.
result = check_claude_remote_control_gate(
path,
{"ANTHROPIC_BASE_URL": "http://127.0.0.1:9999"},
version_resolver=resolver,
)
assert result is not None
assert "2.1.196" in result.summary
assert calls == [1]
def test_version_resolver_pre_gate_version_suppresses(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",
)
assert (
check_claude_remote_control_gate(path, {}, version_resolver=lambda: (2, 1, 195)) is None
)
def test_malformed_settings_base_url_does_not_crash(self, tmp_path):
# Issue #1779: settings.json is user-edited; a typo'd IPv6 literal made
# urlparse raise ValueError("Invalid IPv6 URL") and crashed doctor.
# Malformed values degrade to "no host" and the check stays silent —
# check_claude_routing separately flags unusable URLs.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://[::1:8787"}}),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {}) is None
def test_malformed_shell_base_url_does_not_crash(self, tmp_path):
path = tmp_path / "settings.json"
path.write_text("{}", encoding="utf-8")
assert check_claude_remote_control_gate(path, {"ANTHROPIC_BASE_URL": "http://["}) is None
def test_pre_gate_version_suppresses_warning(self, tmp_path):
# Older Claude Code does not gate RC on the base URL — no false alarm.
path = tmp_path / "settings.json"
path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}),
encoding="utf-8",
)
assert check_claude_remote_control_gate(path, {}, version=(2, 1, 195)) is None
def test_gated_version_warns_with_exact_version(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, {}, version=(2, 1, 196))
assert result is not None
assert result.status == WARN
assert "2.1.196" in result.summary
assert "disables" in result.summary
# Sibling gates are co-reported in the hint (#746 / #1158).
assert "#746" in (result.hint or "")
assert "#1158" in (result.hint or "")
def test_settings_check_still_routes(self, tmp_path): def test_settings_check_still_routes(self, tmp_path):
path = tmp_path / "settings.json" path = tmp_path / "settings.json"
path.write_text( path.write_text(

View file

@ -22,4 +22,7 @@ def test_remote_control_gate_message_mentions_warning_and_source() -> None:
message = remote_control_gate_message(source=REMOTE_CONTROL_BASE_URL_ENV) message = remote_control_gate_message(source=REMOTE_CONTROL_BASE_URL_ENV)
assert "Remote Control" in message assert "Remote Control" in message
assert REMOTE_CONTROL_BASE_URL_ENV in message assert REMOTE_CONTROL_BASE_URL_ENV in message
assert "launch Claude without Headroom for sessions that need this feature" in message # Issue #1779: the wording must be accurate — name the /rc command and tell
# the user how to regain it, without the old hedged "may hide the menu".
assert "/rc" in message
assert "run Claude without Headroom for sessions that need Remote Control" in message

View file

@ -0,0 +1,248 @@
"""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)
# ---------------------------------------------------------------------------
# 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