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:
+
+ /oauth/callback - OAuth callback handler
+ /oauth/register - Register a callback port (POST)
+ /health - Health check
+
+
+
+""",
+ 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."