fix(doctor): surface that Claude Desktop agent sessions bypass the proxy (#2987)
## Description `headroom doctor` reports the `claude` check as a pass whenever `~/.claude/settings.json` carries an `ANTHROPIC_BASE_URL` pointing at the proxy. That is correct for the terminal Claude Code CLI. But Claude Desktop (`com.anthropic.claudefordesktop`) unconditionally overwrites that variable when spawning agent sessions (#869), so on a Desktop-primary machine `doctor` asserts routing that is in fact discarded, and nothing in the output hints that Desktop sessions are unrouted (#2925). ## Fix Add a per-surface `claude desktop` check that warns about the bypass when Claude Desktop's config directory is detected, pointing at #869. Following the issue's suggestion, it models per-surface reporting like the existing `wrap_marker` / `shell env` rows: it is a separate row emitted only when Desktop is present, so it never contradicts a genuinely routed CLI, and the existing `claude` check is left unchanged. Detection uses Claude Desktop's per-user config directory (distinct from the CLI's `~/.claude`): - macOS: `~/Library/Application Support/Claude` - Windows: `%APPDATA%\Claude` - Linux: `$XDG_CONFIG_HOME/Claude` (or `~/.config/Claude`) Fixes #2925 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/cli/doctor.py`: add `claude_desktop_config_dir()` (cross-platform) and `check_claude_desktop()` (WARN when the dir exists, `None` otherwise); append it to the `doctor()` check list when present. - `tests/test_cli_doctor.py`: `TestClaudeDesktop` -- no row when absent; WARN naming the bypass and #869 when present; the `doctor --json` entrypoint appends the row only when Desktop is detected. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added ### Test Output ```text tests/test_cli_doctor.py 78 passed # uvx ruff@0.15.22 check -> All checks passed! # uvx mypy@1.20.2 headroom/cli/doctor.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 and mypy 1.20.2 via uvx. - Exact command / steps: `uvx ruff@0.15.22 check headroom/cli/doctor.py tests/test_cli_doctor.py`; `uvx mypy@1.20.2 headroom/cli/doctor.py`; `python -m pytest tests/test_cli_doctor.py -q`; then drove the check directly and through the `doctor --json` entrypoint with `claude_desktop_config_dir` pointed at a tmp dir (created the dir, ran `doctor --json`, then removed it and reran). - Observed result: with the dir present, a `claude desktop` row appears with status `warn` and a `#869` hint; with the dir absent, no such row is emitted and the rest of the report is unchanged. A Desktop-primary machine now gets an explicit warning that Desktop agent sessions bypass the proxy, instead of a bare `claude: pass` that reads as though all Claude routing is live. - Not tested: a live Claude Desktop install (detection is directory-existence, exercised against a tmp dir). ## Runtime Rollout Safety - Rollout-managed feature(s): none. This adds a read-only diagnostic row to `headroom doctor`; it is not behind any rollout channel or feature flag. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: no. The existing `claude` check and all other rows are unchanged; the new `claude desktop` row is additive and only appears when Claude Desktop's config directory is detected. - Kill switch / disable path: N/A. The row self-suppresses (returns `None`) on any machine without the Desktop config directory. - Unsafe override required: no. - Qualification impact: none. No proxy request path, routing, or token accounting is touched; the change is confined to the doctor diagnostic surface. - Rollback path: revert this PR; the doctor output returns to its prior set of rows with no state or migration to undo. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`: it is generated by release-please from my Conventional Commit PR title ## Additional Notes Scope: this warns whenever Claude Desktop is present, which is accurate (Desktop agent sessions always bypass per #869) and matches the precedent for doctor-accuracy fixes (#2618/#2614 Codex, #2566 ollama). The issue's stronger refinement -- suppress the warning when a `client=claude-code` request has recently reached the proxy -- would need per-client traffic observation the doctor does not have today; I left that as a follow-up rather than build new traffic-tracking infra into this fix. Happy to add it if you'd prefer the conditional form. Rebased onto current `main` to resolve an overlap with the newly merged `check_claude_auth_conflict` in `doctor.py`; both checks now coexist. Co-authored-by: JD Davis <mxjerrett@gmail.com>
This commit is contained in:
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
@@ -224,6 +225,51 @@ def check_claude_auth_conflict(
|
||||
)
|
||||
|
||||
|
||||
def claude_desktop_config_dir() -> Path:
|
||||
"""Return Claude Desktop's per-user config directory for this platform.
|
||||
|
||||
Claude Desktop (``com.anthropic.claudefordesktop``) stores its config here,
|
||||
distinct from Claude Code CLI's ``~/.claude``. Directory existence is used as
|
||||
a proxy for "Desktop is installed / has been run" (#2925).
|
||||
"""
|
||||
home = Path.home()
|
||||
if sys.platform == "darwin":
|
||||
return home / "Library" / "Application Support" / "Claude"
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
base = Path(appdata) if appdata else home / "AppData" / "Roaming"
|
||||
return base / "Claude"
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else home / ".config"
|
||||
return base / "Claude"
|
||||
|
||||
|
||||
def check_claude_desktop(config_dir: Path) -> CheckResult | None:
|
||||
"""Surface that Claude Desktop agent sessions bypass the proxy (#2925 / #869).
|
||||
|
||||
Claude Desktop unconditionally overwrites ``ANTHROPIC_BASE_URL`` when it
|
||||
spawns agent sessions, so a correctly-wrapped ``~/.claude/settings.json``
|
||||
(which the ``claude`` check verifies for the terminal CLI) does not route
|
||||
Desktop traffic. Without this, ``doctor`` passes on the settings value alone
|
||||
and never hints that Desktop sessions are unrouted.
|
||||
|
||||
Reported as its own per-surface row -- like ``wrap_marker`` and ``shell env``
|
||||
-- and only when Desktop is detected, so it never contradicts a genuinely
|
||||
routed CLI. Returns ``None`` when Desktop is absent (no row).
|
||||
"""
|
||||
if not config_dir.exists():
|
||||
return None
|
||||
return CheckResult(
|
||||
name="claude desktop",
|
||||
status=WARN,
|
||||
summary="agent sessions bypass the proxy (Desktop overwrites ANTHROPIC_BASE_URL)",
|
||||
hint=(
|
||||
"Desktop routing is not supported yet (see #869); use the terminal "
|
||||
"Claude Code CLI for proxy-routed sessions."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def check_claude_remote_control_gate(
|
||||
settings_path: Path,
|
||||
environ: Mapping[str, str],
|
||||
@@ -630,6 +676,9 @@ def doctor(port: int, emit_json: bool) -> None:
|
||||
)
|
||||
if remote_control_gate_check is not None:
|
||||
checks.append(remote_control_gate_check)
|
||||
desktop_check = check_claude_desktop(claude_desktop_config_dir())
|
||||
if desktop_check is not None:
|
||||
checks.append(desktop_check)
|
||||
deployments = check_deployments(list_manifests())
|
||||
if deployments is not None:
|
||||
checks.append(deployments)
|
||||
|
||||
@@ -15,6 +15,7 @@ from headroom.cli.doctor import (
|
||||
SKIP,
|
||||
WARN,
|
||||
check_budget,
|
||||
check_claude_desktop,
|
||||
check_claude_remote_control_gate,
|
||||
check_claude_routing,
|
||||
check_codex_routing,
|
||||
@@ -152,6 +153,43 @@ class TestClaudeRouting:
|
||||
assert "gateway.corp.example" in result.summary
|
||||
|
||||
|
||||
class TestClaudeDesktop:
|
||||
def test_no_desktop_dir_produces_no_row(self, tmp_path):
|
||||
# #2925: absent Desktop -> no row, so it never contradicts a routed CLI.
|
||||
assert check_claude_desktop(tmp_path / "Claude") is None
|
||||
|
||||
def test_desktop_present_warns_about_bypass(self, tmp_path):
|
||||
desktop = tmp_path / "Claude"
|
||||
desktop.mkdir()
|
||||
result = check_claude_desktop(desktop)
|
||||
assert result is not None
|
||||
assert result.name == "claude desktop"
|
||||
assert result.status == WARN
|
||||
assert "bypass" in result.summary
|
||||
assert "#869" in (result.hint or "")
|
||||
|
||||
def test_doctor_appends_desktop_row_when_present(self, tmp_path, monkeypatch):
|
||||
# Integration: the entrypoint surfaces the Desktop row when detected.
|
||||
desktop = tmp_path / "Claude"
|
||||
desktop.mkdir()
|
||||
monkeypatch.setattr(doctor_mod, "claude_desktop_config_dir", lambda: desktop)
|
||||
monkeypatch.setattr(doctor_mod, "probe_json", lambda *a, **k: None)
|
||||
monkeypatch.setattr(doctor_mod, "list_manifests", lambda: [])
|
||||
result = CliRunner().invoke(main, ["doctor", "--json"])
|
||||
payload = json.loads(result.output)
|
||||
rows = {c["name"]: c for c in payload["checks"]}
|
||||
assert "claude desktop" in rows
|
||||
assert rows["claude desktop"]["status"] == WARN
|
||||
|
||||
def test_doctor_omits_desktop_row_when_absent(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(doctor_mod, "claude_desktop_config_dir", lambda: tmp_path / "Claude")
|
||||
monkeypatch.setattr(doctor_mod, "probe_json", lambda *a, **k: None)
|
||||
monkeypatch.setattr(doctor_mod, "list_manifests", lambda: [])
|
||||
result = CliRunner().invoke(main, ["doctor", "--json"])
|
||||
payload = json.loads(result.output)
|
||||
assert "claude desktop" not in {c["name"] for c in payload["checks"]}
|
||||
|
||||
|
||||
class TestClaudeRemoteControlGate:
|
||||
def test_settings_custom_base_warns(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
|
||||
Reference in New Issue
Block a user