diff --git a/CHANGELOG.md b/CHANGELOG.md index 9248773..0c95e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ never silent. Format loosely follows [Keep a Changelog](https://keepachangelog.c ## [Unreleased] +### Added — Claude subscription auth on Linux (credential file fallback) + +- `credentials.claude_code` now reads the Claude Code OAuth token from + `~/.claude/.credentials.json` when the macOS keychain yields nothing, so the + `claude-pro` provider (subscription-quota billing) works on Linux boxes and + servers, not just macOS. macOS is unchanged: keychain first, file as fallback. +- `is_claude_code_available()` and `ClaudeCodeCredentials.read()` resolve a + credential from either source and return None/False only when neither is + present. The reader never raises and never logs the token contents. + ### Added — Iterative skill routing with watchmen-driven improvement - New `watchmen route --bucket ` reads the corpus to find diff --git a/src/watchmen/credentials/claude_code.py b/src/watchmen/credentials/claude_code.py index 96d171c..2591042 100644 --- a/src/watchmen/credentials/claude_code.py +++ b/src/watchmen/credentials/claude_code.py @@ -21,24 +21,36 @@ — traffic is billed against the user's Claude subscription quota rather than per-token API credits. This is the whole point of the integration. -Linux + Windows store credentials differently (libsecret / dconf / -DPAPI). They're out of scope for v0.7; `is_claude_code_available()` -returns False on those platforms so the rest of the code degrades -gracefully without OS-specific guards everywhere. +On macOS the credential lives in the keychain (read via `security`). On +Linux (and any non-macOS host) Claude Code writes the same `claudeAiOauth` +payload to `~/.claude/.credentials.json` (mode 0600); we read that file as a +fallback so watchmen can run on a Claude subscription from a Linux box or +server, not just a Mac. macOS still prefers the keychain and falls back to +the file if the keychain has no entry. Windows libsecret/DPAPI stores remain +out of scope; when neither source yields a blob, `read()` returns None and +`is_claude_code_available()` returns False so the rest of the code degrades +gracefully. """ from __future__ import annotations import json import subprocess -import sys import time from dataclasses import dataclass +from pathlib import Path _KEYCHAIN_SERVICE = "Claude Code-credentials" +def _credentials_file() -> Path: + """On-disk credential location used on non-macOS hosts (and as a macOS + fallback). Resolved per call so tests can monkeypatch `Path.home()` / + this helper to point at a fixture file.""" + return Path.home() / ".claude" / ".credentials.json" + + @dataclass(frozen=True) class ClaudeCodeCredentials: """In-memory view of the keychain payload's `claudeAiOauth` block. @@ -62,17 +74,15 @@ def read(cls) -> "ClaudeCodeCredentials | None": """Pull the current credential from the keychain. Returns None if: - - we're not on macOS - - Claude Code isn't installed (no keychain entry) - - the entry exists but doesn't have a `claudeAiOauth` block + - no credential source is available (no keychain entry on macOS, + no `~/.claude/.credentials.json` on other hosts) + - the blob exists but doesn't have a `claudeAiOauth` block (corrupt / legacy / unexpected schema — bail rather than guess) Never raises. Callers that need to distinguish "unavailable" from "available but expired" should chain a `.is_expired()` check. """ - if not is_claude_code_available(): - return None - raw = _read_keychain_blob() + raw = _read_blob() if raw is None: return None try: @@ -115,15 +125,40 @@ def has_inference_scope(self) -> bool: def is_claude_code_available() -> bool: - """True iff we're on a platform where reading Claude Code's - credential store is supported (macOS + the keychain has the entry). - - Cheap to call — checks platform first, then probes keychain in a - single subprocess invocation. Used by onboard / settings UI to decide - whether to surface the "use your Claude subscription" option.""" - if sys.platform != "darwin": - return False - return _read_keychain_blob() is not None + """True iff a Claude Code credential blob is readable from any supported + source — the macOS keychain or the `~/.claude/.credentials.json` file. + + Cheap to call — the keychain probe is one subprocess invocation that + no-ops instantly on hosts without `security`, and the file check is a + single read. Used by onboard / settings UI to decide whether to surface + the "use your Claude subscription" option.""" + return _read_blob() is not None + + +def _read_blob() -> str | None: + """Return the raw credential JSON from the best available source. + + Tries the macOS keychain first (a fast no-op on hosts without the + `security` binary, e.g. Linux), then falls back to the on-disk + `~/.claude/.credentials.json` file. Returns None when neither yields a + blob. Keeping the keychain call unconditional (rather than gating on + `sys.platform`) means a single code path serves macOS and Linux and lets + tests stub either source independently.""" + blob = _read_keychain_blob() + if blob is not None: + return blob + return _read_credentials_file() + + +def _read_credentials_file() -> str | None: + """Read `~/.claude/.credentials.json`, the non-macOS credential store + (and macOS fallback). Returns the file text, or None on any error + (missing file, unreadable). Never raises and never logs the contents — + the blob holds a live OAuth token.""" + try: + return _credentials_file().read_text(encoding="utf-8") or None + except OSError: + return None def _read_keychain_blob() -> str | None: diff --git a/tests/conftest.py b/tests/conftest.py index 7b59b19..d427162 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,6 +63,12 @@ def _isolate_oauth_credentials(monkeypatch): return # Pre-OAuth branch / installs without the credentials module monkeypatch.setattr(_cc, "is_claude_code_available", lambda: False) monkeypatch.setattr(_cc, "_read_keychain_blob", lambda: None) + # The on-disk path lookup — the Linux/server fallback (and macOS fallback) + # that reads `~/.claude/.credentials.json`. Without this stub, read() would + # pick up a real credentials file on a dev box / CI runner that happens to + # have one (this is exactly how the suite breaks on a machine where the dev + # is signed in to Claude Code) and isolation would leak. + monkeypatch.setattr(_cc, "_read_credentials_file", lambda: None) # For Codex, the read() helper resolves Path.home() each call. Tests # that need a clean slate should monkeypatch Path.home() to a tmp dir # — much easier than stubbing the read method outright. We don't add diff --git a/tests/test_oauth.py b/tests/test_oauth.py index f072941..cafba67 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -123,14 +123,59 @@ def test_claude_credentials_skips_oauth_when_inference_scope_missing(monkeypatch assert not creds.has_inference_scope() -def test_is_claude_code_available_false_on_non_darwin(monkeypatch): - """We only support macOS keychain reads for v0.8. Linux/Windows have - different credential stores; the discovery function returns False there - so the rest of the code degrades gracefully without OS-specific guards - sprinkled throughout.""" +def test_read_blob_none_when_no_source(monkeypatch): + """With neither a keychain entry nor a `~/.claude/.credentials.json` file + (both low-level readers stubbed to None by the autouse isolation fixture), + the composed `_read_blob()` yields None — so read() returns None and + availability is False, degrading gracefully. We assert the real composition + helper directly because the autouse fixture stubs the public + `is_claude_code_available` wrapper itself.""" from watchmen.credentials import claude_code as _cc - monkeypatch.setattr(_cc.sys, "platform", "linux") - assert _cc.is_claude_code_available() is False + assert _cc._read_blob() is None + + +def test_claude_credentials_fall_back_to_file_when_no_keychain(monkeypatch): + """On a host without a keychain (Linux box / server), read() sources the + same `claudeAiOauth` payload from `~/.claude/.credentials.json`. This is + the fallback that lets watchmen run on a Claude subscription off a Mac.""" + from watchmen.credentials import claude_code as _cc + monkeypatch.setattr(_cc, "_read_keychain_blob", lambda: None) + monkeypatch.setattr(_cc, "_read_credentials_file", lambda: json.dumps({ + "claudeAiOauth": { + "accessToken": "linux-access", + "refreshToken": "linux-refresh", + "expiresAt": 9_999_999_999_999, + "scopes": ["user:inference"], + "subscriptionType": "max", + "rateLimitTier": "default_claude_max_5x", + } + })) + creds = _cc.ClaudeCodeCredentials.read() + assert creds is not None + assert creds.access_token == "linux-access" + assert creds.subscription_type == "max" + assert creds.has_inference_scope() + assert not creds.is_expired() + # Composition: with the file present (keychain empty), `_read_blob()` + # surfaces it, so availability resolves True. (Asserted on the real + # helper; the autouse fixture stubs the public wrapper.) + assert _cc._read_blob() is not None + + +def test_keychain_takes_precedence_over_file(monkeypatch): + """When both sources exist (a macOS box that also has the on-disk file), + the keychain wins — it is Claude Code's primary, freshest store.""" + from watchmen.credentials import claude_code as _cc + monkeypatch.setattr(_cc, "_read_keychain_blob", lambda: json.dumps({ + "claudeAiOauth": {"accessToken": "from-keychain", "refreshToken": "r", + "expiresAt": 9_999_999_999_999, "scopes": ["user:inference"]} + })) + monkeypatch.setattr(_cc, "_read_credentials_file", lambda: json.dumps({ + "claudeAiOauth": {"accessToken": "from-file", "refreshToken": "r", + "expiresAt": 9_999_999_999_999, "scopes": ["user:inference"]} + })) + creds = _cc.ClaudeCodeCredentials.read() + assert creds.access_token == "from-keychain" # ─── CodexCredentials ──────────────────────────────────────────────────────