Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project> --bucket <skill>` reads the corpus to find
Expand Down
75 changes: 55 additions & 20 deletions src/watchmen/credentials/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 52 additions & 7 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────
Expand Down