fix(claude): fall back to macOS Keychain for credentials
lint / lint (push) Failing after 2m10s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Successful in 1m6s

On macOS, Claude Code stores credentials in the Keychain under
service "Claude Code-credentials" rather than in a file. When
~/.claude/.credentials.json is absent, shell out to:
  security find-generic-password -s "Claude Code-credentials" -w
and parse the result as the same JSON schema.

~/.claude.json holds only profile/UI metadata (oauthAccount has
no token fields). expiresAt in the credentials is milliseconds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:46:53 +00:00
parent bdd352570b
commit 7278ee1157
3 changed files with 166 additions and 60 deletions
+56 -19
View File
@@ -1,20 +1,29 @@
"""Host Claude auth helpers. """Host Claude auth helpers.
Reads the host's Claude Code credentials from ~/.claude/.credentials.json Reads the host's Claude Code credentials and returns only the access
and returns only the access token needed by egress. Does not expose token needed by egress. Does not expose refresh tokens or raw payloads.
refresh tokens or raw auth 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 from __future__ import annotations
import json import json
import os import os
import subprocess
import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ...log import die from ...log import die
_KEYCHAIN_SERVICE = "Claude Code-credentials"
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path: def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
env = os.environ if host_env is None else host_env env = os.environ if host_env is None else host_env
home = env.get("HOME") 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" 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( def claude_host_access_token(
host_env: dict[str, str] | None = None, host_env: dict[str, str] | None = None,
*, *,
now: datetime | None = None, now: datetime | None = None,
) -> str: ) -> str:
path = claude_auth_path(host_env) path = claude_auth_path(host_env)
if not path.is_file(): raw: dict[str, object] | None = None
die(
f"claude host credentials: auth file missing at {path}. " if path.is_file():
"Run `claude login` on the host or disable " try:
"agent_provider.forward_host_credentials." raw = json.loads(path.read_text())
) except (OSError, json.JSONDecodeError) as e:
try: die(f"claude host credentials: could not read valid JSON at {path}: {e}")
raw = json.loads(path.read_text()) if not isinstance(raw, dict):
except (OSError, json.JSONDecodeError) as e: die(f"claude host credentials: {path} must contain a JSON object")
die(f"claude host credentials: could not read valid JSON at {path}: {e}") else:
if not isinstance(raw, dict): raw = _read_keychain()
die(f"claude host credentials: {path} must contain a JSON object") 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") oauth = raw.get("claudeAiOauth")
if not isinstance(oauth, dict): if not isinstance(oauth, dict):
die( 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 " "Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials." "agent_provider.forward_host_credentials."
) )
@@ -53,11 +90,11 @@ def claude_host_access_token(
access_token = oauth.get("accessToken") access_token = oauth.get("accessToken")
if not isinstance(access_token, str) or not access_token: if not isinstance(access_token, str) or not access_token:
die( die(
f"claude host credentials: {path} claudeAiOauth.accessToken is missing " "claude host credentials: claudeAiOauth.accessToken is missing or empty. "
"or empty. Run `claude login` on the host and restart the bottle." "Run `claude login` on the host and restart the bottle."
) )
# expiresAt is stored in milliseconds # expiresAt is in milliseconds
expires_at = oauth.get("expiresAt") expires_at = oauth.get("expiresAt")
if isinstance(expires_at, (int, float)): if isinstance(expires_at, (int, float)):
check_now = now or datetime.now(timezone.utc) check_now = now or datetime.now(timezone.utc)
@@ -68,9 +68,16 @@ Rejects in manifest validation when:
### Host auth extraction (`contrib/claude/claude_auth.py`) ### Host auth extraction (`contrib/claude/claude_auth.py`)
Claude Code stores its OAuth credentials in `~/.claude/.credentials.json` Claude Code credential storage varies by platform:
(not in `~/.claude.json`, which contains only UI state and profile
metadata). The relevant fields are: - **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 ```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`: At prepare/launch time, when `forward_host_credentials: true`:
1. Resolve `~/.claude/.credentials.json` (via `$HOME`). 1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
2. Parse the JSON object. `security find-generic-password -s "Claude Code-credentials" -w`
3. Require a `claudeAiOauth` dict. and parse its stdout as JSON.
4. Require a non-empty `claudeAiOauth.accessToken` string. 2. Require a `claudeAiOauth` dict.
5. If `claudeAiOauth.expiresAt` is present as a number, divide by 1000 3. Require a non-empty `claudeAiOauth.accessToken` string.
and require the result to be in the future. 4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
6. Return only the access token to the launch path. 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 Errors name the missing or invalid condition and point the operator at
`claude login`, without printing token values. `claude login`, without printing token values.
+91 -30
View File
@@ -3,10 +3,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
import subprocess
import tempfile import tempfile
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.contrib.claude.claude_auth import ( from bot_bottle.contrib.claude.claude_auth import (
claude_auth_path, claude_auth_path,
@@ -15,6 +17,11 @@ from bot_bottle.contrib.claude.claude_auth import (
from bot_bottle.log import Die 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): class TestClaudeHostAccessToken(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.") self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
@@ -26,7 +33,7 @@ class TestClaudeHostAccessToken(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.tmp.cleanup() 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)) self.auth_path.write_text(json.dumps(payload))
def test_auth_path_uses_home_env(self): def test_auth_path_uses_home_env(self):
@@ -35,41 +42,33 @@ class TestClaudeHostAccessToken(unittest.TestCase):
claude_auth_path({"HOME": str(self.home)}), 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" key = "sk-ant-oat01-real-key"
self._write({"claudeAiOauth": {"accessToken": key}}) self._write({"claudeAiOauth": {"accessToken": key}})
out = claude_host_access_token({"HOME": str(self.home)}) out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out) self.assertEqual(key, out)
def test_missing_auth_file_dies(self): def test_file_missing_claude_ai_oauth_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):
self._write({"hasCompletedOnboarding": True}) self._write({"hasCompletedOnboarding": True})
with self.assertRaises(Die): with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)}) 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}}) self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
with self.assertRaises(Die): with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)}) 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": ""}}) self._write({"claudeAiOauth": {"accessToken": ""}})
with self.assertRaises(Die): with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)}) claude_host_access_token({"HOME": str(self.home)})
def test_expired_token_dies(self): def test_file_expired_token_dies(self):
# expiresAt is in milliseconds; 1000000 ms = year 1970 # expiresAt is milliseconds; 1_000_000 ms is year 1970
self._write({ self._write({
"claudeAiOauth": { "claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000},
"accessToken": "sk-ant-oat01-x",
"expiresAt": 1000000,
},
}) })
with self.assertRaises(Die): with self.assertRaises(Die):
claude_host_access_token( claude_host_access_token(
@@ -77,14 +76,11 @@ class TestClaudeHostAccessToken(unittest.TestCase):
now=datetime(2026, 1, 1, tzinfo=timezone.utc), 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" key = "sk-ant-oat01-y"
# 2000000000000 ms = ~year 2033 # 2_000_000_000_000 ms year 2033
self._write({ self._write({
"claudeAiOauth": { "claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
"accessToken": key,
"expiresAt": 2000000000000,
},
}) })
out = claude_host_access_token( out = claude_host_access_token(
{"HOME": str(self.home)}, {"HOME": str(self.home)},
@@ -92,35 +88,100 @@ class TestClaudeHostAccessToken(unittest.TestCase):
) )
self.assertEqual(key, out) 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" key = "sk-ant-oat01-z"
self._write({"claudeAiOauth": {"accessToken": key}}) self._write({"claudeAiOauth": {"accessToken": key}})
out = claude_host_access_token({"HOME": str(self.home)}) out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out) 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 {{{") self.auth_path.write_text("not json {{{")
with self.assertRaises(Die): with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)}) 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("[]") self.auth_path.write_text("[]")
with self.assertRaises(Die): with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)}) 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" key = "sk-ant-oat01-real"
self._write({ self._write({
"claudeAiOauth": { "claudeAiOauth": {
"accessToken": key, "accessToken": key,
"refreshToken": "sk-ant-ort01-secret", "refreshToken": "sk-ant-ort01-secret",
"scopes": ["user:inference", "user:profile"], "scopes": ["user:inference"],
"expiresAt": 2000000000000, "expiresAt": 2_000_000_000_000,
}, },
}) })
out = claude_host_access_token({"HOME": str(self.home)}) out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out) 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__": if __name__ == "__main__":
unittest.main() unittest.main()