fix(claude): fall back to macOS Keychain for credentials
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:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user