fix(doctor): report project-scoped Claude routing instead of a false negative

`headroom doctor` read only `~/.claude/settings.json`, but Claude Code layers
project settings over user settings and `headroom init claude` without
--global writes the project-scoped `.claude/settings.local.json`. Sessions
routed that way were reported "not routed" while they were demonstrably
routed -- `ps eww` on the live process showed ANTHROPIC_BASE_URL pointing at
the proxy, the mcp__headroom__* tools were present, and headroom_stats showed
164 of 174 requests compressed on that very session (#3205).

The cost was not cosmetic: the team believed 3 of 4 sessions were unrouted on
doctor's word and hand-checked `ps eww` plus MCP tool presence on each one to
find the real state.

check_claude_routing now takes the project-scoped candidates and consults them
in Claude's own precedence order (project local, project, then user),
reporting the first that carries ANTHROPIC_BASE_URL. The summary names the
file that supplied it, so which scope is in effect is never ambiguous -- the
ambiguity is what made this expensive to diagnose.

Reading more files must not turn a routed session into a crash or a silent
skip, so per-file parse failures are surfaced verbatim rather than swallowed:
an unreadable project file reports "could not parse", not "not routed". The
existing non-dict guard is preserved per file, since a hand-edited settings
file containing `[]` or `null` would otherwise raise AttributeError inside the
very command run to diagnose it.

The third argument is optional and defaults to the previous single-file
behaviour, so existing callers and tests are unaffected.

The reporter suggested scraping live `claude` process environments via `ps`.
That is not needed here and would be platform-specific: the routing is written
to a file we already know the path of, and the gap was that we read the wrong
scope.

Refs #3205 (issue 2 of 2; the wrap-session crashes are not addressed here)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tejas Chopra 2026-08-22 12:53:48 -07:00
parent 91186b40d8
commit c820658f2a
2 changed files with 136 additions and 24 deletions

View file

@ -15,7 +15,7 @@ import json
import os
import re
import sys
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
@ -149,47 +149,76 @@ def check_version_drift(livez: dict[str, Any] | None, installed: str) -> CheckRe
)
def check_claude_routing(settings_path: Path, port: int) -> CheckResult:
"""Is Claude Code configured to route through the proxy?"""
def _claude_base_url_in(path: Path) -> tuple[str, CheckResult | None]:
"""Read ``env.ANTHROPIC_BASE_URL`` from one Claude settings file.
Returns ``(base_url, error)``. A parse problem comes back as a WARN so the
caller surfaces it verbatim instead of skipping the file and reporting the
misleading "not routed".
"""
name = "claude"
if not settings_path.exists():
return CheckResult(
name=name,
status=WARN,
summary="not routed (no ~/.claude/settings.json)",
hint="wrap it: headroom wrap claude",
)
try:
payload = json.loads(settings_path.read_text(encoding="utf-8"))
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return CheckResult(
name=name,
status=WARN,
summary=f"could not parse {settings_path}: {exc}",
)
return "", CheckResult(name=name, status=WARN, summary=f"could not parse {path}: {exc}")
# `json.loads` succeeds on valid non-object JSON (e.g. `[]`, `null`, `42`),
# which a hand-edited or reset settings file can contain. `.get` on a
# non-dict raises AttributeError, and it is not one of the caught parse
# errors above, so it would crash the very command run to diagnose the
# broken config. Treat a non-object like an unparseable file.
if not isinstance(payload, dict):
return CheckResult(
return "", CheckResult(
name=name,
status=WARN,
summary=f"could not parse {settings_path}: not a JSON object",
summary=f"could not parse {path}: not a JSON object",
)
base_url = ""
env_block = payload.get("env")
if isinstance(env_block, dict):
base_url = str(env_block.get("ANTHROPIC_BASE_URL", "") or "")
if not base_url:
return str(env_block.get("ANTHROPIC_BASE_URL", "") or ""), None
return "", None
def check_claude_routing(
settings_path: Path,
port: int,
project_settings_paths: Sequence[Path] | None = None,
) -> CheckResult:
"""Is Claude Code configured to route through the proxy?
Claude Code layers project settings over user settings, and `headroom init
claude` without --global writes the project-scoped
``.claude/settings.local.json``. Reading only ``~/.claude/settings.json``
reported "not routed" for sessions that demonstrably were -- confirmed by
`ps eww` on the live process and by active compression on it (#3205).
Candidates are consulted in Claude's own precedence order, and the summary
names the file that supplied the routing so the scope is never ambiguous.
"""
name = "claude"
candidates = [*(project_settings_paths or []), settings_path]
existing = [path for path in candidates if path.exists()]
if not existing:
return CheckResult(
name=name,
status=WARN,
summary="not routed (no ANTHROPIC_BASE_URL in settings env)",
summary="not routed (no ~/.claude/settings.json)",
hint="wrap it: headroom wrap claude",
)
return _classify_routing_url(name, base_url, port, source=str(settings_path))
first_error: CheckResult | None = None
for candidate in existing:
base_url, error = _claude_base_url_in(candidate)
if error is not None:
first_error = first_error or error
continue
if base_url:
return _classify_routing_url(name, base_url, port, source=str(candidate))
if first_error is not None:
return first_error
return CheckResult(
name=name,
status=WARN,
summary="not routed (no ANTHROPIC_BASE_URL in settings env)",
hint="wrap it: headroom wrap claude",
)
def check_claude_auth_conflict(
@ -653,7 +682,11 @@ def doctor(port: int, emit_json: bool) -> None:
checks = [
check_proxy_liveness(livez, base_url),
check_version_drift(livez, installed),
check_claude_routing(claude_settings_path(), port),
check_claude_routing(
claude_settings_path(),
port,
[project_local_claude_settings, project_claude_settings],
),
check_wrap_marker_staleness(project_local_claude_settings),
check_codex_routing(codex_config_path(), port),
check_shell_env(os.environ, port),

View file

@ -375,6 +375,85 @@ class TestClaudeRemoteControlGate:
assert result.status == PASS
class TestClaudeRoutingScope:
"""Project-scoped routing must not read as "not routed" (#3205).
`headroom init claude` without --global writes
`.claude/settings.local.json`. Reading only `~/.claude/settings.json`
reported not-routed for sessions that were genuinely routed and actively
compressing, which sent one team hand-checking `ps eww` on every session.
"""
@staticmethod
def _settings(path, base_url): # noqa: ANN001, ANN205
path.parent.mkdir(parents=True, exist_ok=True)
body = {"env": {"ANTHROPIC_BASE_URL": base_url}} if base_url else {"env": {}}
path.write_text(json.dumps(body), encoding="utf-8")
return path
def test_project_local_settings_count_as_routed(self, tmp_path):
user = tmp_path / "user" / "settings.json"
project = self._settings(
tmp_path / "proj" / ".claude" / "settings.local.json", "http://127.0.0.1:8787"
)
result = check_claude_routing(user, 8787, [project])
assert result.status == PASS
assert "settings.local.json" in result.summary or "settings.local.json" in str(result)
def test_project_settings_json_counts_as_routed(self, tmp_path):
user = tmp_path / "user" / "settings.json"
project = self._settings(
tmp_path / "proj" / ".claude" / "settings.json", "http://127.0.0.1:8787"
)
assert check_claude_routing(user, 8787, [project]).status == PASS
def test_project_scope_takes_precedence_over_user_scope(self, tmp_path):
"""Claude layers project over user, so the reported port follows suit."""
user = self._settings(tmp_path / "user" / "settings.json", "http://127.0.0.1:9999")
project = self._settings(
tmp_path / "proj" / ".claude" / "settings.local.json", "http://127.0.0.1:8787"
)
assert check_claude_routing(user, 8787, [project]).status == PASS
def test_falls_back_to_user_scope_when_project_has_no_base_url(self, tmp_path):
user = self._settings(tmp_path / "user" / "settings.json", "http://127.0.0.1:8787")
project = self._settings(tmp_path / "proj" / ".claude" / "settings.local.json", "")
assert check_claude_routing(user, 8787, [project]).status == PASS
def test_still_warns_when_nothing_routes(self, tmp_path):
user = self._settings(tmp_path / "user" / "settings.json", "")
project = self._settings(tmp_path / "proj" / ".claude" / "settings.local.json", "")
assert check_claude_routing(user, 8787, [project]).status == WARN
def test_missing_project_file_is_skipped_not_fatal(self, tmp_path):
user = self._settings(tmp_path / "user" / "settings.json", "http://127.0.0.1:8787")
absent = tmp_path / "proj" / ".claude" / "settings.local.json"
assert check_claude_routing(user, 8787, [absent]).status == PASS
def test_unparseable_project_file_surfaces_rather_than_reporting_not_routed(self, tmp_path):
project = tmp_path / "proj" / ".claude" / "settings.local.json"
project.parent.mkdir(parents=True, exist_ok=True)
project.write_text("{not json", encoding="utf-8")
user = tmp_path / "user" / "settings.json"
result = check_claude_routing(user, 8787, [project])
assert result.status == WARN
assert "could not parse" in result.summary
def test_no_project_paths_preserves_original_behaviour(self, tmp_path):
user = self._settings(tmp_path / "user" / "settings.json", "http://127.0.0.1:8787")
assert check_claude_routing(user, 8787).status == PASS
class TestCodexRouting:
def test_missing_file_warns(self, tmp_path):
assert check_codex_routing(tmp_path / "config.toml", 8787).status == WARN