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.
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)
@@ -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.
+91 -30
View File
@@ -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()