diff --git a/bot_bottle/contrib/claude/claude_auth.py b/bot_bottle/contrib/claude/claude_auth.py index f8b337b..af3963b 100644 --- a/bot_bottle/contrib/claude/claude_auth.py +++ b/bot_bottle/contrib/claude/claude_auth.py @@ -1,20 +1,29 @@ """Host Claude auth helpers. -Reads the host's Claude Code credentials from ~/.claude/.credentials.json -and returns only the access token needed by egress. Does not expose -refresh tokens or raw auth payloads. +Reads the host's Claude Code credentials and returns only the access +token needed by egress. Does not expose refresh tokens or raw payloads. + +Credential storage by platform: + Linux — ~/.claude/.credentials.json + macOS — macOS Keychain, service "Claude Code-credentials" + (file path is tried first; Keychain is the fallback) """ from __future__ import annotations import json import os +import subprocess +import sys from datetime import datetime, timezone from pathlib import Path from ...log import die +_KEYCHAIN_SERVICE = "Claude Code-credentials" + + def claude_auth_path(host_env: dict[str, str] | None = None) -> Path: env = os.environ if host_env is None else host_env home = env.get("HOME") @@ -23,29 +32,57 @@ def claude_auth_path(host_env: dict[str, str] | None = None) -> Path: return Path.home() / ".claude" / ".credentials.json" +def _read_keychain() -> dict[str, object] | None: + """Try the macOS Keychain. Returns parsed JSON dict or None.""" + if sys.platform != "darwin": + return None + try: + result = subprocess.run( + ["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode != 0 or not result.stdout.strip(): + return None + try: + raw = json.loads(result.stdout.strip()) + except json.JSONDecodeError: + return None + return raw if isinstance(raw, dict) else None + + def claude_host_access_token( host_env: dict[str, str] | None = None, *, now: datetime | None = None, ) -> str: path = claude_auth_path(host_env) - if not path.is_file(): - die( - f"claude host credentials: auth file missing at {path}. " - "Run `claude login` on the host or disable " - "agent_provider.forward_host_credentials." - ) - try: - raw = json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as e: - die(f"claude host credentials: could not read valid JSON at {path}: {e}") - if not isinstance(raw, dict): - die(f"claude host credentials: {path} must contain a JSON object") + raw: dict[str, object] | None = None + + if path.is_file(): + try: + raw = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as e: + die(f"claude host credentials: could not read valid JSON at {path}: {e}") + if not isinstance(raw, dict): + die(f"claude host credentials: {path} must contain a JSON object") + else: + raw = _read_keychain() + if raw is None: + die( + f"claude host credentials: auth file missing at {path} and " + f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. " + "Run `claude login` on the host or disable " + "agent_provider.forward_host_credentials." + ) oauth = raw.get("claudeAiOauth") if not isinstance(oauth, dict): die( - f"claude host credentials: {path} is missing claudeAiOauth. " + "claude host credentials: claudeAiOauth is missing from credentials. " "Run `claude login` on the host or disable " "agent_provider.forward_host_credentials." ) @@ -53,11 +90,11 @@ def claude_host_access_token( access_token = oauth.get("accessToken") if not isinstance(access_token, str) or not access_token: die( - f"claude host credentials: {path} claudeAiOauth.accessToken is missing " - "or empty. Run `claude login` on the host and restart the bottle." + "claude host credentials: claudeAiOauth.accessToken is missing or empty. " + "Run `claude login` on the host and restart the bottle." ) - # expiresAt is stored in milliseconds + # expiresAt is in milliseconds expires_at = oauth.get("expiresAt") if isinstance(expires_at, (int, float)): check_now = now or datetime.now(timezone.utc) diff --git a/docs/prds/prd-new-claude-forward-host-credentials.md b/docs/prds/prd-new-claude-forward-host-credentials.md index f6820a3..c98c05b 100644 --- a/docs/prds/prd-new-claude-forward-host-credentials.md +++ b/docs/prds/prd-new-claude-forward-host-credentials.md @@ -68,9 +68,16 @@ Rejects in manifest validation when: ### Host auth extraction (`contrib/claude/claude_auth.py`) -Claude Code stores its OAuth credentials in `~/.claude/.credentials.json` -(not in `~/.claude.json`, which contains only UI state and profile -metadata). The relevant fields are: +Claude Code credential storage varies by platform: + +- **Linux**: `~/.claude/.credentials.json` +- **macOS**: macOS Keychain, service `"Claude Code-credentials"` + (the file path is tried first; Keychain is the fallback when the file + is absent) + +`~/.claude.json` contains only UI state and profile metadata — no token. + +The credentials JSON schema (same whether from file or Keychain): ```json { @@ -83,17 +90,18 @@ metadata). The relevant fields are: } ``` -Note: `expiresAt` is in **milliseconds** (not seconds). +`expiresAt` is in **milliseconds** (not seconds). At prepare/launch time, when `forward_host_credentials: true`: -1. Resolve `~/.claude/.credentials.json` (via `$HOME`). -2. Parse the JSON object. -3. Require a `claudeAiOauth` dict. -4. Require a non-empty `claudeAiOauth.accessToken` string. -5. If `claudeAiOauth.expiresAt` is present as a number, divide by 1000 - and require the result to be in the future. -6. Return only the access token to the launch path. +1. Try `~/.claude/.credentials.json`; on macOS, if absent, run + `security find-generic-password -s "Claude Code-credentials" -w` + and parse its stdout as JSON. +2. Require a `claudeAiOauth` dict. +3. Require a non-empty `claudeAiOauth.accessToken` string. +4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require + the result to be in the future. +5. Return only the access token to the launch path. Errors name the missing or invalid condition and point the operator at `claude login`, without printing token values. diff --git a/tests/unit/test_contrib_claude_auth.py b/tests/unit/test_contrib_claude_auth.py index d812afa..7f638ef 100644 --- a/tests/unit/test_contrib_claude_auth.py +++ b/tests/unit/test_contrib_claude_auth.py @@ -3,10 +3,12 @@ from __future__ import annotations import json +import subprocess import tempfile import unittest from datetime import datetime, timezone from pathlib import Path +from unittest.mock import MagicMock, patch from bot_bottle.contrib.claude.claude_auth import ( claude_auth_path, @@ -15,6 +17,11 @@ from bot_bottle.contrib.claude.claude_auth import ( from bot_bottle.log import Die +def _cred_json(access_token: str, **extra) -> str: # type: ignore[no-untyped-def] + payload: dict = {"claudeAiOauth": {"accessToken": access_token, **extra}} + return json.dumps(payload) + + class TestClaudeHostAccessToken(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.") @@ -26,7 +33,7 @@ class TestClaudeHostAccessToken(unittest.TestCase): def tearDown(self): self.tmp.cleanup() - def _write(self, payload: dict) -> None: # type: ignore + def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def] self.auth_path.write_text(json.dumps(payload)) def test_auth_path_uses_home_env(self): @@ -35,41 +42,33 @@ class TestClaudeHostAccessToken(unittest.TestCase): claude_auth_path({"HOME": str(self.home)}), ) - def test_returns_access_token(self): + # --- file-based (Linux) --- + + def test_file_returns_access_token(self): key = "sk-ant-oat01-real-key" self._write({"claudeAiOauth": {"accessToken": key}}) out = claude_host_access_token({"HOME": str(self.home)}) self.assertEqual(key, out) - def test_missing_auth_file_dies(self): - with self.assertRaises(Die): - # Use a home with no .credentials.json - empty_home = self.home / "empty" - empty_home.mkdir() - claude_host_access_token({"HOME": str(empty_home)}) - - def test_missing_claude_ai_oauth_dies(self): + def test_file_missing_claude_ai_oauth_dies(self): self._write({"hasCompletedOnboarding": True}) with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) - def test_missing_access_token_dies(self): + def test_file_missing_access_token_dies(self): self._write({"claudeAiOauth": {"expiresAt": 2000000000000}}) with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) - def test_empty_access_token_dies(self): + def test_file_empty_access_token_dies(self): self._write({"claudeAiOauth": {"accessToken": ""}}) with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) - def test_expired_token_dies(self): - # expiresAt is in milliseconds; 1000000 ms = year 1970 + def test_file_expired_token_dies(self): + # expiresAt is milliseconds; 1_000_000 ms is year 1970 self._write({ - "claudeAiOauth": { - "accessToken": "sk-ant-oat01-x", - "expiresAt": 1000000, - }, + "claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000}, }) with self.assertRaises(Die): claude_host_access_token( @@ -77,14 +76,11 @@ class TestClaudeHostAccessToken(unittest.TestCase): now=datetime(2026, 1, 1, tzinfo=timezone.utc), ) - def test_future_expiry_is_accepted(self): + def test_file_future_expiry_is_accepted(self): key = "sk-ant-oat01-y" - # 2000000000000 ms = ~year 2033 + # 2_000_000_000_000 ms ≈ year 2033 self._write({ - "claudeAiOauth": { - "accessToken": key, - "expiresAt": 2000000000000, - }, + "claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000}, }) out = claude_host_access_token( {"HOME": str(self.home)}, @@ -92,35 +88,100 @@ class TestClaudeHostAccessToken(unittest.TestCase): ) self.assertEqual(key, out) - def test_absent_expiry_field_is_accepted(self): + def test_file_absent_expiry_is_accepted(self): key = "sk-ant-oat01-z" self._write({"claudeAiOauth": {"accessToken": key}}) out = claude_host_access_token({"HOME": str(self.home)}) self.assertEqual(key, out) - def test_non_json_file_dies(self): + def test_file_non_json_dies(self): self.auth_path.write_text("not json {{{") with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) - def test_json_array_root_dies(self): + def test_file_json_array_root_dies(self): self.auth_path.write_text("[]") with self.assertRaises(Die): claude_host_access_token({"HOME": str(self.home)}) - def test_extra_fields_in_credentials_are_ignored(self): + def test_file_extra_fields_are_ignored(self): key = "sk-ant-oat01-real" self._write({ "claudeAiOauth": { "accessToken": key, "refreshToken": "sk-ant-ort01-secret", - "scopes": ["user:inference", "user:profile"], - "expiresAt": 2000000000000, + "scopes": ["user:inference"], + "expiresAt": 2_000_000_000_000, }, }) out = claude_host_access_token({"HOME": str(self.home)}) self.assertEqual(key, out) + # --- macOS Keychain fallback --- + + def _home_without_creds(self) -> Path: + """A home dir that has .claude/ but no .credentials.json.""" + empty = self.home / "no-creds" + (empty / ".claude").mkdir(parents=True) + return empty + + def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock: + mock = MagicMock() + mock.returncode = returncode + mock.stdout = stdout + return mock + + def test_keychain_used_when_file_absent(self): + key = "sk-ant-oat01-keychain" + home = self._home_without_creds() + with patch( + "bot_bottle.contrib.claude.claude_auth.subprocess.run", + return_value=self._mock_keychain(_cred_json(key)), + ), patch( + "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", + ): + out = claude_host_access_token({"HOME": str(home)}) + self.assertEqual(key, out) + + def test_keychain_failure_when_file_absent_dies(self): + home = self._home_without_creds() + with patch( + "bot_bottle.contrib.claude.claude_auth.subprocess.run", + return_value=self._mock_keychain("", returncode=44), + ), patch( + "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", + ): + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(home)}) + + def test_no_file_no_keychain_on_linux_dies(self): + home = self._home_without_creds() + with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"): + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(home)}) + + def test_keychain_non_json_dies(self): + home = self._home_without_creds() + with patch( + "bot_bottle.contrib.claude.claude_auth.subprocess.run", + return_value=self._mock_keychain("not-json"), + ), patch( + "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", + ): + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(home)}) + + def test_keychain_security_not_found_dies(self): + home = self._home_without_creds() + with patch( + "bot_bottle.contrib.claude.claude_auth.subprocess.run", + side_effect=FileNotFoundError, + ), patch( + "bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin", + ): + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(home)}) + if __name__ == "__main__": unittest.main()