diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index a5bb084..e1645dc 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -45,6 +45,10 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI}) # forward_host_credentials is enabled. Pipelock must pass these through # (no TLS MITM) or its header DLP blocks the injected JWT. CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") + +# Host that egress injects the host Claude bearer on when Claude +# forward_host_credentials is enabled. +CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",) PromptMode = Literal[ "append_file", "read_prompt_file", diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 204357b..d3c8679 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -23,8 +23,9 @@ from ...agent_provider import ( provider_startup_args, ) from ...backend.docker import util as docker_mod -from ...egress import EgressRoute +from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute from ...log import die, info, warn +from .claude_auth import claude_host_access_token if TYPE_CHECKING: @@ -115,7 +116,6 @@ class ClaudeAgentProvider(AgentProvider): color: str = "", provider_settings: dict[str, object] | None = None, ) -> AgentProvisionPlan: - del forward_host_credentials, host_env resolved_guest_env = dict(guest_env or {}) startup_args = provider_startup_args(provider_settings) guest_home = self.guest_home @@ -177,13 +177,24 @@ class ClaudeAgentProvider(AgentProvider): claude_settings, f"{guest_home}/.claude/settings.json", )) + provisioned_env: dict[str, str] = {} + if forward_host_credentials: + _host_env = host_env or dict(os.environ) + provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = ( + claude_host_access_token(_host_env) + ) + + cred_token_ref = ( + CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials + else auth_token + ) egress_routes = (EgressRoute( host="api.anthropic.com", - auth_scheme="Bearer" if auth_token else "", - token_ref=auth_token, + auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "", + token_ref=cred_token_ref, ),) hidden_env_names: frozenset[str] = frozenset() - if auth_token: + if auth_token or forward_host_credentials: env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) @@ -205,6 +216,7 @@ class ClaudeAgentProvider(AgentProvider): files=tuple(files), egress_routes=egress_routes, hidden_env_names=hidden_env_names, + provisioned_env=provisioned_env, ) def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None: diff --git a/bot_bottle/contrib/claude/claude_auth.py b/bot_bottle/contrib/claude/claude_auth.py new file mode 100644 index 0000000..1bf8fa1 --- /dev/null +++ b/bot_bottle/contrib/claude/claude_auth.py @@ -0,0 +1,76 @@ +"""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. +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +from ...log import die + + +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" + + +def claude_host_access_token( + host_env: dict[str, str] | None = None, + *, + now: datetime | None = None, +) -> str: + path = claude_auth_path(host_env) + if not path.is_file(): + die( + f"claude host credentials: auth file missing at {path}. " + "Run `claude login` on the host or disable " + "agent_provider.forward_host_credentials." + ) + try: + raw = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as e: + die(f"claude host credentials: could not read valid JSON at {path}: {e}") + if not isinstance(raw, dict): + die(f"claude host credentials: {path} must contain a JSON object") + + oauth = raw.get("oauthAccount") + if not isinstance(oauth, dict): + die( + f"claude host credentials: {path} is missing oauthAccount. " + "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: + die( + f"claude host credentials: {path} oauthAccount.sessionKey is missing " + "or empty. Run `claude login` on the host and restart the bottle." + ) + + 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) + if exp_dt <= check_now: + die( + "claude host credentials: host Claude session token is expired. " + "Run `claude login` on the host and restart the bottle." + ) + + return session_key + + +__all__ = [ + "claude_auth_path", + "claude_host_access_token", +] diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 8d29a00..c67c268 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from .manifest import ManifestBottle CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" +CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN" EGRESS_HOSTNAME = "egress" @@ -397,6 +398,7 @@ class Egress(ABC): ) __all__ = [ + "CLAUDE_HOST_CREDENTIAL_TOKEN_REF", "CODEX_HOST_CREDENTIAL_TOKEN_REF", "EGRESS_HOSTNAME", "EGRESS_ROUTES_FILENAME", diff --git a/bot_bottle/manifest_agent.py b/bot_bottle/manifest_agent.py index c0fb1cf..276c1bc 100644 --- a/bot_bottle/manifest_agent.py +++ b/bot_bottle/manifest_agent.py @@ -25,8 +25,9 @@ class ManifestAgentProvider: header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent so the Claude Code CLI starts. - `forward_host_credentials` forwards the host Codex auth token into - the egress sidecar (Codex only). + `forward_host_credentials` forwards the host provider auth token into + the egress sidecar (Codex and Claude). For Codex this reads + `~/.codex/auth.json`; for Claude it reads `~/.claude.json`. """ template: str = "claude" @@ -92,10 +93,15 @@ class ManifestAgentProvider: f"is only supported for built-in templates " f"({', '.join(sorted(PROVIDER_TEMPLATES))})" ) - if forward_host_credentials and template != "codex": + if forward_host_credentials and template not in {"codex", "claude"}: raise ManifestError( f"bottle '{bottle_name}' agent_provider.forward_host_credentials " - "is currently only supported for template 'codex'" + "is only supported for templates 'codex' and 'claude'" + ) + if forward_host_credentials and auth_token: + raise ManifestError( + f"bottle '{bottle_name}' agent_provider.forward_host_credentials " + "and auth_token both set; use one or the other" ) settings = _parse_provider_settings(bottle_name, template, d.get("settings")) return cls( diff --git a/docs/prds/prd-new-claude-forward-host-credentials.md b/docs/prds/prd-new-claude-forward-host-credentials.md new file mode 100644 index 0000000..3c740b9 --- /dev/null +++ b/docs/prds/prd-new-claude-forward-host-credentials.md @@ -0,0 +1,121 @@ +# PRD prd-new: Claude forward_host_credentials + +- **Status:** Draft +- **Author:** claude +- **Created:** 2026-07-01 +- **Issue:** #325 + +## Summary + +Add `agent_provider.forward_host_credentials: true` support for the +`claude` template, mirroring the existing Codex flow. When enabled, +bot-bottle reads the host's Claude OAuth session key from +`~/.claude.json` at launch, forwards it only to the egress sidecar, +and injects a placeholder `CLAUDE_CODE_OAUTH_TOKEN` into the agent so +Claude Code starts without ever seeing the real credential. + +## Problem + +Running a Claude agent in a container today requires the operator to +manually extract a long-lived OAuth token (`claude setup-token`), export +it as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, and reference it explicitly in +the manifest with `agent_provider.auth_token: +"BOT_BOTTLE_CLAUDE_OAUTH_TOKEN"`. This is a two-step manual ceremony +that is easy to skip or do incorrectly. + +The host already stores a valid Claude session in `~/.claude.json` after +`claude login` or `claude setup-token`. Codex already automates an +equivalent extraction from `~/.codex/auth.json`. There is no reason +Claude bottles cannot do the same. + +## Goals / Success Criteria + +- A Claude bottle with `forward_host_credentials: true` in the manifest + uses the host's `~/.claude.json` session key at launch with no + additional operator steps. +- The agent container receives only `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` + — never the real token. +- The real session key lives only in the egress sidecar's environment. +- Missing, malformed, or expired host Claude auth fails launch with a + clear operator-facing message. +- Existing `auth_token` behavior is unchanged. +- `forward_host_credentials: true` is rejected in the manifest when both + `auth_token` and `forward_host_credentials` are set, since they serve + the same purpose. + +## Non-goals + +- Refreshing Claude OAuth tokens in the sidecar. +- Writing a dummy `~/.claude.json` auth state to the agent (unlike the + Codex flow, Claude Code reads its credential from `CLAUDE_CODE_OAUTH_TOKEN` + in env, not from an auth file — no guest-side auth marker is needed). +- Supporting `forward_host_credentials` for providers other than `codex` + and `claude`. + +## Design + +### Manifest schema + +```yaml +agent_provider: + template: claude + forward_host_credentials: true +``` + +Rejects in manifest validation when: +- Template is not `codex` or `claude`. +- Both `auth_token` and `forward_host_credentials` are set. + +### Host auth extraction (`contrib/claude/claude_auth.py`) + +At prepare/launch time, when `forward_host_credentials: true`: + +1. Resolve `~/.claude.json` (falling back to `$HOME/.claude.json`). +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. + +Errors name the missing or invalid condition and point the operator at +`claude login`, without printing token values. + +### Egress route + +When `forward_host_credentials: true`: + +- Provision the session key in `provisioned_env` under + `BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN` (new constant in `egress.py`). +- Set up the `api.anthropic.com` egress route with `auth_scheme: Bearer` + and `token_ref: BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN`. +- Set `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` in the agent env and + add it to `hidden_env_names`. + +No dummy auth file and no `verify` step are needed — Claude Code reads +the credential from the env var, not from a file. + +### Constants + +- `CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"` + in `egress.py` (alongside the existing `CODEX_HOST_CREDENTIAL_TOKEN_REF`). +- `CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)` in + `agent_provider.py` (alongside the existing `CODEX_HOST_CREDENTIAL_HOSTS`). + +### Data flow + +``` +Host ~/.claude.json → bot-bottle launch + │ + ├──► egress sidecar env (real token only) + │ + └──► agent env: CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder + +Agent → HTTPS to api.anthropic.com (via egress) +Egress → injects Authorization: Bearer +Egress → forwards to api.anthropic.com +``` + +## Open questions + +None — the Codex precedent makes the design clear. diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index efbb758..839089f 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -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: @@ -289,6 +293,65 @@ 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" + 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}, + })) + 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): + session_key = "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}, + })) + 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: session_key}, + 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( diff --git a/tests/unit/test_contrib_claude_auth.py b/tests/unit/test_contrib_claude_auth.py new file mode 100644 index 0000000..59c4d10 --- /dev/null +++ b/tests/unit/test_contrib_claude_auth.py @@ -0,0 +1,106 @@ +"""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 bot_bottle.contrib.claude.claude_auth import ( + claude_auth_path, + claude_host_access_token, +) +from bot_bottle.log import Die + + +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" + + def tearDown(self): + self.tmp.cleanup() + + def _write(self, payload: dict) -> None: # type: ignore + 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)}), + ) + + def test_returns_session_key(self): + key = "sk-ant-oat01-real-key" + self._write({"oauthAccount": {"sessionKey": 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)}) + + def test_missing_oauth_account_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}}) + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(self.home)}) + + def test_empty_session_key_dies(self): + self._write({"oauthAccount": {"sessionKey": ""}}) + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(self.home)}) + + def test_expired_token_dies(self): + self._write({ + "oauthAccount": { + "sessionKey": "sk-ant-oat01-x", + "expiresAt": 1000, + }, + }) + with self.assertRaises(Die): + claude_host_access_token( + {"HOME": str(self.home)}, + now=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + + def test_future_expiry_is_accepted(self): + key = "sk-ant-oat01-y" + self._write({ + "oauthAccount": { + "sessionKey": key, + "expiresAt": 2000000000, + }, + }) + out = claude_host_access_token( + {"HOME": str(self.home)}, + now=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + self.assertEqual(key, out) + + def test_absent_expiry_field_is_accepted(self): + key = "sk-ant-oat01-z" + self._write({"oauthAccount": {"sessionKey": key}}) + out = claude_host_access_token({"HOME": str(self.home)}) + self.assertEqual(key, out) + + def test_non_json_file_dies(self): + self.auth_path.write_text("not json {{{") + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(self.home)}) + + def test_json_array_root_dies(self): + self.auth_path.write_text("[]") + with self.assertRaises(Die): + claude_host_access_token({"HOME": str(self.home)}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index dd48eab..752ce50 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -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): diff --git a/tests/unit/test_manifest_validation.py b/tests/unit/test_manifest_validation.py index 45e05cf..e85007d 100644 --- a/tests/unit/test_manifest_validation.py +++ b/tests/unit/test_manifest_validation.py @@ -82,10 +82,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: