diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 3ef816b..2299b93 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 7c1043b..afd2823 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: @@ -118,7 +119,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 @@ -180,13 +180,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"}) @@ -208,6 +219,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..af3963b --- /dev/null +++ b/bot_bottle/contrib/claude/claude_auth.py @@ -0,0 +1,114 @@ +"""Host Claude auth helpers. + +Reads the host's Claude Code credentials and returns only the access +token needed by egress. Does not expose refresh tokens or raw payloads. + +Credential storage by platform: + Linux — ~/.claude/.credentials.json + macOS — macOS Keychain, service "Claude Code-credentials" + (file path is tried first; Keychain is the fallback) +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +from ...log import die + + +_KEYCHAIN_SERVICE = "Claude Code-credentials" + + +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" / ".credentials.json" + return Path.home() / ".claude" / ".credentials.json" + + +def _read_keychain() -> dict[str, object] | None: + """Try the macOS Keychain. Returns parsed JSON dict or None.""" + if sys.platform != "darwin": + return None + try: + result = subprocess.run( + ["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode != 0 or not result.stdout.strip(): + return None + try: + raw = json.loads(result.stdout.strip()) + except json.JSONDecodeError: + return None + return raw if isinstance(raw, dict) else None + + +def claude_host_access_token( + host_env: dict[str, str] | None = None, + *, + now: datetime | None = None, +) -> str: + path = claude_auth_path(host_env) + raw: dict[str, object] | None = None + + if path.is_file(): + 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") + else: + raw = _read_keychain() + if raw is None: + die( + f"claude host credentials: auth file missing at {path} and " + f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. " + "Run `claude login` on the host or disable " + "agent_provider.forward_host_credentials." + ) + + oauth = raw.get("claudeAiOauth") + if not isinstance(oauth, dict): + die( + "claude host credentials: claudeAiOauth is missing from credentials. " + "Run `claude login` on the host or disable " + "agent_provider.forward_host_credentials." + ) + + access_token = oauth.get("accessToken") + if not isinstance(access_token, str) or not access_token: + die( + "claude host credentials: claudeAiOauth.accessToken is missing or empty. " + "Run `claude login` on the host and restart the bottle." + ) + + # expiresAt is 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) / 1000.0, timezone.utc) + if exp_dt <= check_now: + die( + "claude host credentials: host Claude access token is expired. " + "Run `claude login` on the host and restart the bottle." + ) + + return access_token + + +__all__ = [ + "claude_auth_path", + "claude_host_access_token", +] diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index f700dc9..b9b2b30 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -30,6 +30,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" @@ -400,6 +401,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 5766ef6..c6071ad 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 daemon (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/.credentials.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..6c4ca6b --- /dev/null +++ b/docs/prds/prd-new-claude-forward-host-credentials.md @@ -0,0 +1,146 @@ +# 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/.credentials.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/.credentials.json` +after `claude login`. 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/.credentials.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`) + +Claude Code credential storage varies by platform: + +- **Linux**: `~/.claude/.credentials.json` +- **macOS**: macOS Keychain, service `"Claude Code-credentials"` + (the file path is tried first; Keychain is the fallback when the file + is absent) + +`~/.claude.json` contains only UI state and profile metadata — no token. + +The credentials JSON schema (same whether from file or Keychain): + +```json +{ + "claudeAiOauth": { + "accessToken": "", + "refreshToken": "", + "expiresAt": 1748276587173, + "scopes": ["user:inference", "user:profile"] + } +} +``` + +`expiresAt` is in **milliseconds** (not seconds). + +At prepare/launch time, when `forward_host_credentials: true`: + +1. Try `~/.claude/.credentials.json`; on macOS, if absent, run + `security find-generic-password -s "Claude Code-credentials" -w` + and parse its stdout as JSON. +2. Require a `claudeAiOauth` dict. +3. Require a non-empty `claudeAiOauth.accessToken` string. +4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require + the result to be in the future. +5. 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. + +### 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/.credentials.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 adf25d2..1b058ba 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: @@ -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( diff --git a/tests/unit/test_contrib_claude_auth.py b/tests/unit/test_contrib_claude_auth.py new file mode 100644 index 0000000..7e998e2 --- /dev/null +++ b/tests/unit/test_contrib_claude_auth.py @@ -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() 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 a60ba14..d23cc77 100644 --- a/tests/unit/test_manifest_validation.py +++ b/tests/unit/test_manifest_validation.py @@ -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: