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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
"""Host Claude auth helpers.
|
"""Host Claude auth helpers.
|
||||||
|
|
||||||
Reads the host's Claude Code auth state and returns only the
|
Reads the host's Claude Code credentials from ~/.claude/.credentials.json
|
||||||
session key needed by egress. Does not expose refresh tokens
|
and returns only the access token needed by egress. Does not expose
|
||||||
or raw auth payloads.
|
refresh tokens or raw auth payloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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
|
env = os.environ if host_env is None else host_env
|
||||||
home = env.get("HOME")
|
home = env.get("HOME")
|
||||||
if home:
|
if home:
|
||||||
return Path(home) / ".claude.json"
|
return Path(home) / ".claude" / ".credentials.json"
|
||||||
return Path.home() / ".claude.json"
|
return Path.home() / ".claude" / ".credentials.json"
|
||||||
|
|
||||||
|
|
||||||
def claude_host_access_token(
|
def claude_host_access_token(
|
||||||
@@ -42,32 +42,33 @@ def claude_host_access_token(
|
|||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
die(f"claude host credentials: {path} must contain a JSON object")
|
die(f"claude host credentials: {path} must contain a JSON object")
|
||||||
|
|
||||||
oauth = raw.get("oauthAccount")
|
oauth = raw.get("claudeAiOauth")
|
||||||
if not isinstance(oauth, dict):
|
if not isinstance(oauth, dict):
|
||||||
die(
|
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 "
|
"Run `claude login` on the host or disable "
|
||||||
"agent_provider.forward_host_credentials."
|
"agent_provider.forward_host_credentials."
|
||||||
)
|
)
|
||||||
|
|
||||||
session_key = oauth.get("sessionKey")
|
access_token = oauth.get("accessToken")
|
||||||
if not isinstance(session_key, str) or not session_key:
|
if not isinstance(access_token, str) or not access_token:
|
||||||
die(
|
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."
|
"or empty. Run `claude login` on the host and restart the bottle."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# expiresAt is stored 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)
|
||||||
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:
|
if exp_dt <= check_now:
|
||||||
die(
|
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."
|
"Run `claude login` on the host and restart the bottle."
|
||||||
)
|
)
|
||||||
|
|
||||||
return session_key
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -68,15 +68,32 @@ 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`
|
||||||
|
(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`:
|
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.
|
2. Parse the JSON object.
|
||||||
3. Require an `oauthAccount` dict.
|
3. Require a `claudeAiOauth` dict.
|
||||||
4. Require a non-empty `oauthAccount.sessionKey` string.
|
4. Require a non-empty `claudeAiOauth.accessToken` string.
|
||||||
5. If `oauthAccount.expiresAt` is present as a number, require it to be
|
5. If `claudeAiOauth.expiresAt` is present as a number, divide by 1000
|
||||||
in the future.
|
and require the result to be in the future.
|
||||||
6. Return only the session key to the launch path.
|
6. 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.
|
||||||
|
|||||||
@@ -294,12 +294,13 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
self.assertEqual({}, plan.provisioned_env)
|
self.assertEqual({}, plan.provisioned_env)
|
||||||
|
|
||||||
def test_claude_forward_host_credentials_populates_egress_route(self):
|
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:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
home = Path(tmp) / "host-claude"
|
home = Path(tmp) / "host-claude"
|
||||||
home.mkdir()
|
cred_dir = home / ".claude"
|
||||||
(home / ".claude.json").write_text(json.dumps({
|
cred_dir.mkdir(parents=True)
|
||||||
"oauthAccount": {"sessionKey": session_key},
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
}))
|
}))
|
||||||
plan = build_agent_provision_plan(
|
plan = build_agent_provision_plan(
|
||||||
template="claude",
|
template="claude",
|
||||||
@@ -319,12 +320,13 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
||||||
|
|
||||||
def test_claude_forward_host_credentials_populates_provisioned_env(self):
|
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:
|
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||||
home = Path(tmp) / "host-claude"
|
home = Path(tmp) / "host-claude"
|
||||||
home.mkdir()
|
cred_dir = home / ".claude"
|
||||||
(home / ".claude.json").write_text(json.dumps({
|
cred_dir.mkdir(parents=True)
|
||||||
"oauthAccount": {"sessionKey": session_key},
|
(cred_dir / ".credentials.json").write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {"accessToken": access_token},
|
||||||
}))
|
}))
|
||||||
plan = build_agent_provision_plan(
|
plan = build_agent_provision_plan(
|
||||||
template="claude",
|
template="claude",
|
||||||
@@ -336,7 +338,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
|||||||
host_env={"HOME": str(home)},
|
host_env={"HOME": str(home)},
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: session_key},
|
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
|
||||||
plan.provisioned_env,
|
plan.provisioned_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ 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.")
|
||||||
self.home = Path(self.tmp.name)
|
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):
|
def tearDown(self):
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
@@ -33,36 +35,40 @@ class TestClaudeHostAccessToken(unittest.TestCase):
|
|||||||
claude_auth_path({"HOME": str(self.home)}),
|
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"
|
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)})
|
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_missing_auth_file_dies(self):
|
||||||
with self.assertRaises(Die):
|
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})
|
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_session_key_dies(self):
|
def test_missing_access_token_dies(self):
|
||||||
self._write({"oauthAccount": {"expiresAt": 2000000000}})
|
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_session_key_dies(self):
|
def test_empty_access_token_dies(self):
|
||||||
self._write({"oauthAccount": {"sessionKey": ""}})
|
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_expired_token_dies(self):
|
||||||
|
# expiresAt is in milliseconds; 1000000 ms = year 1970
|
||||||
self._write({
|
self._write({
|
||||||
"oauthAccount": {
|
"claudeAiOauth": {
|
||||||
"sessionKey": "sk-ant-oat01-x",
|
"accessToken": "sk-ant-oat01-x",
|
||||||
"expiresAt": 1000,
|
"expiresAt": 1000000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
@@ -73,10 +79,11 @@ class TestClaudeHostAccessToken(unittest.TestCase):
|
|||||||
|
|
||||||
def test_future_expiry_is_accepted(self):
|
def test_future_expiry_is_accepted(self):
|
||||||
key = "sk-ant-oat01-y"
|
key = "sk-ant-oat01-y"
|
||||||
|
# 2000000000000 ms = ~year 2033
|
||||||
self._write({
|
self._write({
|
||||||
"oauthAccount": {
|
"claudeAiOauth": {
|
||||||
"sessionKey": key,
|
"accessToken": key,
|
||||||
"expiresAt": 2000000000,
|
"expiresAt": 2000000000000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
out = claude_host_access_token(
|
out = claude_host_access_token(
|
||||||
@@ -87,7 +94,7 @@ class TestClaudeHostAccessToken(unittest.TestCase):
|
|||||||
|
|
||||||
def test_absent_expiry_field_is_accepted(self):
|
def test_absent_expiry_field_is_accepted(self):
|
||||||
key = "sk-ant-oat01-z"
|
key = "sk-ant-oat01-z"
|
||||||
self._write({"oauthAccount": {"sessionKey": 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)
|
||||||
|
|
||||||
@@ -101,6 +108,19 @@ class TestClaudeHostAccessToken(unittest.TestCase):
|
|||||||
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):
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user