feat(credentials): read Claude Code OAuth token from credentials.json on Linux#128
Conversation
… on Linux The claude-pro provider (subscription-quota billing) only resolved a token from the macOS keychain, so on Linux boxes/servers is_claude_code_available() returned False and read() returned None even when a valid token was present at ~/.claude/.credentials.json. watchmen could not run on a Claude subscription off a Mac. Add a file fallback in credentials.claude_code: - _read_blob() tries the keychain first (a fast no-op on hosts without the security binary), then ~/.claude/.credentials.json. - read() and is_claude_code_available() now source from _read_blob(); macOS keeps keychain precedence, Linux uses the file. Reader never raises, never logs the token. - conftest isolation fixture also stubs the new file reader so the suite can't pick up a real on-disk token; tests cover fallback, keychain precedence, and the no-source case. Verified on Linux: settings api-key --provider claude-pro probe goes from 'no OAuth credential found' to 'OAuth . plan max'; full suite 562 passed.
aktasbatuhan
left a comment
There was a problem hiding this comment.
Took a look, this is a clean change and the design is the right call. Collapsing the platform gate into one _read_blob() that goes keychain then file is way nicer than scattering sys.platform checks around, and keychain-first keeps Mac behavior exactly as it was. Checked the callers too: onboard.py is the only real consumer and it works unchanged, so the "no other files needed" claim holds up. Tests cover the paths that matter. Nice.
One real bug though, worth fixing before merge:
_read_credentials_file() only catches OSError:
return _credentials_file().read_text(encoding="utf-8") or None
except OSError:
return Noneread_text(encoding="utf-8") raises UnicodeDecodeError on a file with bad UTF-8 bytes, and that's a subclass of ValueError, not OSError. So a corrupt or binary ~/.claude/.credentials.json blows straight up through _read_blob() and read(), which both promise "never raises", and onboard.py calls read() with no try/except. That's a crash on the onboarding path. Same function's own docstring says "never raises", which isn't quite true right now.
One-liner fix:
except (OSError, ValueError):
return NoneValueError covers UnicodeDecodeError, and IsADirectoryError is already an OSError so that case is fine. Would be good to add a test feeding it invalid UTF-8 once that's in, since it's the one path not covered yet.
Couple of small things, none blocking:
- The
read()docstring still opens with "Pull the current credential from the keychain". You updated the Returns section but not that first line. Easy fix while you're in there. is_claude_code_available()andread()can disagree on a junk file: availability just checks_read_blob() is not None, whileread()also parses JSON and looks foraccessToken. So a non-JSON file makes availability True but read None. This already happens on the keychain path too so it's not a regression, just noting the file path inherits it.
Also confirmed the unconditional keychain probe is a true no-op on Linux (no security binary, instant FileNotFoundError, no 5s timeout), so the docstring claim there is accurate. Didn't run the suite myself since I'm reviewing off the diff, taking the 562-passed number at face value.
Problem
The
claude-proprovider (subscription-quota billing) only resolved a tokenfrom the macOS keychain. On Linux boxes/servers,
is_claude_code_available()returned
FalseandClaudeCodeCredentials.read()returnedNoneeven when avalid token was present at
~/.claude/.credentials.json, so watchmen could notrun on a Claude subscription off a Mac.
Change
Add a file fallback in
credentials/claude_code.py:_read_blob()tries the macOS keychain first (a fast no-op on hosts withoutthe
securitybinary, e.g. Linux), then falls back to~/.claude/.credentials.json(the sameclaudeAiOauthpayload, mode 0600).read()andis_claude_code_available()now source from_read_blob().macOS keeps keychain precedence; Linux uses the file. The reader never raises
and never logs the token contents.
tests/conftest.pyisolation fixture also stubs the new file reader so thesuite can't pick up a real on-disk token on a signed-in dev box / CI runner.
and no-source returns
None.No changes to
providers.py/config.py/onboard.py— they already callthis contract.
Verification (Linux)
watchmen settings api-key --provider claude-progoes fromno OAuth credential foundtoOAuth . plan max . default_claude_max_5x(probe is local, no subscription quota spent).
pytest tests/test_oauth.py-> 25 passed.test_semantic.py).Notes
chatgptpath are untouched.