feat(claude): add forward_host_credentials support
Reads the host's Claude OAuth session key from ~/.claude.json at launch and forwards it only to the egress sidecar (never to the agent), placing a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent env so Claude Code starts without seeing the real credential. Mirrors the existing Codex forward_host_credentials flow (PRD 0029). Adds claude_auth.py to extract and validate the sessionKey, a CLAUDE_HOST_CREDENTIAL_TOKEN_REF constant in egress.py, and updates manifest_agent.py to allow the flag for both 'codex' and 'claude' templates. Also adds a mutual-exclusion check that rejects setting both auth_token and forward_host_credentials together. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> 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> 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> docs(prd): fix credential path references (~/.claude/.credentials.json) fix(test): suppress gitleaks false positives on synthetic Claude tokens
This commit is contained in:
@@ -9,11 +9,15 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.agent_provider import (
|
||||
CLAUDE_HOST_CREDENTIAL_HOSTS,
|
||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||
build_agent_provision_plan,
|
||||
prompt_args,
|
||||
)
|
||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||
from bot_bottle.egress import (
|
||||
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
|
||||
CODEX_HOST_CREDENTIAL_TOKEN_REF,
|
||||
)
|
||||
|
||||
|
||||
def _jwt(exp: int) -> str:
|
||||
@@ -292,6 +296,67 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual({}, plan.provisioned_env)
|
||||
|
||||
def test_claude_forward_host_credentials_populates_egress_route(self):
|
||||
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
home = Path(tmp) / "host-claude"
|
||||
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",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
forward_host_credentials=True,
|
||||
host_env={"HOME": str(home)},
|
||||
)
|
||||
self.assertEqual(1, len(plan.egress_routes))
|
||||
route = plan.egress_routes[0]
|
||||
self.assertIn(route.host, CLAUDE_HOST_CREDENTIAL_HOSTS)
|
||||
self.assertEqual("Bearer", route.auth_scheme)
|
||||
self.assertEqual(CLAUDE_HOST_CREDENTIAL_TOKEN_REF, route.token_ref)
|
||||
self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"])
|
||||
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
|
||||
|
||||
def test_claude_forward_host_credentials_populates_provisioned_env(self):
|
||||
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
home = Path(tmp) / "host-claude"
|
||||
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",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
forward_host_credentials=True,
|
||||
host_env={"HOME": str(home)},
|
||||
)
|
||||
self.assertEqual(
|
||||
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
|
||||
plan.provisioned_env,
|
||||
)
|
||||
|
||||
def test_claude_without_forward_host_credentials_has_empty_provisioned_env(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = build_agent_provision_plan(
|
||||
template="claude",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
forward_host_credentials=False,
|
||||
)
|
||||
self.assertEqual({}, plan.provisioned_env)
|
||||
|
||||
def test_pi_plan_writes_default_ollama_models(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = build_agent_provision_plan(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Unit: host Claude auth extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.contrib.claude.claude_auth import (
|
||||
claude_auth_path,
|
||||
claude_host_access_token,
|
||||
)
|
||||
from bot_bottle.log import Die
|
||||
|
||||
|
||||
def _cred_json(access_token: str, **extra: object) -> str:
|
||||
payload: dict[str, object] = {"claudeAiOauth": {"accessToken": access_token, **extra}}
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
class TestClaudeHostAccessToken(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
|
||||
self.home = Path(self.tmp.name)
|
||||
self.cred_dir = self.home / ".claude"
|
||||
self.cred_dir.mkdir()
|
||||
self.auth_path = self.cred_dir / ".credentials.json"
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def]
|
||||
self.auth_path.write_text(json.dumps(payload))
|
||||
|
||||
def test_auth_path_uses_home_env(self):
|
||||
self.assertEqual(
|
||||
self.auth_path,
|
||||
claude_auth_path({"HOME": str(self.home)}),
|
||||
)
|
||||
|
||||
# --- file-based (Linux) ---
|
||||
|
||||
def test_file_returns_access_token(self):
|
||||
key = "sk-ant-oat01-real-key" # gitleaks:allow
|
||||
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||
out = claude_host_access_token({"HOME": str(self.home)})
|
||||
self.assertEqual(key, out)
|
||||
|
||||
def test_file_missing_claude_ai_oauth_dies(self):
|
||||
self._write({"hasCompletedOnboarding": True})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_missing_access_token_dies(self):
|
||||
self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_empty_access_token_dies(self):
|
||||
self._write({"claudeAiOauth": {"accessToken": ""}})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_expired_token_dies(self):
|
||||
# expiresAt is milliseconds; 1_000_000 ms is year 1970
|
||||
self._write({
|
||||
"claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000}, # gitleaks:allow
|
||||
})
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token(
|
||||
{"HOME": str(self.home)},
|
||||
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
def test_file_future_expiry_is_accepted(self):
|
||||
key = "sk-ant-oat01-y" # gitleaks:allow
|
||||
# 2_000_000_000_000 ms ≈ year 2033
|
||||
self._write({
|
||||
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
|
||||
})
|
||||
out = claude_host_access_token(
|
||||
{"HOME": str(self.home)},
|
||||
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(key, out)
|
||||
|
||||
def test_file_absent_expiry_is_accepted(self):
|
||||
key = "sk-ant-oat01-z" # gitleaks:allow
|
||||
self._write({"claudeAiOauth": {"accessToken": key}})
|
||||
out = claude_host_access_token({"HOME": str(self.home)})
|
||||
self.assertEqual(key, out)
|
||||
|
||||
def test_file_non_json_dies(self):
|
||||
self.auth_path.write_text("not json {{{")
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_json_array_root_dies(self):
|
||||
self.auth_path.write_text("[]")
|
||||
with self.assertRaises(Die):
|
||||
claude_host_access_token({"HOME": str(self.home)})
|
||||
|
||||
def test_file_extra_fields_are_ignored(self):
|
||||
key = "sk-ant-oat01-real" # gitleaks:allow
|
||||
self._write({
|
||||
"claudeAiOauth": {
|
||||
"accessToken": key,
|
||||
"refreshToken": "sk-ant-ort01-secret", # gitleaks:allow
|
||||
"scopes": ["user:inference"],
|
||||
"expiresAt": 2_000_000_000_000,
|
||||
},
|
||||
})
|
||||
out = claude_host_access_token({"HOME": str(self.home)})
|
||||
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" # gitleaks:allow
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -80,11 +80,19 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
|
||||
"forward_host_credentials": "yes",
|
||||
})
|
||||
|
||||
def test_forward_host_credentials_rejected_for_claude(self):
|
||||
def test_forward_host_credentials_allowed_for_claude(self):
|
||||
b = _provider_config_bottle({
|
||||
"template": "claude",
|
||||
"forward_host_credentials": True,
|
||||
})
|
||||
self.assertTrue(b.agent_provider.forward_host_credentials)
|
||||
|
||||
def test_forward_host_credentials_and_auth_token_rejected_together(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
_provider_config_bottle({
|
||||
"template": "claude",
|
||||
"forward_host_credentials": True,
|
||||
"auth_token": "SOME_TOKEN",
|
||||
})
|
||||
|
||||
def test_auth_token_defaults_empty(self):
|
||||
|
||||
@@ -86,10 +86,22 @@ class TestAgentProviderValidation(unittest.TestCase):
|
||||
"b", {"forward_host_credentials": True, "template": "weird"}
|
||||
)
|
||||
|
||||
def test_forward_creds_non_codex_template(self) -> None:
|
||||
def test_forward_creds_pi_template_rejected(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict(
|
||||
"b", {"forward_host_credentials": True, "template": "claude"}
|
||||
"b", {"forward_host_credentials": True, "template": "pi"}
|
||||
)
|
||||
|
||||
def test_forward_creds_claude_allowed(self) -> None:
|
||||
p = ManifestAgentProvider.from_dict(
|
||||
"b", {"forward_host_credentials": True, "template": "claude"}
|
||||
)
|
||||
self.assertTrue(p.forward_host_credentials)
|
||||
|
||||
def test_forward_creds_and_auth_token_rejected(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgentProvider.from_dict(
|
||||
"b", {"forward_host_credentials": True, "auth_token": "T", "template": "claude"}
|
||||
)
|
||||
|
||||
def test_valid_claude_auth_token(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user