mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
c3db8e47f8
commit
daeff69a75
9 changed files with 832 additions and 41 deletions
|
|
@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### 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)).
|
||||
* **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`.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
|
@ -29,7 +29,11 @@ from headroom.install.state import list_manifests
|
|||
from headroom.paths import savings_path
|
||||
from headroom.providers.claude import (
|
||||
REMOTE_CONTROL_BASE_URL_ENV,
|
||||
REMOTE_CONTROL_SIBLING_GATE_NOTE,
|
||||
detect_claude_code_version,
|
||||
is_custom_anthropic_base_url,
|
||||
remote_control_applies_to_auth,
|
||||
remote_control_gate_active,
|
||||
remote_control_gate_message,
|
||||
)
|
||||
|
||||
|
|
@ -168,37 +172,71 @@ def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
|
|||
|
||||
|
||||
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:
|
||||
"""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"
|
||||
settings_env: dict[str, object] = {}
|
||||
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_env = env_block
|
||||
settings_base_url = str(env_block.get("ANTHROPIC_BASE_URL", "") or "")
|
||||
except (OSError, ValueError):
|
||||
settings_env = {}
|
||||
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", "")
|
||||
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,
|
||||
)
|
||||
|
||||
resolved_version = version
|
||||
version_resolved = version is not None or version_resolver is None
|
||||
|
||||
for base_url, source in (
|
||||
(settings_base_url, "from settings"),
|
||||
(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
|
||||
|
||||
|
||||
|
|
@ -473,7 +511,12 @@ 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)
|
||||
# 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:
|
||||
checks.append(remote_control_gate_check)
|
||||
deployments = check_deployments(list_manifests())
|
||||
|
|
|
|||
|
|
@ -61,8 +61,11 @@ from headroom.providers.claude import (
|
|||
REMOTE_CONTROL_BASE_URL_ENV,
|
||||
TOOL_SEARCH_DEFAULT,
|
||||
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_sibling_gate_note,
|
||||
)
|
||||
from headroom.providers.claude import (
|
||||
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
|
||||
|
||||
|
||||
# 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:
|
||||
"""Return the current live wrap module instance."""
|
||||
return cast(Any, sys.modules[__name__])
|
||||
|
|
@ -4070,11 +4099,37 @@ def claude(
|
|||
)
|
||||
else:
|
||||
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(
|
||||
" "
|
||||
+ 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:
|
||||
|
|
@ -4130,9 +4185,16 @@ def claude(
|
|||
# proxy so tool schemas are not eagerly materialized into local context.
|
||||
_tool_search_value = _configure_tool_search_env(env, tool_search)
|
||||
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(
|
||||
f" {_TOOL_SEARCH_ENV}={_tool_search_value} "
|
||||
"(on-demand tool loading kept on; issue #746)"
|
||||
f" {_TOOL_SEARCH_ENV}={_tool_search_value} ({_tool_search_state}; issue #746)"
|
||||
)
|
||||
elif verbose:
|
||||
click.echo(
|
||||
|
|
|
|||
|
|
@ -3,19 +3,35 @@
|
|||
from .runtime import (
|
||||
DEFAULT_API_URL,
|
||||
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_ENV,
|
||||
detect_claude_code_version,
|
||||
is_custom_anthropic_base_url,
|
||||
parse_claude_code_version,
|
||||
proxy_base_url,
|
||||
remote_control_applies_to_auth,
|
||||
remote_control_gate_active,
|
||||
remote_control_gate_message,
|
||||
remote_control_sibling_gate_note,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_API_URL",
|
||||
"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_ENV",
|
||||
"detect_claude_code_version",
|
||||
"is_custom_anthropic_base_url",
|
||||
"remote_control_gate_message",
|
||||
"parse_claude_code_version",
|
||||
"proxy_base_url",
|
||||
"remote_control_applies_to_auth",
|
||||
"remote_control_gate_active",
|
||||
"remote_control_gate_message",
|
||||
"remote_control_sibling_gate_note",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
DEFAULT_API_URL = "https://api.anthropic.com"
|
||||
|
|
@ -15,28 +17,241 @@ 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."
|
||||
|
||||
# GH #1779: Claude Code v2.1.196 added a client-side eligibility check that
|
||||
# DISABLES first-party Remote Control (`/remote-control` / `/rc`, which mirrors a
|
||||
# local CLI session to claude.ai/code and the mobile apps) whenever
|
||||
# ANTHROPIC_BASE_URL points at a non-`api.anthropic.com` host. Headroom routes
|
||||
# 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:
|
||||
"""Return the shared Remote Control compatibility message for Claude warning paths."""
|
||||
def remote_control_sibling_gate_note(*, tool_search_active: bool, context_1m_enabled: bool) -> str:
|
||||
"""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"
|
||||
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:
|
||||
"""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()
|
||||
if not raw:
|
||||
return False
|
||||
host = (urlparse(raw).hostname or "").strip().lower()
|
||||
return host not in {"", "api.anthropic.com"}
|
||||
return _gate_view_host(raw) 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:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ def _clear_claude_mode_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
"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)
|
||||
|
||||
|
|
@ -50,6 +60,7 @@ def _invoke_wrap_claude(
|
|||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
env: dict[str, str],
|
||||
extra_args: tuple[str, ...] = (),
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
|
|
@ -80,6 +91,10 @@ def _invoke_wrap_claude(
|
|||
return _Completed()
|
||||
|
||||
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)
|
||||
|
||||
result = runner.invoke(
|
||||
|
|
@ -91,6 +106,7 @@ def _invoke_wrap_claude(
|
|||
"--no-mcp",
|
||||
"--no-tokensave",
|
||||
"--no-serena",
|
||||
*extra_args,
|
||||
],
|
||||
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 "Remote Control" 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(
|
||||
|
|
@ -114,7 +177,7 @@ def test_wrap_claude_vertex_passes_custom_base_url_to_proxy_before_child_redirec
|
|||
) -> None:
|
||||
custom_vertex_url = "https://vertex-gateway.internal/custom/v1"
|
||||
|
||||
captured, _output = _invoke_wrap_claude(
|
||||
captured, output = _invoke_wrap_claude(
|
||||
runner,
|
||||
monkeypatch,
|
||||
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"]
|
||||
child_env = captured["child_env"]
|
||||
write_kwargs = captured["write_base_url_kwargs"]
|
||||
|
|
@ -189,7 +256,7 @@ def test_wrap_claude_foundry_proxy_env_behavior_is_unchanged(
|
|||
) -> None:
|
||||
foundry_url = "https://my-resource.services.ai.azure.com/anthropic"
|
||||
|
||||
captured, _output = _invoke_wrap_claude(
|
||||
captured, output = _invoke_wrap_claude(
|
||||
runner,
|
||||
monkeypatch,
|
||||
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"]
|
||||
child_env = captured["child_env"]
|
||||
assert ensure_kwargs["anthropic_api_url"] == foundry_url
|
||||
|
|
|
|||
|
|
@ -172,6 +172,138 @@ class TestClaudeRemoteControlGate:
|
|||
)
|
||||
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):
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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
|
||||
# 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
|
||||
|
|
|
|||
248
tests/test_issue_1779_remote_control_gate.py
Normal file
248
tests/test_issue_1779_remote_control_gate.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue