fix(claude): read credentials from ~/.claude/.credentials.json
lint / lint (push) Successful in 2m10s
test / unit (pull_request) Successful in 53s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m8s

The actual OAuth token is in ~/.claude/.credentials.json under
claudeAiOauth.accessToken, not in ~/.claude.json.
~/.claude.json holds only UI state and profile metadata (oauthAccount
has no token fields). expiresAt in the credentials file is milliseconds,
not seconds.

Discovered after testing against Claude Code 2.1.198.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:33:04 +00:00
parent f0d27863c2
commit bdd352570b
4 changed files with 84 additions and 44 deletions
+14 -13
View File
@@ -1,8 +1,8 @@
"""Host Claude auth helpers.
Reads the host's Claude Code auth state and returns only the
session key needed by egress. Does not expose refresh tokens
or raw auth payloads.
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.
"""
from __future__ import annotations
@@ -19,8 +19,8 @@ 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")
if home:
return Path(home) / ".claude.json"
return Path.home() / ".claude.json"
return Path(home) / ".claude" / ".credentials.json"
return Path.home() / ".claude" / ".credentials.json"
def claude_host_access_token(
@@ -42,32 +42,33 @@ def claude_host_access_token(
if not isinstance(raw, dict):
die(f"claude host credentials: {path} must contain a JSON object")
oauth = raw.get("oauthAccount")
oauth = raw.get("claudeAiOauth")
if not isinstance(oauth, dict):
die(
f"claude host credentials: {path} is missing oauthAccount. "
f"claude host credentials: {path} is missing claudeAiOauth. "
"Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials."
)
session_key = oauth.get("sessionKey")
if not isinstance(session_key, str) or not session_key:
access_token = oauth.get("accessToken")
if not isinstance(access_token, str) or not access_token:
die(
f"claude host credentials: {path} oauthAccount.sessionKey is missing "
f"claude host credentials: {path} claudeAiOauth.accessToken is missing "
"or empty. Run `claude login` on the host and restart the bottle."
)
# expiresAt is stored in milliseconds
expires_at = oauth.get("expiresAt")
if isinstance(expires_at, (int, float)):
check_now = now or datetime.now(timezone.utc)
exp_dt = datetime.fromtimestamp(float(expires_at), timezone.utc)
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
if exp_dt <= check_now:
die(
"claude host credentials: host Claude session token is expired. "
"claude host credentials: host Claude access token is expired. "
"Run `claude login` on the host and restart the bottle."
)
return session_key
return access_token
__all__ = [
@@ -68,15 +68,32 @@ 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:
```json
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": 1748276587173,
"scopes": ["user:inference", "user:profile"]
}
}
```
Note: `expiresAt` is in **milliseconds** (not seconds).
At prepare/launch time, when `forward_host_credentials: true`:
1. Resolve `~/.claude.json` (falling back to `$HOME/.claude.json`).
1. Resolve `~/.claude/.credentials.json` (via `$HOME`).
2. Parse the JSON object.
3. Require an `oauthAccount` dict.
4. Require a non-empty `oauthAccount.sessionKey` string.
5. If `oauthAccount.expiresAt` is present as a number, require it to be
in the future.
6. Return only the session key to the launch path.
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.
Errors name the missing or invalid condition and point the operator at
`claude login`, without printing token values.
+11 -9
View File
@@ -294,12 +294,13 @@ class TestAgentProviderRuntime(unittest.TestCase):
self.assertEqual({}, plan.provisioned_env)
def test_claude_forward_host_credentials_populates_egress_route(self):
session_key = "sk-ant-oat01-test-key"
access_token = "sk-ant-oat01-test-key"
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
home = Path(tmp) / "host-claude"
home.mkdir()
(home / ".claude.json").write_text(json.dumps({
"oauthAccount": {"sessionKey": session_key},
cred_dir = home / ".claude"
cred_dir.mkdir(parents=True)
(cred_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {"accessToken": access_token},
}))
plan = build_agent_provision_plan(
template="claude",
@@ -319,12 +320,13 @@ class TestAgentProviderRuntime(unittest.TestCase):
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
def test_claude_forward_host_credentials_populates_provisioned_env(self):
session_key = "sk-ant-oat01-test-key"
access_token = "sk-ant-oat01-test-key"
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
home = Path(tmp) / "host-claude"
home.mkdir()
(home / ".claude.json").write_text(json.dumps({
"oauthAccount": {"sessionKey": session_key},
cred_dir = home / ".claude"
cred_dir.mkdir(parents=True)
(cred_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {"accessToken": access_token},
}))
plan = build_agent_provision_plan(
template="claude",
@@ -336,7 +338,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
host_env={"HOME": str(home)},
)
self.assertEqual(
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: session_key},
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
plan.provisioned_env,
)
+36 -16
View File
@@ -19,7 +19,9 @@ class TestClaudeHostAccessToken(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
self.home = Path(self.tmp.name)
self.auth_path = self.home / ".claude.json"
self.cred_dir = self.home / ".claude"
self.cred_dir.mkdir()
self.auth_path = self.cred_dir / ".credentials.json"
def tearDown(self):
self.tmp.cleanup()
@@ -33,36 +35,40 @@ class TestClaudeHostAccessToken(unittest.TestCase):
claude_auth_path({"HOME": str(self.home)}),
)
def test_returns_session_key(self):
def test_returns_access_token(self):
key = "sk-ant-oat01-real-key"
self._write({"oauthAccount": {"sessionKey": 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):
claude_host_access_token({"HOME": str(self.home)})
# 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_oauth_account_dies(self):
def test_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_session_key_dies(self):
self._write({"oauthAccount": {"expiresAt": 2000000000}})
def test_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_session_key_dies(self):
self._write({"oauthAccount": {"sessionKey": ""}})
def test_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
self._write({
"oauthAccount": {
"sessionKey": "sk-ant-oat01-x",
"expiresAt": 1000,
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-x",
"expiresAt": 1000000,
},
})
with self.assertRaises(Die):
@@ -73,10 +79,11 @@ class TestClaudeHostAccessToken(unittest.TestCase):
def test_future_expiry_is_accepted(self):
key = "sk-ant-oat01-y"
# 2000000000000 ms = ~year 2033
self._write({
"oauthAccount": {
"sessionKey": key,
"expiresAt": 2000000000,
"claudeAiOauth": {
"accessToken": key,
"expiresAt": 2000000000000,
},
})
out = claude_host_access_token(
@@ -87,7 +94,7 @@ class TestClaudeHostAccessToken(unittest.TestCase):
def test_absent_expiry_field_is_accepted(self):
key = "sk-ant-oat01-z"
self._write({"oauthAccount": {"sessionKey": key}})
self._write({"claudeAiOauth": {"accessToken": key}})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
@@ -101,6 +108,19 @@ class TestClaudeHostAccessToken(unittest.TestCase):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_extra_fields_in_credentials_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,
},
})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
if __name__ == "__main__":
unittest.main()