Skip to content

feat(credentials): read Claude Code OAuth token from credentials.json on Linux#128

Open
kwadwoadu wants to merge 1 commit into
firstbatchxyz:mainfrom
kwadwoadu:feat/claude-pro-linux-credential-fallback
Open

feat(credentials): read Claude Code OAuth token from credentials.json on Linux#128
kwadwoadu wants to merge 1 commit into
firstbatchxyz:mainfrom
kwadwoadu:feat/claude-pro-linux-credential-fallback

Conversation

@kwadwoadu

Copy link
Copy Markdown

Problem

The claude-pro provider (subscription-quota billing) only resolved a token
from the macOS keychain. On Linux boxes/servers, is_claude_code_available()
returned False and ClaudeCodeCredentials.read() returned None even when a
valid token was present at ~/.claude/.credentials.json, so watchmen could not
run 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 without
    the security binary, e.g. Linux), then falls back to
    ~/.claude/.credentials.json (the same claudeAiOauth payload, mode 0600).
  • read() and is_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.py isolation fixture also stubs the new file reader so the
    suite can't pick up a real on-disk token on a signed-in dev box / CI runner.
  • Tests cover: file fallback when no keychain, keychain precedence over file,
    and no-source returns None.

No changes to providers.py / config.py / onboard.py — they already call
this contract.

Verification (Linux)

  • watchmen settings api-key --provider claude-pro goes from
    no OAuth credential found to OAuth . plan max . default_claude_max_5x
    (probe is local, no subscription quota spent).
  • pytest tests/test_oauth.py -> 25 passed.
  • Full suite -> 562 passed, 4 skipped (numpy-absent test_semantic.py).

Notes

  • macOS behavior unchanged (keychain-first); worth one sanity check on a Mac.
  • Windows libsecret/DPAPI and the Codex chatgpt path are untouched.

… 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 aktasbatuhan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 None

read_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 None

ValueError 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() and read() can disagree on a junk file: availability just checks _read_blob() is not None, while read() also parses JSON and looks for accessToken. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants