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__ = [