From daeff69a7549d40b579caf2b5d6a2840a5df68b5 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha <148203331+Ayushraj06-bit@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:31:37 +0530 Subject: [PATCH] =?UTF-8?q?fix(wrap):=20surface=20Claude=20Remote=20Contro?= =?UTF-8?q?l=20base-URL=20gate=20accurately=20(#1=E2=80=A6=20(#1883)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …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 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 - [ ] 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 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Tejas Chopra --- CHANGELOG.md | 1 + headroom/cli/doctor.py | 83 ++++-- headroom/cli/wrap.py | 72 ++++- headroom/providers/claude/__init__.py | 18 +- headroom/providers/claude/runtime.py | 239 ++++++++++++++++- .../test_wrap_claude_vertex_proxy_env.py | 75 +++++- tests/test_cli_doctor.py | 132 ++++++++++ tests/test_issue_1601_remote_control_gate.py | 5 +- tests/test_issue_1779_remote_control_gate.py | 248 ++++++++++++++++++ 9 files changed, 832 insertions(+), 41 deletions(-) create mode 100644 tests/test_issue_1779_remote_control_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d494772c3..65fd0c277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index 3176afa3e..9a970b5e1 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -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()) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 369ece852..8bf691c7f 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -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( diff --git a/headroom/providers/claude/__init__.py b/headroom/providers/claude/__init__.py index b91fb20c1..e15e1b2c3 100644 --- a/headroom/providers/claude/__init__.py +++ b/headroom/providers/claude/__init__.py @@ -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", ] diff --git a/headroom/providers/claude/runtime.py b/headroom/providers/claude/runtime.py index 84da9236e..cec4b4af9 100644 --- a/headroom/providers/claude/runtime.py +++ b/headroom/providers/claude/runtime.py @@ -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:, 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: diff --git a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py index 1e6685711..efeadcfd1 100644 --- a/tests/test_cli/test_wrap_claude_vertex_proxy_env.py +++ b/tests/test_cli/test_wrap_claude_vertex_proxy_env.py @@ -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 diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 5ece31a4e..ab8ff91fe 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -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( diff --git a/tests/test_issue_1601_remote_control_gate.py b/tests/test_issue_1601_remote_control_gate.py index 7a6fbf779..f98c85ebf 100644 --- a/tests/test_issue_1601_remote_control_gate.py +++ b/tests/test_issue_1601_remote_control_gate.py @@ -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 diff --git a/tests/test_issue_1779_remote_control_gate.py b/tests/test_issue_1779_remote_control_gate.py new file mode 100644 index 000000000..cc6118335 --- /dev/null +++ b/tests/test_issue_1779_remote_control_gate.py @@ -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