From d59218b763f3e66e5a6a5686d91872a15c8633ad Mon Sep 17 00:00:00 2001 From: "Abe Diaz (@abe238)" Date: Tue, 26 May 2026 01:38:14 -0700 Subject: [PATCH] vision: OAuth-from-keychain fallback for Mac users with Claude Code Adds a credential resolver to ClaudeVisionProvider: 1. explicit api_key= argument 2. ANTHROPIC_API_KEY env var 3. ANTHROPIC_AUTH_TOKEN env var (OAuth bearer) 4. macOS only: 'Claude Code-credentials' keychain entry The keychain branch makes 'bambu vision watch' work on a Mac with Claude Code authenticated with zero extra setup. Token is read on demand via the 'security' command, never written to disk, never logged. Falls through to the next source on any failure (missing entry, wrong format, non-Mac). Verified live: 'bambu vision classify ' against a captured A1 mini frame returned a valid JSON verdict via the keychain path (cost ~$0.0022 with Haiku 4.5). Tests: 51 passing (39 prior + 11 credentials + 1 OAuth-keychain-wiring). --- .env.example | 10 ++- docs/vision.md | 19 ++++- src/bambu_ai/vision/claude.py | 28 ++++--- src/bambu_ai/vision/credentials.py | 86 +++++++++++++++++++ tests/vision/test_claude_provider.py | 36 +++++++- tests/vision/test_credentials.py | 118 +++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 src/bambu_ai/vision/credentials.py create mode 100644 tests/vision/test_credentials.py diff --git a/.env.example b/.env.example index 11289ff..16ab201 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,11 @@ SERIAL=01P00A000000000 # Anyone who knows it can POST and read, so don't use a guessable name. NTFY_TOPIC=bambu-ai-pick-something-random-here -# Optional: Anthropic API key for the vision watcher (bambu vision watch). -# Without this, vision is no-op'd cleanly. -# Get a key at https://console.anthropic.com. +# Optional: Anthropic credentials for the vision watcher (bambu vision watch). +# Three sources are tried in order: +# 1. ANTHROPIC_API_KEY here (a regular API key from https://console.anthropic.com) +# 2. ANTHROPIC_AUTH_TOKEN here (an OAuth bearer token, if you have one) +# 3. macOS only: Claude Code's stored OAuth token from the keychain — automatic, +# no setup needed if you've already authenticated `claude` on this Mac. +# If none of the above are available, the vision watcher refuses to start with a clear error. # ANTHROPIC_API_KEY=sk-ant-... diff --git a/docs/vision.md b/docs/vision.md index 558193b..47444a0 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -19,13 +19,28 @@ GitHub issue: [#20](https://github.com/abe238/bambu-ai/issues/20). pip install -e ".[vision]" # (or: make setup-vision once that target lands) -# 2. Add your API key to .env (gitignored). -echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env +# 2. Provide Anthropic credentials. Three options, tried in this order: +# a) An Anthropic API key in .env (works everywhere): +# echo "ANTHROPIC_API_KEY=sk-ant-..." >> .env +# b) An OAuth bearer token in .env: +# echo "ANTHROPIC_AUTH_TOKEN=..." >> .env +# c) macOS only: nothing to set. If you have Claude Code installed and +# authenticated, the watcher reads its OAuth token from the keychain. +# The token is read on demand, never written to disk, never logged. # 3. Start the watcher. bambu vision watch ``` +For Claude Code users on macOS, option (c) is the path of least resistance — +no separate API key, no copy-paste. The watcher logs which source it used +("env: ANTHROPIC_API_KEY", "keychain (Claude Code)", etc.) on startup. + +> Note: using Claude Code's OAuth token consumes the same Claude account's +> quota as Claude Code itself. Heavy vision use during your normal coding +> sessions can rate-limit both. For production-grade unattended monitoring, +> a separate Anthropic API key is usually the right call. + To kick the tires without burning API calls: ```sh diff --git a/src/bambu_ai/vision/claude.py b/src/bambu_ai/vision/claude.py index 922993b..26d9936 100644 --- a/src/bambu_ai/vision/claude.py +++ b/src/bambu_ai/vision/claude.py @@ -9,11 +9,11 @@ import base64 import io import json -import os import re from typing import TYPE_CHECKING, Any from .base import VisionProvider +from .credentials import Credentials, resolve_credentials from .types import FailureClass, Verdict if TYPE_CHECKING: @@ -72,18 +72,26 @@ def __init__( self.model = model self.max_tokens = max_tokens self.jpeg_quality = jpeg_quality + self.credentials_source: str = "(injected client)" if client is not None else "(unresolved)" if client is not None: self.client = client + return + + from anthropic import Anthropic # imported lazily so the package works without [vision] + + creds: Credentials = resolve_credentials(api_key) + if not creds.found: + raise RuntimeError( + "No Anthropic credentials available. Set ANTHROPIC_API_KEY in .env, " + "or ANTHROPIC_AUTH_TOKEN, or run on a Mac with Claude Code authenticated " + "(its keychain entry is used as a fallback). For offline testing use " + "MockVisionProvider." + ) + self.credentials_source = creds.source + if creds.api_key: + self.client = Anthropic(api_key=creds.api_key) else: - from anthropic import Anthropic # imported lazily so the package works without [vision] - - key = api_key or os.environ.get("ANTHROPIC_API_KEY") - if not key: - raise RuntimeError( - "ANTHROPIC_API_KEY is not set. Add it to .env or pass api_key=" - " or use MockVisionProvider for offline testing." - ) - self.client = Anthropic(api_key=key) + self.client = Anthropic(auth_token=creds.auth_token) # ------------------------------------------------------------------ public diff --git a/src/bambu_ai/vision/credentials.py b/src/bambu_ai/vision/credentials.py new file mode 100644 index 0000000..bfa64d0 --- /dev/null +++ b/src/bambu_ai/vision/credentials.py @@ -0,0 +1,86 @@ +"""Credential resolution for the Claude vision provider. + +Precedence (first hit wins): + +1. Explicit ``api_key=`` argument to :class:`ClaudeVisionProvider`. +2. ``ANTHROPIC_API_KEY`` environment variable. +3. ``ANTHROPIC_AUTH_TOKEN`` environment variable (OAuth bearer). +4. **macOS only**: read the Claude Code OAuth bearer token from the + ``Claude Code-credentials`` keychain entry — convenience for users who + already have Claude Code installed and don't want to manage a second key. + +The third and fourth options yield an *auth token* (Bearer); options 1–2 yield +an *API key* (x-api-key header). The Anthropic Python SDK accepts both modes; +this helper picks the right one and returns a tagged result. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Credentials: + """Tagged credential resolved by :func:`resolve_credentials`. + + Exactly one of ``api_key`` / ``auth_token`` is non-empty. + ``source`` is human-readable text for logs ("env: ANTHROPIC_API_KEY", + "keychain (Claude Code)", etc.) — never log the actual secret. + """ + + api_key: str | None + auth_token: str | None + source: str + + @property + def found(self) -> bool: + return bool(self.api_key) or bool(self.auth_token) + + +def resolve_credentials(api_key: str | None = None) -> Credentials: + if api_key: + return Credentials(api_key=api_key, auth_token=None, source="explicit argument") + if env_key := os.environ.get("ANTHROPIC_API_KEY"): + return Credentials(api_key=env_key, auth_token=None, source="env: ANTHROPIC_API_KEY") + if env_token := os.environ.get("ANTHROPIC_AUTH_TOKEN"): + return Credentials(api_key=None, auth_token=env_token, source="env: ANTHROPIC_AUTH_TOKEN") + if token := _read_claude_code_oauth_token(): + return Credentials(api_key=None, auth_token=token, source="keychain (Claude Code)") + return Credentials(api_key=None, auth_token=None, source="none") + + +def _read_claude_code_oauth_token() -> str | None: + """Pull the Claude Code OAuth access token from the macOS keychain. + + Returns None on any failure — the caller treats this as "no credentials". + Never raises. Never logs the token. + + Reads from the ``Claude Code-credentials`` generic-password entry installed + by the Claude Code CLI on macOS. The entry's secret is a JSON blob + containing ``claudeAiOauth.accessToken`` (and a refreshToken/expiresAt that + Claude Code itself manages — we don't refresh from here). + """ + if sys.platform != "darwin": + return None + try: + result = subprocess.run( + ["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return None + if result.returncode != 0 or not result.stdout.strip(): + return None + try: + data = json.loads(result.stdout) + token = data["claudeAiOauth"]["accessToken"] + except (json.JSONDecodeError, KeyError, TypeError): + return None + return token if isinstance(token, str) and token else None diff --git a/tests/vision/test_claude_provider.py b/tests/vision/test_claude_provider.py index a0cfc52..531b21c 100644 --- a/tests/vision/test_claude_provider.py +++ b/tests/vision/test_claude_provider.py @@ -136,11 +136,15 @@ def test_request_payload_includes_image_and_text(provider_factory) -> None: assert image_block["source"]["media_type"] == "image/jpeg" -def test_missing_api_key_raises_runtime_error(monkeypatch) -> None: +def test_missing_credentials_raises_runtime_error(monkeypatch) -> None: + """No env var, no keychain — provider must fail with a clear message.""" monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - # Pass no client — provider must try to construct Anthropic() and fail because no key. - # But it tries to import anthropic too; if the import is missing we get a different error. - # We use a stub that simulates "no key". + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + # Block the macOS keychain fallback so the test is platform-independent. + from bambu_ai.vision import credentials as creds_mod + + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: None) + try: ClaudeVisionProvider(api_key=None, client=None) # type: ignore[arg-type] except RuntimeError as e: @@ -149,3 +153,27 @@ def test_missing_api_key_raises_runtime_error(monkeypatch) -> None: pytest.skip("anthropic SDK not installed in this env") else: pytest.fail("expected RuntimeError or ImportError") + + +def test_oauth_token_from_keychain_used_when_no_env_var(monkeypatch) -> None: + """When the keychain has a token and no env vars are set, provider uses auth_token=.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + from bambu_ai.vision import credentials as creds_mod + + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: "from-keychain") + + captured: dict = {} + + class FakeAnthropic: + def __init__(self, **kwargs): + captured.update(kwargs) + + # Patch the anthropic.Anthropic constructor. + import anthropic + + monkeypatch.setattr(anthropic, "Anthropic", FakeAnthropic) + + p = ClaudeVisionProvider(api_key=None, client=None) # type: ignore[arg-type] + assert "keychain" in p.credentials_source.lower() + assert captured == {"auth_token": "from-keychain"} diff --git a/tests/vision/test_credentials.py b/tests/vision/test_credentials.py new file mode 100644 index 0000000..e123cd0 --- /dev/null +++ b/tests/vision/test_credentials.py @@ -0,0 +1,118 @@ +"""Credential resolution precedence.""" + +from __future__ import annotations + +import json +import subprocess +from unittest.mock import MagicMock + +import pytest + +from bambu_ai.vision import credentials as creds_mod + + +@pytest.fixture(autouse=True) +def _clear_env(monkeypatch): + """Ensure no real Anthropic env vars leak into tests.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + + +def _block_keychain(monkeypatch): + """Pretend the keychain lookup always fails so the env-var paths can be tested in isolation.""" + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: None) + + +class TestPrecedence: + def test_explicit_api_key_wins(self, monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env-but-ignored") + c = creds_mod.resolve_credentials(api_key="explicit-arg") + assert c.api_key == "explicit-arg" + assert c.auth_token is None + assert c.source == "explicit argument" + assert c.found + + def test_env_api_key_used_when_no_argument(self, monkeypatch) -> None: + _block_keychain(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "env-key") + c = creds_mod.resolve_credentials() + assert c.api_key == "env-key" + assert c.auth_token is None + assert "ANTHROPIC_API_KEY" in c.source + + def test_env_auth_token_used_when_no_api_key(self, monkeypatch) -> None: + _block_keychain(monkeypatch) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "env-bearer") + c = creds_mod.resolve_credentials() + assert c.api_key is None + assert c.auth_token == "env-bearer" + assert "ANTHROPIC_AUTH_TOKEN" in c.source + + def test_keychain_used_as_last_resort(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod, "_read_claude_code_oauth_token", lambda: "from-keychain") + c = creds_mod.resolve_credentials() + assert c.api_key is None + assert c.auth_token == "from-keychain" + assert "keychain" in c.source.lower() + + def test_returns_not_found_when_nothing_available(self, monkeypatch) -> None: + _block_keychain(monkeypatch) + c = creds_mod.resolve_credentials() + assert not c.found + assert c.source == "none" + + +class TestReadClaudeCodeOauthToken: + def test_returns_None_on_non_macos(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "linux") + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_security_command_missing(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + + def _raise(*_a, **_kw): + raise FileNotFoundError("security") + + monkeypatch.setattr(creds_mod.subprocess, "run", _raise) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_security_nonzero(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + mock_run = MagicMock( + return_value=subprocess.CompletedProcess(args=[], returncode=44, stdout="", stderr="not found") + ) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_blob_is_not_json(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + mock_run = MagicMock( + return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="not-json") + ) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_None_when_json_missing_expected_path(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + mock_run = MagicMock( + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=json.dumps({"other": "structure"}) + ) + ) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() is None + + def test_returns_token_when_present(self, monkeypatch) -> None: + monkeypatch.setattr(creds_mod.sys, "platform", "darwin") + blob = json.dumps( + { + "claudeAiOauth": { + "accessToken": "sk-ant-fake-12345", + "refreshToken": "sk-ant-refresh", + "expiresAt": 9999999999, + } + } + ) + mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout=blob)) + monkeypatch.setattr(creds_mod.subprocess, "run", mock_run) + assert creds_mod._read_claude_code_oauth_token() == "sk-ant-fake-12345"