diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..ad56c93d1b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,64 @@ +# ============================================================================= +# Consolidated .dockerignore — applies to all Docker builds from repo root +# ============================================================================= +# Both backend and web images build with context: . (repo root). +# Backend needs: pyproject.toml, uv.lock, src/ +# Web needs: web/ + +# Git +.git/ +.github/ +.gitignore + +# Docker configs (Dockerfiles read via -f, not from context) +docker/ + +# Tests +tests/ + +# Documentation +docs/ +*.md + +# Virtual environments +.venv/ +venv/ + +# Python caches +__pycache__/ +*.py[cod] +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ + +# Coverage +htmlcov/ +coverage.xml +.coverage +.coverage.* + +# Databases +*.db +*.sqlite3 + +# Environment files +.env +.env.* + +# IDE +.idea/ +.vscode/ + +# Claude Code config +.claude/ + +# Logs +logs/ +*.log + +# OS files +Thumbs.db +.DS_Store + +# uv +.python-version diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 81% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md index 035423c3b9..6421099fe6 100644 --- a/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,7 +8,7 @@ cd ai-company uv sync ``` -For the full setup walkthrough (prerequisites, IDE config, etc.), see [docs/getting_started.md](docs/getting_started.md). +For the full setup walkthrough (prerequisites, IDE config, etc.), see [docs/getting_started.md](../docs/getting_started.md). ## Branching Strategy @@ -107,11 +107,11 @@ To run all hooks manually: uv run pre-commit run --all-files ``` -See [docs/getting_started.md](docs/getting_started.md) for the full list of hooks and what each one does. +See [docs/getting_started.md](../docs/getting_started.md) for the full list of hooks and what each one does. ## Code Style -Code conventions (type hints, docstrings, immutability, line length, etc.) are documented in [CLAUDE.md](CLAUDE.md). Both human contributors and AI assistants follow the same rules. +Code conventions (type hints, docstrings, immutability, line length, etc.) are documented in [CLAUDE.md](../CLAUDE.md). Both human contributors and AI assistants follow the same rules. ## Pull Request Process @@ -128,15 +128,18 @@ Code conventions (type hints, docstrings, immutability, line length, etc.) are d ```text src/ai_company/ # Main package api/ budget/ cli/ communication/ config/ core/ - engine/ memory/ providers/ security/ templates/ tools/ + engine/ hr/ memory/ observability/ persistence/ + providers/ security/ templates/ tools/ tests/ unit/ integration/ e2e/ docs/ # Developer documentation +docker/ # Dockerfiles, Compose, .env.example +web/ # Web UI scaffold (nginx + placeholder) .github/ # CI, dependabot, actions ``` -See [docs/getting_started.md](docs/getting_started.md) for descriptions of each sub-package. +See [docs/getting_started.md](../docs/getting_started.md) for descriptions of each sub-package. ## License -This project is licensed under [BUSL-1.1](LICENSE). By contributing, you agree that your contributions will be licensed under the same terms. +This project is licensed under [BUSL-1.1](../LICENSE). By contributing, you agree that your contributions will be licensed under the same terms. diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 100% rename from SECURITY.md rename to .github/SECURITY.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 58ff8625cc..da3b75f714 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -34,3 +34,31 @@ updates: - Aureliolo labels: - type:ci + + - package-ecosystem: docker + directory: /docker/backend + schedule: + interval: daily + time: "06:00" + timezone: Etc/UTC + commit-message: + prefix: "chore" + open-pull-requests-limit: 5 + reviewers: + - Aureliolo + labels: + - type:chore + + - package-ecosystem: docker + directory: /docker/web + schedule: + interval: daily + time: "06:00" + timezone: Etc/UTC + commit-message: + prefix: "chore" + open-pull-requests-limit: 5 + reviewers: + - Aureliolo + labels: + - type:chore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c61fd31ccf..26f05ac152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - uses: ./.github/actions/setup-python-uv @@ -37,7 +37,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - uses: ./.github/actions/setup-python-uv @@ -54,7 +54,7 @@ jobs: matrix: python-version: ["3.14"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - uses: ./.github/actions/setup-python-uv @@ -73,6 +73,8 @@ jobs: --cov-fail-under=80 \ --junitxml=junit.xml + # Codecov upload is best-effort — CI should not fail on Codecov outages + # or missing tokens. Coverage enforcement is handled by --cov-fail-under. - name: Upload coverage to Codecov if: always() uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 @@ -83,7 +85,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: test-results-${{ matrix.python-version }} path: junit.xml diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6dfc60af0a..d653e2c3e2 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,12 +14,12 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4 with: fail-on-severity: high # LicenseRef-scancode-free-unknown: aiosqlite 0.21.0 — MIT per classifiers, scancode misdetects diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..4d3c001743 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,241 @@ +name: Docker + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Extract app version from pyproject.toml (single source of truth) + version: + name: Extract Version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + app_version: ${{ steps.version.outputs.app_version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Extract version from pyproject.toml + id: version + run: | + VERSION=$(python3 -c " + import tomllib + import sys + try: + with open('pyproject.toml', 'rb') as f: + data = tomllib.load(f) + print(data['tool']['commitizen']['version']) + except (KeyError, FileNotFoundError) as e: + print(f'::error::Failed to extract version: {e}', file=sys.stderr) + sys.exit(1) + ") + echo "app_version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "App version: ${VERSION}" + + build-backend: + name: Build Backend + needs: [version] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ghcr.io/aureliolo/ai-company-backend + tags: | + type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,prefix=sha- + + # Build locally first, scan, then push only if scans pass + - name: Build image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: docker/backend/Dockerfile + push: false + load: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + # SHA tag always exists (version/semver tags only on v* tags) + - name: Compute scan image ref + id: scan-ref + run: echo "ref=ghcr.io/aureliolo/ai-company-backend:sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - name: Trivy scan + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ steps.scan-ref.outputs.ref }} + format: table + exit-code: "1" + severity: CRITICAL,HIGH + + - name: Grype scan + uses: anchore/scan-action@1638637db639e0ade3258b51db49a9a137574c3e # v6 + with: + image: ${{ steps.scan-ref.outputs.ref }} + fail-build: true + severity-cutoff: high + + # Push only after both scans pass — prevents publishing vulnerable images. + # NOTE: This is a separate build invocation from the scan step. GHA cache + # ensures deterministic layer content, but the manifest digest differs due + # to SBOM + provenance attestation layers added only on push. + - name: Push image + id: push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: docker/backend/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + platforms: linux/amd64 + sbom: true + provenance: true + + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Sign image + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + if [ -z "$DIGEST" ]; then + echo "::error::Push step did not produce a digest — cannot sign image" + exit 1 + fi + cosign sign --yes ghcr.io/aureliolo/ai-company-backend@${DIGEST} + + build-web: + name: Build Web + needs: [version] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ghcr.io/aureliolo/ai-company-web + tags: | + type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,prefix=sha- + + # Build locally first, scan, then push only if scans pass + - name: Build image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: docker/web/Dockerfile + push: false + load: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + # SHA tag always exists (version/semver tags only on v* tags) + - name: Compute scan image ref + id: scan-ref + run: echo "ref=ghcr.io/aureliolo/ai-company-web:sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - name: Trivy scan + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ steps.scan-ref.outputs.ref }} + format: table + exit-code: "1" + severity: CRITICAL,HIGH + + - name: Grype scan + uses: anchore/scan-action@1638637db639e0ade3258b51db49a9a137574c3e # v6 + with: + image: ${{ steps.scan-ref.outputs.ref }} + fail-build: true + severity-cutoff: high + + # Push only after both scans pass — prevents publishing vulnerable images. + # NOTE: Separate build invocation; GHA cache ensures deterministic layers, + # but manifest digest differs due to SBOM + provenance attestation layers. + - name: Push image + id: push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: docker/web/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + platforms: linux/amd64 + sbom: true + provenance: true + + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Sign image + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + if [ -z "$DIGEST" ]; then + echo "::error::Push step did not produce a digest — cannot sign image" + exit 1 + fi + cosign sign --yes ghcr.io/aureliolo/ai-company-web@${DIGEST} diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 72d9a47604..57874f131a 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -19,7 +19,7 @@ jobs: env: GITLEAKS_VERSION: "8.24.3" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 persist-credentials: false diff --git a/.gitignore b/.gitignore index 05c520e261..e7d50f28ab 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,9 @@ logs/ Thumbs.db .DS_Store +# Web UI +web/node_modules/ +web/dist/ + # uv .python-version diff --git a/CLAUDE.md b/CLAUDE.md index 324b936139..6cff64f801 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,28 @@ uv run pytest tests/ -n auto --cov=ai_company --cov-fail-under=80 # full suite uv run pre-commit run --all-files # all pre-commit hooks ``` +## Docker + +```bash +# Build and run (from repo root) +cp docker/.env.example docker/.env # configure env vars +docker compose -f docker/compose.yml build +docker compose -f docker/compose.yml up -d +docker compose -f docker/compose.yml down + +# Verify +curl http://localhost:8000/api/v1/health # backend (direct) +curl http://localhost:3000/api/v1/health # backend (via web proxy) +``` + +- **Backend**: 3-stage build (builder → setup → distroless runtime), Chainguard Python, non-root (UID 65532), CIS-hardened +- **Web**: `nginxinc/nginx-unprivileged`, SPA routing, API/WebSocket proxy to backend +- **Config**: all Docker files in `docker/` — Dockerfiles, compose, `.env.example` +- **CI**: `.github/workflows/docker.yml` — build → scan → push to GHCR + cosign sign (images only pushed after Trivy/Grype scans pass) +- **Build context**: single root `.dockerignore` (both images build with `context: .`) +- **Tags**: CI tags images with version from `pyproject.toml` (`[tool.commitizen].version`), semver, and SHA +- **Dependabot**: auto-updates Docker image digests and versions daily + ## Package Structure ```text @@ -137,8 +159,9 @@ src/ai_company/ ## CI - **Jobs**: lint (ruff) + type-check (mypy src/ tests/) + test (pytest + coverage) run in parallel → ci-pass (gate) +- **Docker**: `.github/workflows/docker.yml` — builds backend + web images, pushes to GHCR, runs Trivy + Grype scans, signs with cosign. Triggers on push to main and version tags (`v*`). - **Matrix**: Python 3.14 -- **Dependabot**: daily uv + github-actions updates, grouped minor/patch, no auto-merge +- **Dependabot**: daily uv + github-actions + docker updates, grouped minor/patch, no auto-merge - **Secret scanning**: gitleaks workflow on push/PR + weekly schedule - **Dependency review**: license allow-list (permissive only), PR comment summaries - **Coverage**: Codecov integration (replaces artifact-only uploads) diff --git a/DESIGN_SPEC.md b/DESIGN_SPEC.md index 06f89da0c7..f9b4cfebd2 100644 --- a/DESIGN_SPEC.md +++ b/DESIGN_SPEC.md @@ -2739,7 +2739,7 @@ Circular inheritance is detected via chain tracking and raises `TemplateInherita | **Database** | SQLite (aiosqlite) → PostgreSQL / MariaDB | Pluggable `PersistenceBackend` protocol (§7.6). SQLite ships first via aiosqlite async driver. PostgreSQL, MariaDB as future backends — swap via config, no app code changes | | **Web UI** | Vue 3 + Vite | Modern, fast, good ecosystem. Simpler than React for dashboards | | **Real-time** | WebSocket (Litestar channels plugin) | Built-in pub/sub broadcasting, per-channel history, backpressure management. Real-time agent activity, task updates, chat feed | -| **Containerization** | Docker + Docker Compose | Isolated code execution, reproducible environments | +| **Containerization** | Docker + Docker Compose | Production container packaging: Chainguard Python distroless runtime (non-root UID 65532, CIS Docker Benchmark v1.6.0 hardened, minimal attack surface, continuously scanned in CI), `nginxinc/nginx-unprivileged` web tier, GHCR registry, cosign image signing, Trivy + Grype vulnerability scanning, SBOM + SLSA provenance. Also used for isolated code execution sandboxing | | **Docker API** | aiodocker | Async-native Docker API client for `DockerSandbox` backend | | **Tool Integration** | MCP SDK (`mcp`) | Industry standard for LLM-to-tool integration | | **Agent Comms** | A2A Protocol compatible | Future-proof inter-agent communication | @@ -3206,6 +3206,31 @@ ai-company/ │ │ ├── ADR-001-memory-layer.md │ │ └── ADR-002-design-decisions-batch-1.md │ └── getting_started.md +├── docker/ +│ ├── backend/ +│ │ └── Dockerfile # 3-stage: python:3.14-slim → chainguard/python-dev → chainguard/python (distroless) +│ ├── sandbox/ +│ │ └── Dockerfile # Code execution sandbox (Python + Node.js, non-root) +│ ├── web/ +│ │ └── Dockerfile # nginxinc/nginx-unprivileged (non-root) +│ ├── compose.yml # CIS-hardened orchestration +│ ├── compose.override.yml # Local dev overrides (debug logging) +│ └── .env.example # Environment variable reference +├── web/ +│ ├── index.html # Placeholder dashboard with health check +│ └── nginx.conf # SPA routing + API/WebSocket proxy +├── .github/ +│ ├── workflows/ +│ │ ├── ci.yml # Lint + type-check + test (parallel) +│ │ ├── docker.yml # Build → scan → push → sign (GHCR) +│ │ ├── dependency-review.yml # License allow-list on PRs +│ │ └── secret-scan.yml # Gitleaks on push/PR + weekly +│ ├── actions/ +│ │ └── setup-python-uv/ # Composite action: Python + uv install +│ ├── dependabot.yml # uv + github-actions + docker updates +│ ├── CONTRIBUTING.md +│ └── SECURITY.md +├── .dockerignore # Consolidated Docker build context exclusions ├── DESIGN_SPEC.md # This document ├── README.md ├── pyproject.toml @@ -3226,6 +3251,7 @@ ai-company/ | Web UI | Vue 3 | React, Svelte, HTMX | Simpler than React for dashboards | | Persistence | Pluggable protocol + repository protocols | ORM (SQLAlchemy), raw SQL, hybrid | Same frozen Pydantic models in and out (no DTOs), async throughout, backend-swappable via config. Repository protocols decouple app code from storage engine. See §7.6 | | Sandboxing | Layered: subprocess + Docker | Docker-only, subprocess-only, WASM | Risk-proportionate: fast subprocess for file/git, Docker isolation for code execution. Pluggable `SandboxBackend` protocol enables K8s migration later | +| Container Packaging | Chainguard distroless + GHCR | Alpine, Debian-slim, scratch, Docker Hub | Chainguard Python distroless: no shell/package-manager (minimal attack surface), non-root by default, continuously scanned in CI. GHCR over Docker Hub: tighter GitHub integration, no rate limits for public images, native OIDC token auth. cosign keyless signing for supply-chain integrity. Trivy + Grype dual scanning for comprehensive CVE coverage | ### 15.5 Engineering Conventions diff --git a/README.md b/README.md index 6ed91b5422..b94549fdcd 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,20 @@ AI Company lets you spin up a virtual organization staffed entirely by AI agents - **MCP** for tool integration - **Vue 3** for web dashboard (planned) - **SQLite** (aiosqlite) → PostgreSQL for operational data persistence +- **Docker** with Chainguard Python distroless runtime (CIS-hardened, non-root) +- **nginx** (unprivileged) for web UI reverse proxy ## System Requirements - **Python 3.14+** - **uv** — package manager ([install](https://docs.astral.sh/uv/getting-started/installation/)) - **Git 2.x+** — required at runtime for built-in git tools (subprocess-based, not a Python binding) -- **Docker** (optional) — required for code execution sandbox and Docker-backed tool isolation. Install [Docker Desktop](https://docs.docker.com/get-docker/) or Docker Engine. File system and git tools work without Docker via subprocess isolation. +- **Docker** (optional) — required for code execution sandbox, Docker-backed tool isolation, and running the full stack via Docker Compose. Install [Docker Desktop](https://docs.docker.com/get-docker/) or Docker Engine. File system and git tools work without Docker via subprocess isolation. ## Getting Started +### Development (local Python) + ```bash git clone https://github.com/Aureliolo/ai-company.git cd ai-company @@ -72,10 +76,29 @@ uv sync See [docs/getting_started.md](docs/getting_started.md) for prerequisites, IDE setup, and the full walkthrough. +### Docker Compose (full stack) + +```bash +cp docker/.env.example docker/.env # configure env vars (set LLM_API_KEY) +docker compose -f docker/compose.yml build +docker compose -f docker/compose.yml up -d +``` + +Services (default ports, configurable via `BACKEND_PORT` / `WEB_PORT` in `docker/.env`): +- **Backend API**: `http://localhost:8000` — Litestar REST + WebSocket +- **Web Dashboard**: `http://localhost:3000` — placeholder (proxies `/api/` and `/ws` to backend) + +```bash +curl http://localhost:8000/api/v1/health # health check (default port) +docker compose -f docker/compose.yml down # stop services +``` + +See [docker/](docker/) for Dockerfiles, compose config, and environment variable reference. + ## Documentation - [Getting Started](docs/getting_started.md) - Setup and installation guide -- [Contributing](CONTRIBUTING.md) - Branch, commit, and PR workflow +- [Contributing](.github/CONTRIBUTING.md) - Branch, commit, and PR workflow - [CLAUDE.md](CLAUDE.md) - Code conventions and AI assistant reference - [Design Specification](DESIGN_SPEC.md) - Full high-level design diff --git a/config/.gitkeep b/config/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000000..eae54725a1 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,31 @@ +# ============================================================================= +# AI Company — Environment Variables +# ============================================================================= +# Copy this file to .env and fill in values (from repo root): +# cp docker/.env.example docker/.env +# ============================================================================= + +# --- LLM Provider ----------------------------------------------------------- +# API key for the LLM provider (required for agent execution) +LLM_API_KEY= + +# --- Application ------------------------------------------------------------- +# Log level: debug, info, warning, error, critical +AI_COMPANY_LOG_LEVEL=info + +# SQLite database path (inside container: /data/ai-company.db) +AI_COMPANY_DB_PATH=/data/ai-company.db + +# Agent memory storage directory (inside container: /data/memory) +AI_COMPANY_MEMORY_DIR=/data/memory + +# --- Container Networking ---------------------------------------------------- +# Host port for the backend API +BACKEND_PORT=8000 + +# Host port for the web dashboard +WEB_PORT=3000 + +# --- Docker Sandbox ---------------------------------------------------------- +# Docker socket for agent code execution sandbox (optional) +# DOCKER_HOST=unix:///var/run/docker.sock diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile new file mode 100644 index 0000000000..5550816365 --- /dev/null +++ b/docker/backend/Dockerfile @@ -0,0 +1,107 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================= +# AI Company Backend — Multi-stage, CIS-hardened container +# ============================================================================= +# Three stages: builder → setup → runtime (distroless). +# Chainguard Python distroless: minimal attack surface (no shell, no package +# manager), non-root by default (UID 65532), continuously rebuilt by Chainguard. +# CIS Docker Benchmark v1.6.0 compliant. +# All base images pinned by version or digest (Dependabot auto-updates). +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1 — Builder (ephemeral: compiles deps + project wheel) +# --------------------------------------------------------------------------- +FROM python:3.14.3-slim@sha256:6a27522252aef8432841f224d9baaa6e9fce07b07584154fa0b9a96603af7456 AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.10.9@sha256:10902f58a1606787602f303954cea099626a4adb02acbac4c69920fe9d278f82 /uv /uvx /bin/ + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +WORKDIR /app + +# Install dependencies first (layer cache — only invalidated on lock change) +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --no-install-project + +# Copy source and install project. +# touch README.md: hatchling requires README.md for package metadata but +# .dockerignore excludes it — create an empty file to satisfy the build. +RUN touch README.md +COPY src/ src/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +# Strip __pycache__ from source (bytecode already compiled by uv into .venv) +RUN find /app/src -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +# --------------------------------------------------------------------------- +# Stage 2 — Setup (ephemeral: fix symlinks + create dirs, needs shell) +# --------------------------------------------------------------------------- +# Chainguard Python -dev variant (has shell for RUN commands). +# Pinned by digest — free tier only exposes latest/latest-dev tags. +# To update: docker buildx imagetools inspect cgr.dev/chainguard/python:latest-dev +FROM cgr.dev/chainguard/python@sha256:4decc0a7de3d586bbb38307244f32a10f50c96627714ac2785a347a8b63ceeb4 AS setup + +USER root +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/src /app/src + +# Fix venv Python symlink: builder points to /usr/local/bin/python3, +# but Chainguard Python is at /usr/bin/python +RUN rm -f /app/.venv/bin/python && ln -s /usr/bin/python /app/.venv/bin/python + +# Create data directory for persistent volumes +RUN mkdir -p /data && chown 65532:65532 /data + +# Set final ownership +RUN chown -R 65532:65532 /app + +# --------------------------------------------------------------------------- +# Stage 3 — Runtime (distroless: no shell, no uv, no package manager) +# --------------------------------------------------------------------------- +# Chainguard Python distroless — minimal attack surface, continuously scanned. +# Pinned by digest — free tier only exposes latest/latest-dev tags. +# To update: docker buildx imagetools inspect cgr.dev/chainguard/python:latest +FROM cgr.dev/chainguard/python@sha256:308d2fda1d9ad9620454de97cb84fa407bd20e24dd66902909f4b3b44b310836 AS runtime + +# OCI image labels (https://github.com/opencontainers/image-spec/blob/main/annotations.md) +LABEL org.opencontainers.image.title="ai-company-backend" \ + org.opencontainers.image.description="AI Company orchestration backend" \ + org.opencontainers.image.url="https://github.com/Aureliolo/ai-company" \ + org.opencontainers.image.source="https://github.com/Aureliolo/ai-company" \ + org.opencontainers.image.licenses="BUSL-1.1" \ + org.opencontainers.image.vendor="Aureliolo" + +WORKDIR /app + +# Copy prepared venv, source, and data dir from setup stage +COPY --from=setup --chown=65532:65532 /app/.venv /app/.venv +COPY --from=setup --chown=65532:65532 /app/src /app/src +COPY --from=setup --chown=65532:65532 /data /data + +# Runtime environment +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + AI_COMPANY_HOST="0.0.0.0" \ + AI_COMPANY_PORT="8000" + +USER 65532 + +EXPOSE 8000 + +# Healthcheck — exec form (no shell needed, works with distroless) +HEALTHCHECK --interval=10s --timeout=5s --retries=3 --start-period=15s \ + CMD ["/usr/bin/python", "-c", "import json,urllib.request,sys; r=urllib.request.urlopen('http://localhost:8000/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='healthy' else 1)"] + +# Reset Chainguard base entrypoint (/usr/bin/python) so CMD runs uvicorn +# directly instead of being passed as Python arguments. +ENTRYPOINT [] +CMD ["/app/.venv/bin/uvicorn", "ai_company.api.app:create_app", "--factory", \ + "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/compose.override.yml b/docker/compose.override.yml new file mode 100644 index 0000000000..89d555af99 --- /dev/null +++ b/docker/compose.override.yml @@ -0,0 +1,12 @@ +# Local development overrides. +# NOT auto-merged when using -f: include explicitly with: +# docker compose -f docker/compose.yml -f docker/compose.override.yml up +services: + backend: + environment: + AI_COMPANY_LOG_LEVEL: "debug" + # Docker socket for agent code execution sandbox. + # WARNING: Mounting the Docker socket gives the container full control + # over the Docker daemon. Only enable in trusted development environments. + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000000..2064abb891 --- /dev/null +++ b/docker/compose.yml @@ -0,0 +1,47 @@ +services: + backend: + build: + context: .. + dockerfile: docker/backend/Dockerfile + ports: + - "${BACKEND_PORT:-8000}:8000" + volumes: + - ai-company-data:/data + env_file: .env + environment: + AI_COMPANY_HOST: "0.0.0.0" + AI_COMPANY_PORT: "8000" + AI_COMPANY_DB_PATH: "/data/ai-company.db" + AI_COMPANY_MEMORY_DIR: "/data/memory" + # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25) + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=64m + restart: unless-stopped + + web: + build: + context: .. + dockerfile: docker/web/Dockerfile + ports: + - "${WEB_PORT:-3000}:8080" + depends_on: + backend: + condition: service_healthy + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=16m + - /var/cache/nginx:size=32m + - /var/run:size=1m + restart: unless-stopped + +volumes: + ai-company-data: diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile new file mode 100644 index 0000000000..f095eacf05 --- /dev/null +++ b/docker/web/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================= +# AI Company Web — Non-root nginx container (CIS hardening applied via compose.yml) +# ============================================================================= + +FROM nginxinc/nginx-unprivileged:1.29.5-alpine@sha256:aec540f08f99df3c830549d5dd7bfaf63e01cbbb499e37400c5af9f8e8554e9f + +LABEL org.opencontainers.image.title="ai-company-web" \ + org.opencontainers.image.description="AI Company web dashboard" \ + org.opencontainers.image.url="https://github.com/Aureliolo/ai-company" \ + org.opencontainers.image.source="https://github.com/Aureliolo/ai-company" \ + org.opencontainers.image.licenses="BUSL-1.1" \ + org.opencontainers.image.vendor="Aureliolo" + +COPY web/nginx.conf /etc/nginx/conf.d/default.conf +COPY web/index.html web/style.css web/app.js /usr/share/nginx/html/ + +EXPOSE 8080 + +HEALTHCHECK --interval=10s --timeout=3s --retries=3 --start-period=5s \ + CMD ["wget", "--spider", "--quiet", "http://localhost:8080/"] diff --git a/docs/getting_started.md b/docs/getting_started.md index b1302272fb..59b8dcebc8 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -82,7 +82,7 @@ uv run ruff check src/ tests/ uv run ruff format --check src/ tests/ # Type check -uv run mypy src/ +uv run mypy src/ tests/ # Tests with coverage uv run pytest tests/ -n auto --cov=ai_company --cov-fail-under=80 @@ -100,9 +100,9 @@ uv run ruff format src/ tests/ ```text ai-company/ src/ai_company/ # Main package (src layout) - api/ # FastAPI REST + WebSocket routes + api/ # Litestar REST + WebSocket routes budget/ # Cost tracking and spending controls - cli/ # Typer CLI commands + cli/ # CLI interface (future) communication/ # Inter-agent message bus config/ # YAML config loading and validation core/ # Shared domain models @@ -112,16 +112,20 @@ ai-company/ security/ # SecOps, approval gates, sandboxing templates/ # Pre-built company templates tools/ # Tool registry, MCP integration + hr/ # HR engine (hiring, firing, performance) + observability/ # Structured logging, correlation tracking + persistence/ # Pluggable persistence backends tests/ unit/ # Fast, isolated tests (no I/O) integration/ # Tests with I/O, databases, APIs e2e/ # Full system tests docs/ # Developer documentation + docker/ # Dockerfiles, Compose, .env.example + web/ # Web UI scaffold (nginx + placeholder) .github/ # CI workflows, dependabot, actions pyproject.toml # Project config (deps, tools, linters) DESIGN_SPEC.md # Full high-level design specification CLAUDE.md # AI assistant quick reference - CONTRIBUTING.md # Contributor workflow guide ``` ## IDE Setup @@ -146,6 +150,6 @@ VS Code should auto-detect the `.venv` directory. If not, use **Python: Select I ## Next Steps -- [CONTRIBUTING.md](../CONTRIBUTING.md) — branch, commit, and PR workflow +- [CONTRIBUTING.md](../.github/CONTRIBUTING.md) — branch, commit, and PR workflow - [CLAUDE.md](../CLAUDE.md) — code conventions and quick command reference - [DESIGN_SPEC.md](../DESIGN_SPEC.md) — full high-level design specification diff --git a/src/ai_company/api/app.py b/src/ai_company/api/app.py index 40cc60b772..793b5832fb 100644 --- a/src/ai_company/api/app.py +++ b/src/ai_company/api/app.py @@ -6,7 +6,6 @@ """ import time -from collections.abc import Callable from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -25,7 +24,7 @@ from ai_company.api.controllers import ALL_CONTROLLERS from ai_company.api.controllers.ws import ws_handler from ai_company.api.exception_handlers import EXCEPTION_HANDLERS -from ai_company.api.middleware import RequestLoggingMiddleware +from ai_company.api.middleware import CSPMiddleware, RequestLoggingMiddleware from ai_company.api.state import AppState from ai_company.api.ws_models import WsEvent, WsEventType from ai_company.budget.tracker import CostTracker # noqa: TC001 @@ -44,6 +43,7 @@ from collections.abc import Awaitable, Callable, Sequence from litestar.channels import ChannelsPlugin + from litestar.types import Middleware from ai_company.api.config import ApiConfig @@ -335,12 +335,8 @@ def create_app( name="Permissions-Policy", value="geolocation=(), camera=(), microphone=()", ), - ResponseHeader( - name="Content-Security-Policy", - value="default-src 'self'; script-src 'self'", - ), ], - middleware=middleware, # type: ignore[arg-type] + middleware=middleware, plugins=plugins, exception_handlers=EXCEPTION_HANDLERS, # type: ignore[arg-type] openapi_config=OpenAPIConfig( @@ -366,11 +362,11 @@ def _build_bridge( return MessageBusBridge(message_bus, channels_plugin) -def _build_middleware(api_config: ApiConfig) -> list[object]: +def _build_middleware(api_config: ApiConfig) -> list[Middleware]: """Build the middleware stack from configuration.""" rl = api_config.rate_limit rate_limit = LitestarRateLimitConfig( rate_limit=(rl.time_unit, rl.max_requests), # type: ignore[arg-type] exclude=list(rl.exclude_paths), ) - return [RequestLoggingMiddleware, rate_limit.middleware] + return [CSPMiddleware, RequestLoggingMiddleware, rate_limit.middleware] diff --git a/src/ai_company/api/middleware.py b/src/ai_company/api/middleware.py index 733f2636ce..2aff710575 100644 --- a/src/ai_company/api/middleware.py +++ b/src/ai_company/api/middleware.py @@ -1,11 +1,11 @@ -"""Request logging middleware. +"""Request middleware. -Logs every request start and completion with method, path, status -code, and duration using structured logging. +Provides ASGI middleware for request logging and path-aware +Content-Security-Policy headers. """ import time -from typing import Any +from typing import Any, Final from litestar import Request from litestar.enums import ScopeType @@ -19,6 +19,64 @@ logger = get_logger(__name__) +# Strict CSP for API routes — no inline scripts, self-origin only. +_API_CSP: Final[str] = "default-src 'self'; script-src 'self'" + +# Relaxed CSP for /docs/ — Scalar UI loads resources from external origins. +# cdn.jsdelivr.net: JS bundle, CSS, fonts, source maps +# fonts.scalar.com: Scalar-hosted font files +# proxy.scalar.com: API proxy and registry features +_DOCS_CSP: Final[str] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data: https://cdn.jsdelivr.net; " + "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com; " + "connect-src 'self' https://cdn.jsdelivr.net https://proxy.scalar.com" +) + + +class CSPMiddleware: + """ASGI middleware that applies path-aware Content-Security-Policy. + + API routes get a strict policy (self-origin only). The ``/docs/`` + path gets a relaxed policy that allows Scalar UI resources from + ``cdn.jsdelivr.net``, ``fonts.scalar.com``, and + ``proxy.scalar.com``. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + """Inject the appropriate CSP header based on request path.""" + if scope["type"] != ScopeType.HTTP: + await self.app(scope, receive, send) + return + + path: str = scope.get("path", "") + is_docs = path == "/docs" or path.startswith("/docs/") + csp_value = _DOCS_CSP if is_docs else _API_CSP + + async def inject_csp(message: Any) -> None: + if ( + isinstance(message, dict) + and message.get("type") == "http.response.start" + ): + headers = list(message.get("headers", [])) + headers.append( + (b"content-security-policy", csp_value.encode()), + ) + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, inject_csp) + class RequestLoggingMiddleware: """ASGI middleware that logs request start and completion. diff --git a/tests/unit/api/test_middleware.py b/tests/unit/api/test_middleware.py index a9d41f5bb5..418a090f88 100644 --- a/tests/unit/api/test_middleware.py +++ b/tests/unit/api/test_middleware.py @@ -1,10 +1,105 @@ -"""Tests for request logging middleware.""" +"""Tests for request middleware (CSP and logging).""" from typing import Any import pytest from litestar.testing import TestClient # noqa: TC002 +from ai_company.api.middleware import ( + _API_CSP, + _DOCS_CSP, + CSPMiddleware, +) + + +async def _fake_app(scope: Any, receive: Any, send: Any) -> None: + """Minimal ASGI app that returns a 200 response.""" + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [], + } + ) + await send({"type": "http.response.body", "body": b""}) + + +def _make_scope(path: str) -> dict[str, Any]: + """Build a minimal ASGI HTTP scope for testing.""" + return { + "type": "http", + "path": path, + "method": "GET", + "headers": [], + "query_string": b"", + "root_path": "", + "scheme": "http", + "server": ("localhost", 8000), + } + + +@pytest.mark.unit +class TestCSPMiddleware: + """Tests for path-aware Content-Security-Policy middleware.""" + + def test_api_route_gets_strict_csp(self, test_client: TestClient[Any]) -> None: + response = test_client.get("/api/v1/health") + csp = response.headers.get("content-security-policy") + assert csp == _API_CSP + + def test_docs_route_gets_relaxed_csp(self, test_client: TestClient[Any]) -> None: + response = test_client.get("/docs/api") + csp = response.headers.get("content-security-policy") + assert csp == _DOCS_CSP + + def test_docs_exact_path_gets_relaxed_csp( + self, test_client: TestClient[Any] + ) -> None: + response = test_client.get("/docs") + csp = response.headers.get("content-security-policy") + assert csp == _DOCS_CSP + + @pytest.mark.parametrize( + ("path", "expected_csp"), + [ + ("/documents", _API_CSP), + ("/docsearch", _API_CSP), + ("/docs/api", _DOCS_CSP), + ("/docs/openapi.json", _DOCS_CSP), + ], + ids=[ + "documents-strict", + "docsearch-strict", + "docs-subpath-relaxed", + "docs-openapi-relaxed", + ], + ) + async def test_csp_path_boundary(self, path: str, expected_csp: str) -> None: + """Verify CSP assignment for boundary paths via direct ASGI invocation.""" + middleware = CSPMiddleware(_fake_app) + captured: list[dict[str, Any]] = [] + + async def capture_send(message: Any) -> None: + captured.append(message) + + await middleware(_make_scope(path), None, capture_send) # type: ignore[arg-type] + + start_msg = captured[0] + headers = dict(start_msg["headers"]) + assert headers[b"content-security-policy"] == expected_csp.encode() + + async def test_non_http_scope_passes_through(self) -> None: + """Non-HTTP scopes should not get CSP headers.""" + called = False + + async def passthrough_app(scope: Any, receive: Any, send: Any) -> None: + nonlocal called + called = True + + middleware = CSPMiddleware(passthrough_app) + await middleware({"type": "lifespan"}, None, None) # type: ignore[arg-type] + assert called + @pytest.mark.unit class TestRequestLoggingMiddleware: diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000000..5540241165 --- /dev/null +++ b/web/app.js @@ -0,0 +1,29 @@ +(function () { + var el = document.getElementById("status"); + var text = document.getElementById("status-text"); + + function check() { + fetch("/api/v1/health") + .then(function (r) { + if (!r.ok) { throw new Error("HTTP " + r.status); } + return r.json(); + }) + .then(function (data) { + var s = data.data && data.data.status; + if (s === "healthy") { + el.className = "status status-connected"; + text.textContent = "Backend connected (v" + (data.data && data.data.version || "?") + ")"; + } else { + el.className = "status status-disconnected"; + text.textContent = "Backend unhealthy (" + (s || "unknown") + ")"; + } + }) + .catch(function () { + el.className = "status status-disconnected"; + text.textContent = "Backend unreachable"; + }); + } + + check(); + setInterval(check, 15000); +})(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000..23323236d6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + AI Company + + + +
+

AI Company

+

Dashboard — Coming Soon

+
+ + Checking backend... +
+
+ + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000000..9765f86634 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,53 @@ +server { + listen 8080; + server_name _; + server_tokens off; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; + gzip_min_length 256; + + # Security headers + # NOTE: nginx does not inherit add_header directives from the server block + # into location blocks that define their own add_header. If you add headers + # to a location block, you must repeat these security headers there too. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'" always; + + # SPA routing — try static files, fall back to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy to backend service + location /api/ { + proxy_pass http://backend:8000; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # WebSocket proxy + location /ws { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + } +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000000..6e005b11e8 --- /dev/null +++ b/web/style.css @@ -0,0 +1,50 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + background: #0f172a; + color: #e2e8f0; + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} +.container { + text-align: center; + max-width: 480px; + padding: 2rem; +} +h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + background: linear-gradient(135deg, #60a5fa, #a78bfa); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} +.subtitle { + font-size: 1.1rem; + color: #94a3b8; + margin-bottom: 2rem; +} +.status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} +.status-checking { background: #1e293b; color: #94a3b8; } +.status-checking .status-dot { background: #94a3b8; } +.status-connected { background: #064e3b; color: #6ee7b7; } +.status-connected .status-dot { background: #34d399; } +.status-disconnected { background: #450a0a; color: #fca5a5; } +.status-disconnected .status-dot { background: #f87171; }