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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 87 additions and 0 deletions

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import sys
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime 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( def check_claude_remote_control_gate(
settings_path: Path, settings_path: Path,
environ: Mapping[str, str], environ: Mapping[str, str],
@ -630,6 +676,9 @@ def doctor(port: int, emit_json: bool) -> None:
) )
if remote_control_gate_check is not None: if remote_control_gate_check is not None:
checks.append(remote_control_gate_check) 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()) deployments = check_deployments(list_manifests())
if deployments is not None: if deployments is not None:
checks.append(deployments) checks.append(deployments)

View file

@ -15,6 +15,7 @@ from headroom.cli.doctor import (
SKIP, SKIP,
WARN, WARN,
check_budget, check_budget,
check_claude_desktop,
check_claude_remote_control_gate, check_claude_remote_control_gate,
check_claude_routing, check_claude_routing,
check_codex_routing, check_codex_routing,
@ -152,6 +153,43 @@ class TestClaudeRouting:
assert "gateway.corp.example" in result.summary 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: class TestClaudeRemoteControlGate:
def test_settings_custom_base_warns(self, tmp_path): def test_settings_custom_base_warns(self, tmp_path):
path = tmp_path / "settings.json" path = tmp_path / "settings.json"