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:
Abhay Singh
2026-08-17 03:34:44 +05:30
committed by GitHub
parent 536c949a69
commit be5b26d807
2 changed files with 87 additions and 0 deletions
+49
View File
@@ -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)
+38
View File
@@ -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"