From bdd352570b03dfbdd0ae10e46320efff93eff7fb Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 1 Jul 2026 21:33:04 +0000 Subject: [PATCH] fix(claude): read credentials from ~/.claude/.credentials.json 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 --- bot_bottle/contrib/claude/claude_auth.py | 27 +++++----- ...prd-new-claude-forward-host-credentials.md | 29 ++++++++--- tests/unit/test_agent_provider.py | 20 +++---- tests/unit/test_contrib_claude_auth.py | 52 +++++++++++++------ 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/bot_bottle/contrib/claude/claude_auth.py b/bot_bottle/contrib/claude/claude_auth.py index 1bf8fa1..f8b337b 100644 --- a/bot_bottle/contrib/claude/claude_auth.py +++ b/bot_bottle/contrib/claude/claude_auth.py @@ -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__ = [ diff --git a/docs/prds/prd-new-claude-forward-host-credentials.md b/docs/prds/prd-new-claude-forward-host-credentials.md index 3c740b9..f6820a3 100644 --- a/docs/prds/prd-new-claude-forward-host-credentials.md +++ b/docs/prds/prd-new-claude-forward-host-credentials.md @@ -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. diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 839089f..f3c17e6 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -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, ) diff --git a/tests/unit/test_contrib_claude_auth.py b/tests/unit/test_contrib_claude_auth.py index 59c4d10..d812afa 100644 --- a/tests/unit/test_contrib_claude_auth.py +++ b/tests/unit/test_contrib_claude_auth.py @@ -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()