From f93979bb5b6e64f046d96c00648368264218df80 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Thu, 5 Feb 2026 05:26:55 +0000 Subject: [PATCH] Add docker --- .github/workflows/sonarcloud.yml | 4 +- Makefile | 4 + docker/Dockerfile | 103 +++++++++ docker/docker-compose.yml | 35 +++ docker/oauth-proxy.py | 363 +++++++++++++++++++++++++++++++ docs/DOCKER_OAUTH_SETUP.md | 308 ++++++++++++++++++++++++++ scripts/validate-docker.sh | 62 ++++++ 7 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 docker/oauth-proxy.py create mode 100644 docs/DOCKER_OAUTH_SETUP.md create mode 100755 scripts/validate-docker.sh diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index a96ac79..ee39169 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -29,7 +29,7 @@ jobs: run: make coverage-sonar - name: SonarCloud scan - uses: SonarSource/sonarcloud-github-action@v5 + uses: SonarSource/sonarcloud-github-action@ffc3010689be73b8e5ae0c57ce35968afd7909e8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -43,7 +43,7 @@ jobs: -Dsonar.enableIssueAnnotation=true - name: SonarCloud quality gate - uses: SonarSource/sonarqube-quality-gate-action@v1.2.0 + uses: SonarSource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b with: scanMetadataReportFile: dist/quality/sonar/scannerwork/report-task.txt env: diff --git a/Makefile b/Makefile index 2edc81e..9bda0d0 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,7 @@ kill: # Quality and Security targets .PHONY: sonar sonar-cloud coverage-sonar sbom sbom-upload gitleaks fmt lint vet +.PHONY: docker-validate sonar: ## Run sonar-scanner for SonarQube analysis @SONAR_DIR=$(SONAR_DIR) COVERAGE_DIR=$(COVERAGE_DIR) VERSION=$(VERSION) ./scripts/run-sonar.sh @@ -146,6 +147,9 @@ vet: ## Run type checking with mypy echo "mypy not found. Install with: pip install mypy"; \ fi +docker-validate: ## Build Docker image and validate docker-compose + @./scripts/validate-docker.sh + help: @echo "Claude Code API Commands:" @echo "" diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..0f9aa03 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,103 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + python3 \ + python3-pip \ + python3-venv \ + sudo \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -s /bin/bash claudeuser && \ + echo "claudeuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# Set up application directory +WORKDIR /home/claudeuser/app +COPY . /home/claudeuser/app +RUN chown -R claudeuser:claudeuser /home/claudeuser/app + +USER claudeuser + +# Install Claude CLI using the official installer (no npm required) +RUN curl -fsSL https://claude.ai/install.sh | bash + +# Create virtualenv and install dependencies +RUN python3 -m venv /home/claudeuser/venv && \ + /home/claudeuser/venv/bin/pip install --upgrade pip setuptools wheel && \ + /home/claudeuser/venv/bin/pip install -e . --use-pep517 || \ + /home/claudeuser/venv/bin/pip install -e . + +ENV PATH="/home/claudeuser/venv/bin:/home/claudeuser/.local/bin:/home/claudeuser/.bun/bin:${PATH}" + +# Create Claude config and workspace directories +RUN mkdir -p /home/claudeuser/.config/claude /home/claudeuser/app/workspace + +EXPOSE 8000 + +ENV HOST=0.0.0.0 +ENV PORT=8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +RUN cat <<'EOF' > /home/claudeuser/entrypoint.sh +#!/usr/bin/env bash +set -euo pipefail + +auth_ready=false + +# Prefer host-mounted Claude auth (Claude Max / Claude Code) +if [ -d "$HOME/.claude" ] && [ -n "$(ls -A "$HOME/.claude" 2>/dev/null)" ]; then + echo "Using Claude auth from $HOME/.claude" + auth_ready=true +fi + +# Fallback to Claude config file if present +if [ "$auth_ready" != "true" ] && [ -f "$HOME/.config/claude/config.json" ]; then + echo "Using Claude config from $HOME/.config/claude/config.json" + auth_ready=true +fi + +# Optionally write config from API key (only when explicitly requested) +if [ "$auth_ready" != "true" ] && [ -n "${ANTHROPIC_API_KEY:-}" ] && [ "${USE_CLAUDE_MAX:-}" != "true" ]; then + if [ "${WRITE_CLAUDE_CONFIG:-}" = "true" ]; then + echo "Configuring Claude Code with API key..." + python3 - <<'PY' +import json +import os +from pathlib import Path + +config_dir = Path.home() / ".config" / "claude" +config_dir.mkdir(parents=True, exist_ok=True) +with (config_dir / "config.json").open("w", encoding="utf-8") as handle: + json.dump({"apiKey": os.environ["ANTHROPIC_API_KEY"], "autoUpdate": False}, handle) +PY + echo "Claude Code configured with API key" + auth_ready=true + else + echo "ANTHROPIC_API_KEY is set but WRITE_CLAUDE_CONFIG is not true." + echo "For security, no config file was written. Mount ~/.claude or ~/.config/claude or set WRITE_CLAUDE_CONFIG=true." + fi +fi + +if [ "$auth_ready" != "true" ] && [ "${USE_CLAUDE_MAX:-}" = "true" ]; then + echo "Using Claude Max subscription - please run: docker exec -it claude-code-api claude" + echo "Then authenticate via browser when prompted" +elif [ "$auth_ready" != "true" ]; then + echo "No authentication configured. Mount ~/.claude or ~/.config/claude, or set ANTHROPIC_API_KEY + WRITE_CLAUDE_CONFIG=true." +fi + +echo "Starting API server..." +cd /home/claudeuser/app +exec python3 -m claude_code_api.main +EOF +RUN chmod +x /home/claudeuser/entrypoint.sh + +ENTRYPOINT ["/home/claudeuser/entrypoint.sh"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..f7e207d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,35 @@ +services: + claude-code-api: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: claude-code-api + ports: + - "127.0.0.1:8000:8000" # API server port + - "127.0.0.1:8888:8888" # OAuth callback proxy port + environment: + # Use Claude Max subscription instead of API key + - USE_CLAUDE_MAX=true + - HOST=0.0.0.0 + - PORT=8000 + # Optional: Project root for Claude Code workspace + - CLAUDE_PROJECT_ROOT=/home/claudeuser/app/workspace + # OAuth proxy configuration + - OAUTH_PROXY_HOST=host.docker.internal + - OAUTH_PROXY_PORT=8888 + volumes: + # Mount workspace for persistent projects + - ../workspace:/home/claudeuser/app/workspace + # Mount local Claude auth (Claude Max / Claude Code) + - ${HOME}/.claude:/home/claudeuser/.claude + # Mount Claude config to persist authentication + - ../claude-config:/home/claudeuser/.config/claude + # Optional: Mount custom config + - ../config:/home/claudeuser/app/config + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/docker/oauth-proxy.py b/docker/oauth-proxy.py new file mode 100644 index 0000000..a8db26d --- /dev/null +++ b/docker/oauth-proxy.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +""" +OAuth Callback Proxy for Claude Code in Docker + +This proxy runs on the host machine and forwards OAuth callbacks from MCP servers +into the Docker container where Claude Code is running. + +Usage: + python3 docker/oauth-proxy.py [--port PORT] [--container-host HOST] + +The proxy listens for OAuth callbacks on the host and forwards them to the container. +""" + +import asyncio +import argparse +import logging +import sys +from typing import Dict, Any +from urllib.parse import urlencode, parse_qs, urlparse + +try: + from aiohttp import web, ClientSession, ClientError +except ImportError: + print("Error: aiohttp is required. Install with: pip install aiohttp") + sys.exit(1) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('oauth-proxy') + + +class OAuthProxy: + """OAuth callback proxy for Docker containers.""" + + def __init__(self, container_host: str = "localhost", container_port: int = 8000): + self.container_host = container_host + self.container_port = container_port + self.active_sessions: Dict[str, int] = {} # session_id -> callback_port mapping + + async def handle_oauth_callback(self, request: web.Request) -> web.Response: + """Handle OAuth callback and forward to container.""" + try: + # Get all query parameters + query_params = dict(request.query) + + logger.info(f"Received OAuth callback: {request.url}") + logger.info(f"Query params: {query_params}") + + # Extract session info if provided + session_id = query_params.get('state', 'unknown') + callback_port = query_params.get('callback_port') + + if not callback_port: + # Try to determine callback port from state or other params + logger.warning("No callback_port specified, using default container port") + callback_port = self.container_port + else: + callback_port = int(callback_port) + + # Forward to container + target_url = f"http://{self.container_host}:{callback_port}/oauth/callback" + + logger.info(f"Forwarding OAuth callback to: {target_url}") + + async with ClientSession() as session: + try: + # Forward the callback with all query parameters + async with session.get(target_url, params=query_params, timeout=10) as resp: + response_text = await resp.text() + + logger.info(f"Container response: {resp.status}") + + # Return success page to user + return web.Response( + text=self._success_html(session_id), + content_type='text/html', + status=200 + ) + + except ClientError as e: + logger.error(f"Failed to forward to container: {e}") + return web.Response( + text=self._error_html(str(e)), + content_type='text/html', + status=502 + ) + + except Exception as e: + logger.error(f"Error handling OAuth callback: {e}", exc_info=True) + return web.Response( + text=self._error_html(str(e)), + content_type='text/html', + status=500 + ) + + async def handle_register_callback(self, request: web.Request) -> web.Response: + """Register a callback port for a session.""" + try: + data = await request.json() + session_id = data.get('session_id') + callback_port = data.get('callback_port') + + if not session_id or not callback_port: + return web.json_response( + {'error': 'Missing session_id or callback_port'}, + status=400 + ) + + self.active_sessions[session_id] = int(callback_port) + + logger.info(f"Registered callback for session {session_id} on port {callback_port}") + + return web.json_response({ + 'status': 'registered', + 'session_id': session_id, + 'callback_url': f'http://localhost:{self.proxy_port}/oauth/callback?state={session_id}&callback_port={callback_port}' + }) + + except Exception as e: + logger.error(f"Error registering callback: {e}") + return web.json_response({'error': str(e)}, status=500) + + async def handle_health(self, request: web.Request) -> web.Response: + """Health check endpoint.""" + return web.json_response({ + 'status': 'healthy', + 'service': 'oauth-proxy', + 'container_host': self.container_host, + 'container_port': self.container_port, + 'active_sessions': len(self.active_sessions) + }) + + def _success_html(self, session_id: str) -> str: + """Generate success HTML page.""" + return f""" + + + + OAuth Authentication Successful + + + +
+
+

Authentication Successful!

+

Your MCP server has been successfully authenticated with Claude Code.

+

You can now close this window and return to your terminal.

+
Session: {session_id}
+
+ + +""" + + def _error_html(self, error: str) -> str: + """Generate error HTML page.""" + return f""" + + + + OAuth Authentication Failed + + + +
+
+

Authentication Failed

+

There was an error completing the OAuth authentication.

+

Please try again or check the logs for more details.

+
{error}
+
+ + +""" + + +async def create_app(proxy: OAuthProxy, port: int) -> web.Application: + """Create and configure the web application.""" + app = web.Application() + + # Store proxy port for callback URL generation + proxy.proxy_port = port + + # Add routes + app.router.add_get('/oauth/callback', proxy.handle_oauth_callback) + app.router.add_post('/oauth/register', proxy.handle_register_callback) + app.router.add_get('/health', proxy.handle_health) + + # Root endpoint with instructions + async def handle_root(request): + return web.Response( + text=""" + + + + OAuth Proxy for Claude Code + + + +

OAuth Proxy for Claude Code

+
+ Status: Running
+ Callback URL: http://localhost:""" + str(port) + """/oauth/callback +
+

Usage

+

This proxy forwards OAuth callbacks from MCP servers into your Docker container.

+

Endpoints:

+ + + +""", + content_type='text/html' + ) + + app.router.add_get('/', handle_root) + + return app + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description='OAuth Callback Proxy for Claude Code in Docker' + ) + parser.add_argument( + '--port', + type=int, + default=8888, + help='Port to listen on for OAuth callbacks (default: 8888)' + ) + parser.add_argument( + '--container-host', + default='localhost', + help='Container host (default: localhost)' + ) + parser.add_argument( + '--container-port', + type=int, + default=8000, + help='Container port (default: 8000)' + ) + + args = parser.parse_args() + + # Create proxy + proxy = OAuthProxy( + container_host=args.container_host, + container_port=args.container_port + ) + + # Create and run app + logger.info(f"Starting OAuth Proxy on port {args.port}") + logger.info(f"Forwarding to container at {args.container_host}:{args.container_port}") + logger.info(f"OAuth callback URL: http://localhost:{args.port}/oauth/callback") + logger.info("Press Ctrl+C to stop") + + app = asyncio.run(create_app(proxy, args.port)) + web.run_app(app, host='0.0.0.0', port=args.port) + + +if __name__ == '__main__': + main() diff --git a/docs/DOCKER_OAUTH_SETUP.md b/docs/DOCKER_OAUTH_SETUP.md new file mode 100644 index 0000000..79ee112 --- /dev/null +++ b/docs/DOCKER_OAUTH_SETUP.md @@ -0,0 +1,308 @@ +# Docker OAuth Setup for MCP Servers + +This guide explains how to authenticate MCP servers that require OAuth when running Claude Code inside a Docker container. + +## The Problem + +MCP servers (like GitHub, Gmail, etc.) use OAuth authentication which requires: +1. Opening a browser to authenticate +2. Redirecting back to `http://localhost:[random-port]/callback` + +**Issue**: The Docker container can't receive these callbacks because: +- The browser runs on your host machine +- The callback URL points to `localhost` on the host +- The Docker container has a different network namespace + +## The Solution: OAuth Proxy + +We've created an OAuth proxy that runs on your host machine and forwards callbacks into the container. + +## Setup Instructions + +### Step 1: Install Dependencies + +The OAuth proxy requires `aiohttp`: + +```bash +pip install aiohttp +``` + +Or if using the project virtualenv: + +```bash +cd claude-code-api +pip install -e . +pip install aiohttp +``` + +### Step 2: Start the OAuth Proxy + +In a **separate terminal** (keep it running), start the OAuth proxy: + +```bash +python3 docker/oauth-proxy.py +``` + +You should see: +``` +Starting OAuth Proxy on port 8888 +Forwarding to container at localhost:8000 +OAuth callback URL: http://localhost:8888/oauth/callback +Press Ctrl+C to stop +``` + +**Optional**: Run on a different port: +```bash +python3 docker/oauth-proxy.py --port 9999 +``` + +### Step 3: Start Docker Container + +In your main terminal: + +```bash +docker-compose -f docker/docker-compose.yml up -d +``` + +This compose file mounts your host Claude auth directory: + +- `${HOME}/.claude` → `/home/claudeuser/.claude` + +Ensure your host `~/.claude` contains your Claude Code/Max auth before starting. + +### Step 4: Configure MCP Servers + +When configuring MCP servers that require OAuth, you'll need to set the callback URL to use the proxy. + +#### Option A: During Interactive Authentication + +1. Run `claude` inside the container: + ```bash + docker exec -it claude-code-api claude + ``` + +2. When Claude prompts you to authenticate an MCP server: + - Copy the authentication URL + - Open it in your **host** browser (not container) + - Complete the OAuth flow + - The callback will automatically be forwarded through the proxy + +#### Option B: Manual Configuration + +If you need to manually configure callback URLs in MCP server settings: + +**Use**: `http://localhost:8888/oauth/callback` + +Instead of the default random port that Claude Code generates. + +### Step 5: Verify Setup + +Check that both services are running: + +```bash +# Check API server +curl http://localhost:8000/health + +# Check OAuth proxy +curl http://localhost:8888/health +``` + +Both should return healthy status. + +## How It Works + +``` +┌─────────────────┐ +│ Your Browser │ +│ (on host) │ +└────────┬────────┘ + │ 1. OAuth redirect + │ http://localhost:8888/oauth/callback?code=... + ▼ +┌─────────────────┐ +│ OAuth Proxy │ +│ (on host) │ Runs: docker/oauth-proxy.py +│ Port: 8888 │ +└────────┬────────┘ + │ 2. Forward callback + │ http://localhost:8000/... + ▼ +┌─────────────────┐ +│ Docker Container│ +│ Claude Code │ Receives callback +│ Port: 8000 │ Completes auth +└─────────────────┘ +``` + +## Troubleshooting + +### Issue: "Connection refused" when forwarding callback + +**Solution**: Make sure the Docker container is running: +```bash +docker ps | grep claude-code-api +``` + +### Issue: OAuth proxy can't reach container + +**Solution**: Check that port 8888 is exposed in `docker/docker-compose.yml`: +```yaml +ports: + - "127.0.0.1:8000:8000" + - "127.0.0.1:8888:8888" # Should be present +``` + +### Issue: Browser shows "localhost:8888 refused to connect" + +**Solution**: OAuth proxy isn't running. Start it in a separate terminal: +```bash +python3 docker/oauth-proxy.py +``` + +### Issue: Callback succeeds but MCP still not authenticated + +**Solution**: Check container logs: +```bash +docker logs claude-code-api +``` + +Look for errors in the OAuth flow. + +## Advanced Configuration + +### Custom Container Host + +If running Docker on a different machine: + +```bash +python3 docker/oauth-proxy.py --container-host 192.168.1.100 --container-port 8000 +``` + +### Multiple Containers + +Run multiple proxies on different ports: + +```bash +# Terminal 1 - Container 1 +python3 docker/oauth-proxy.py --port 8888 --container-port 8000 + +# Terminal 2 - Container 2 +python3 docker/oauth-proxy.py --port 8889 --container-port 8001 +``` + +### Production Deployment + +For production, run the OAuth proxy as a systemd service: + +1. Create `/etc/systemd/system/claude-oauth-proxy.service`: + +```ini +[Unit] +Description=OAuth Proxy for Claude Code +After=network.target + +[Service] +Type=simple +User=your-user +WorkingDirectory=/path/to/claude-code-api +ExecStart=/usr/bin/python3 /path/to/claude-code-api/docker/oauth-proxy.py +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +2. Enable and start: + +```bash +sudo systemctl enable claude-oauth-proxy +sudo systemctl start claude-oauth-proxy +``` + +## Alternative: Host Network Mode (Linux/Mac Only) + +If you're on Linux or Mac, you can use host network mode instead of the proxy: + +Edit `docker/docker-compose.yml`: + +```yaml +services: + claude-code-api: + network_mode: "host" + # Remove 'ports' section when using host mode +``` + +**Pros**: No proxy needed, simpler setup +**Cons**: Less isolation, Linux/Mac only, not recommended for production + +## Security Notes + +1. The OAuth proxy only forwards callbacks, it doesn't store credentials +2. All communication is local (localhost only by default) +3. For production, consider: + - Adding authentication to the proxy + - Using HTTPS + - Restricting which containers can be targeted + - Running behind a reverse proxy + +## Testing the Setup + +Test the complete flow: + +```bash +# 1. Start OAuth proxy +python3 docker/oauth-proxy.py + +# 2. In another terminal, start container +docker-compose -f docker/docker-compose.yml up -d + +# 3. Test callback forwarding +curl "http://localhost:8888/oauth/callback?code=test123&state=test-session" + +# Should return a success page +``` + +## Support + +If you encounter issues: + +1. Check all services are running: + - OAuth proxy: `curl http://localhost:8888/health` + - API server: `curl http://localhost:8000/health` + - Container: `docker ps` + +2. Check logs: + ```bash + # OAuth proxy logs (in terminal where it's running) + # Container logs + docker logs claude-code-api + ``` + +3. Verify network connectivity: + ```bash + # From host to container + curl http://localhost:8000/health + ``` + +## FAQ + +**Q: Do I need to run the OAuth proxy all the time?** +A: Only when authenticating MCP servers. Once authenticated, credentials are stored and the proxy isn't needed. + +**Q: Can I use this with existing authenticated MCP servers?** +A: Yes! If you already authenticated MCP servers on your host, just mount your `~/.config/claude` directory: +```yaml +volumes: + - ~/.config/claude:/home/claudeuser/.config/claude +``` + +**Q: Does this work on Windows?** +A: Yes! The OAuth proxy runs on any platform. Just use `python` instead of `python3` on Windows. + +**Q: How do I stop everything?** +A: +```bash +# Stop OAuth proxy: Ctrl+C in its terminal +# Stop container +docker-compose -f docker/docker-compose.yml down +``` diff --git a/scripts/validate-docker.sh b/scripts/validate-docker.sh new file mode 100755 index 0000000..eb90cf7 --- /dev/null +++ b/scripts/validate-docker.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_NAME="${DOCKER_PROJECT_NAME:-claude-code-api-validate}" +COMPOSE_FILE="docker/docker-compose.yml" +HEALTH_URL="${DOCKER_HEALTH_URL:-http://localhost:8000/health}" +BASE_URL="${DOCKER_BASE_URL:-http://localhost:8000}" +MAX_RETRIES="${DOCKER_HEALTH_RETRIES:-30}" +SLEEP_SECONDS="${DOCKER_HEALTH_SLEEP:-2}" +E2E_MODEL_ID="${DOCKER_E2E_MODEL_ID:-claude-haiku-4-5-20251001}" + +cleanup() { + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down --remove-orphans >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "Building Docker image..." +docker build -f docker/Dockerfile -t claude-code-api:docker-validate . + +echo "Validating docker-compose configuration..." +docker compose -f "$COMPOSE_FILE" config >/dev/null + +echo "Starting Docker stack for end-to-end validation..." +docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d --build + +echo "Waiting for API health check at $HEALTH_URL..." +healthy=false +for _ in $(seq 1 "$MAX_RETRIES"); do + if curl -fsS "$HEALTH_URL" >/dev/null; then + healthy=true + break + fi + sleep "$SLEEP_SECONDS" +done + +if [ "$healthy" != "true" ]; then + echo "API did not become healthy in time. Logs:" >&2 + docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs --no-color >&2 || true + exit 1 +fi + +if [ -z "${ANTHROPIC_API_KEY:-}" ]; then + has_auth=false + if [ -d "claude-config" ] && [ -n "$(ls -A claude-config 2>/dev/null)" ]; then + has_auth=true + fi + if [ -d "${HOME}/.claude" ] && [ -n "$(ls -A "${HOME}/.claude" 2>/dev/null)" ]; then + has_auth=true + fi + if [ "$has_auth" != "true" ]; then + echo "E2E tests require authentication. Set ANTHROPIC_API_KEY or provide ./claude-config or ~/.claude with Claude credentials." >&2 + exit 1 + fi +fi + +echo "Running E2E tests against $BASE_URL with model $E2E_MODEL_ID..." +CLAUDE_CODE_API_E2E=1 \ +CLAUDE_CODE_API_BASE_URL="$BASE_URL" \ +CLAUDE_CODE_API_TEST_MODEL="$E2E_MODEL_ID" \ +python -m pytest tests/test_e2e_live_api.py -m e2e -v + +echo "Docker build, compose config, API health, and E2E tests validated."