refactor(env): unify Docker env vars — canonical AUTH_SECRET_KEY, remove JWT_SECRET fallback

Canonical variables:
  - AUTH_SECRET_KEY — JWT signing key for backend + agent (was split across
    AUTH_SECRET_KEY / JWT_SECRET)
  - SERVICE_JWT — agent→backend service token
  - No JWT_SECRET fallback: decoder fails with migration guidance if only
    JWT_SECRET is set

Compose files unified:
  - docker-compose.yml, docker-compose.enterprise-clean.yml,
    docker-compose.e2e.yml — all use AUTH_SECRET_KEY
  - build.sh generated compose now passes AUTH_SECRET_KEY + ENCRYPTION_KEY
    to backend

Env examples unified and completed:
  - .env.example — comprehensive template, all compose vars
  - .env.enterprise-clean.example — production template
  - backend/.env.example — backend-only run
  - docker/.env.agent.example — agent-only run
  - NEW: .env.current.example, .env.master.example,
    .env.e2e.example, frontend/.env.example

Tests aligned:
  - conftest sets AUTH_SECRET_KEY (canonical value matched across test files)
  - test mocks use canonical name
  - 1176 passed, 0 failed
This commit is contained in:
2026-07-06 14:24:17 +03:00
parent a5b7adb61c
commit 48e3ff4503
15 changed files with 586 additions and 126 deletions

View File

@@ -7,7 +7,7 @@
# #endregion env.enterprise-clean
# ======================================================================
# PostgreSQL (внешний, корпоративный)
# PostgreSQL (внешний, корпоративный) — ОБЯЗАТЕЛЬНО
# ======================================================================
POSTGRES_HOST=postgres.company.local
POSTGRES_PORT=5432
@@ -21,6 +21,22 @@ POSTGRES_PASSWORD=change-me
BACKEND_HOST_PORT=8001
FRONTEND_HOST_PORT=8000
FRONTEND_SSL_PORT=443
AGENT_HOST_PORT=7860
# ======================================================================
# Безопасность (ОБЯЗАТЕЛЬНО)
# ======================================================================
# JWT-ключ подписи токенов — единый для backend и agent.
# Сгенерировать: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
AUTH_SECRET_KEY=change-me-to-a-random-secret-32-chars-min
# Fernet-ключ шифрования паролей подключений и API-ключей.
# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY=3YIxOr3_GFht9ZyjRQMqOdtuO1CKOj8Y9mpY89iMbZY=
# Сервисный токен для agent→backend вызовов.
# Сгенерировать: python3 -c "import secrets; print('svc-' + secrets.token_urlsafe(24))"
SERVICE_JWT=agent-service-secret
# ======================================================================
# Сертификаты (корпоративные)
@@ -29,8 +45,16 @@ CERTS_PATH=./certs
SSL_KEY_PASSPHRASE=
# ======================================================================
# LLM CA-сертификаты (скачка по URL на старте контейнера)
# LLM / AI провайдеры
# ======================================================================
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
# Агент: LLM настройки (если не подтягиваются из FastAPI /api/agent/llm-config)
LLM_API_KEY=
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
LLM_SSL_VERIFY=true
# LLM CA-сертификаты (скачка по URL на старте контейнера, через запятую)
LLM_CA_CERT_URLS=
# ======================================================================
@@ -40,13 +64,7 @@ ENABLE_BELIEF_STATE_LOGGING=true
TASK_LOG_LEVEL=INFO
# ======================================================================
# Шифрование (ОБЯЗАТЕЛЬНО)
# ======================================================================
# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY=3YIxOr3_GFht9ZyjRQMqOdtuO1CKOj8Y9mpY89iMbZY=
# ======================================================================
# Admin (первый запуск)
# Admin bootstrap (первый запуск)
# ======================================================================
INITIAL_ADMIN_CREATE=false
INITIAL_ADMIN_USERNAME=admin
@@ -54,7 +72,31 @@ INITIAL_ADMIN_PASSWORD=change-me
INITIAL_ADMIN_EMAIL=
# ======================================================================
# Features (настраивается через web UI)
# CORS / Безопасность деплоя
# ======================================================================
ALLOWED_ORIGINS=http://localhost:8000
FORCE_HTTPS=false
APP_TIMEZONE=Europe/Moscow
# ======================================================================
# Features
# ======================================================================
FEATURES__DATASET_REVIEW=true
FEATURES__HEALTH_MONITOR=true
# ======================================================================
# Агент (опциональные тонкие настройки)
# ======================================================================
# GRADIO_ALLOW_PORT_FALLBACK=true
# AGENT_ENABLE_LLM_TITLES=true
# AGENT_TITLE_GENERATION_TIMEOUT_S=0.25
# AGENT_PREFETCH_DASHBOARD_LIMIT=25
# AGENT_CONFIRM_TOOLS=false
# AGENT_INTERRUPT_BEFORE=
# ======================================================================
# ADFS SSO (опционально)
# ======================================================================
# ADFS_CLIENT_ID=
# ADFS_CLIENT_SECRET=
# ADFS_METADATA_URL=

View File

@@ -1,38 +1,87 @@
# ======================================================================
# superset-tools — Переменные окружения
# Только обязательные + docker. Остальное настраивается через web UI.
# superset-tools — локальная разработка (docker compose)
# Все переменные, используемые docker-compose.yml.
# Скопируйте в .env.current или .env.master и отредактируйте под ветку.
# ======================================================================
# --- Обязательно: секреты ---
AUTH_SECRET_KEY=change-me-to-a-random-secret # JWT-ключ подписи токенов (обязательно)
ENCRYPTION_KEY=3YIxOr3_GFht9ZyjRQMqOdtuO1CKOj8Y9mpY89iMbZY= # Fernet-ключ шифрования (сгенерируйте свой для прода)
# ── Проект ─────────────────────────────────────────────────────────────
COMPOSE_PROJECT_NAME=ss-tools-current
# --- Обязательно: базы данных ---
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools # Основная БД
AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools # БД аутентификации
TASKS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools # БД задач
# ── PostgreSQL (встроенный, docker compose db service) ──────────────────
POSTGRES_IMAGE=postgres:16-alpine
POSTGRES_HOST_PORT=5433
POSTGRES_DB=ss_tools
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
# --- ADFS SSO (опционально) ---
ADFS_CLIENT_ID= # Client ID для ADFS (оставьте пустым, если не используется)
ADFS_CLIENT_SECRET= # Client Secret для ADFS
ADFS_METADATA_URL= # URL метаданных ADFS
# ── Порты хоста ────────────────────────────────────────────────────────
BACKEND_HOST_PORT=8101
FRONTEND_HOST_PORT=8100
FRONTEND_SSL_PORT=443
AGENT_HOST_PORT=7860
# --- Администратор (первый запуск) ---
INITIAL_ADMIN_CREATE=false # true — создать администратора при старте (только для первого запуска)
INITIAL_ADMIN_USERNAME=admin # Логин администратора
INITIAL_ADMIN_PASSWORD=admin123 # Пароль (обязателен при INITIAL_ADMIN_CREATE=true)
INITIAL_ADMIN_EMAIL=admin@example.com # Email администратора (опционально)
# ── Безопасность (ОБЯЗАТЕЛЬНО) ─────────────────────────────────────────
# JWT-ключ подписи токенов — единый для backend и agent.
# Сгенерировать: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
AUTH_SECRET_KEY=change-me-to-a-random-secret-32-chars-min
# Fernet-ключ шифрования паролей подключений и API-ключей.
# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY=3YIxOr3_GFht9ZyjRQMqOdtuO1CKOj8Y9mpY89iMbZY=
# Сервисный токен для agent→backend вызовов.
# Сгенерировать: python3 -c "import secrets; print('svc-' + secrets.token_urlsafe(24))"
SERVICE_JWT=agent-service-secret
# JWT audience / issuer (опционально)
# JWT_AUDIENCE=superset-tools-api
# JWT_ISSUER=superset-tools
# --- Порты (Docker) ---
BACKEND_HOST_PORT=8001 # Внешний порт бэкенда на хосте
FRONTEND_HOST_PORT=8000 # Внешний порт фронтенда на хосте
# ── Admin bootstrap (первый запуск) ────────────────────────────────────
INITIAL_ADMIN_CREATE=true
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin
INITIAL_ADMIN_EMAIL=
# --- PostgreSQL (прямое подключение, без DATABASE_URL) ---
POSTGRES_HOST=localhost # Хост PostgreSQL
POSTGRES_PORT=5432 # Порт PostgreSQL
POSTGRES_DB=ss_tools # Имя БД
POSTGRES_USER=postgres # Пользователь
POSTGRES_PASSWORD=postgres # Пароль
# ── LLM / AI провайдеры (опционально — настраивается через Web UI) ─────
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
# Агент: LLM настройки (если не подтягиваются из FastAPI /api/agent/llm-config)
LLM_API_KEY=
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
LLM_SSL_VERIFY=true
LLM_CA_CERT_URLS=
# --- Docker Compose ---
COMPOSE_PROJECT_NAME=superset-tools # Имя Docker Compose проекта
# ── Агент (настройки) ──────────────────────────────────────────────────
GRADIO_SERVER_PORT=7860
# GRADIO_ALLOW_PORT_FALLBACK=true
# AGENT_ENABLE_LLM_TITLES=true
# AGENT_TITLE_GENERATION_TIMEOUT_S=0.25
# AGENT_PREFETCH_DASHBOARD_LIMIT=25
# AGENT_CONFIRM_TOOLS=false
# AGENT_INTERRUPT_BEFORE=
# ── Сертификаты / фронтенд ─────────────────────────────────────────────
CERTS_PATH=./certs
SSL_KEY_PASSPHRASE=
# ── Логирование ────────────────────────────────────────────────────────
ENABLE_BELIEF_STATE_LOGGING=true
TASK_LOG_LEVEL=INFO
# ── CORS / Безопасность деплоя ─────────────────────────────────────────
ALLOWED_ORIGINS=http://localhost:8100,http://127.0.0.1:8100
FORCE_HTTPS=false
APP_TIMEZONE=Europe/Moscow
# ── Features ───────────────────────────────────────────────────────────
FEATURES__DATASET_REVIEW=true
FEATURES__HEALTH_MONITOR=true
# ── ADFS SSO (опционально) ─────────────────────────────────────────────
# ADFS_CLIENT_ID=
# ADFS_CLIENT_SECRET=
# ADFS_METADATA_URL=
# ── OpenRouter (опционально) ───────────────────────────────────────────
# OPENROUTER_SITE_URL=
# OPENROUTER_APP_NAME=
# APP_BASE_URL=

View File

@@ -0,0 +1,245 @@
# Plan: unify Docker/.env variables and examples
## Current problem
В проекте сейчас есть drift между compose-файлами и env-примерами:
- `backend` использует `AUTH_SECRET_KEY` как канонический JWT signing secret (`src/core/auth/config.py`).
- `agent` исторически использовал `JWT_SECRET`, а затем получил временный fallback на `AUTH_SECRET_KEY` в `src/agent/_jwt_decoder.py`; этот fallback нужно удалить.
- `.env.example`, `.env.enterprise-clean.example`, `backend/.env.example`, `docker/.env.agent.example`, `docker-compose.yml`, `docker-compose.enterprise-clean.yml` и generated compose внутри `build.sh` описывают разные наборы переменных.
- `docker/.env.agent.example` устарел: там `AUTH_SECRET_KEY`, но compose раньше прокидывал `JWT_SECRET`; также не перечислены agent tuning переменные.
- `docker-compose.enterprise-clean.yml` и `build.sh` generated compose должны быть синхронны, иначе локальная сборка и bundle дают разное runtime-поведение.
- Локальные `.env.current` / `.env.master` содержат только порты/admin/features и не показывают auth/LLM/certs/security переменные.
## Canonical naming decision
Use `AUTH_SECRET_KEY` as the canonical token-signing secret across backend and agent.
Rationale:
- Backend already requires `AUTH_SECRET_KEY` through `AuthConfig`.
- Root/backend examples already document `AUTH_SECRET_KEY`.
- `JWT_SECRET` is ambiguous: it can mean backend auth token signing, service token signing, or agent token validation.
- No fallback: `JWT_SECRET` must be removed from Docker/runtime examples and from agent JWT decoding logic. Deployments must define `AUTH_SECRET_KEY` only.
Canonical variables:
- `AUTH_SECRET_KEY`: canonical shared user JWT signing key, backend + agent.
- `JWT_SECRET`: removed/deprecated hard error — do not use in compose, examples, or agent runtime.
- `SERVICE_JWT`: service-to-backend token for agent calls; separate from user JWT signing key.
- `ENCRYPTION_KEY`: Fernet key for encrypted connection credentials/API keys.
## Files to update
### 1. `docker-compose.yml`
Unify local dev compose with canonical variable names:
- DB service:
- Use `${POSTGRES_DB:-ss_tools}`, `${POSTGRES_USER:-postgres}`, `${POSTGRES_PASSWORD:-postgres}` instead of hardcoded values.
- Healthcheck should use the same values.
- Backend service:
- Construct `DATABASE_URL`, `TASKS_DATABASE_URL`, `AUTH_DATABASE_URL` from the same POSTGRES variables.
- Pass `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, `JWT_AUDIENCE`, `JWT_ISSUER`, `ALLOWED_ORIGINS`, `FORCE_HTTPS`, `APP_TIMEZONE` where relevant.
- Pass `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_CA_CERT_URLS`, `LLM_SSL_VERIFY` consistently.
- Agent service:
- Pass `AUTH_SECRET_KEY` only.
- Remove `JWT_SECRET` entirely; do not include compatibility aliases.
- Keep `SERVICE_JWT`, `DATABASE_URL`, `FASTAPI_URL`, `LLM_*`, `GRADIO_*`, `AGENT_*` variables.
- Frontend service:
- Add `SSL_KEY_PASSPHRASE` if local frontend can terminate SSL similarly to enterprise.
### 2. `docker-compose.enterprise-clean.yml`
Make enterprise compose match the canonical env contract:
- Backend:
- Keep external Postgres URL composition from POSTGRES variables.
- Add/pass `AUTH_SECRET_KEY` explicitly.
- Keep required `ENCRYPTION_KEY`.
- Add optional documented variables that backend reads:
- `JWT_AUDIENCE`, `JWT_ISSUER`
- `ALLOWED_ORIGINS`, `FORCE_HTTPS`
- `APP_TIMEZONE`
- `LLM_SSL_VERIFY`, `LLM_CA_CERT_URLS`
- `OPENROUTER_SITE_URL`, `OPENROUTER_APP_NAME`
- Agent:
- Use `AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?AUTH_SECRET_KEY must be set}`.
- Remove `JWT_SECRET` entirely; no fallback/alias support.
- Keep `DATABASE_URL` for LangGraph checkpoint persistence.
- Include all agent optional knobs in comments/examples, but compose can omit defaults if code defaults are sufficient.
- Frontend:
- Keep `SSL_KEY_PASSPHRASE`, `CERTS_PATH` mounts, host ports.
### 3. `docker-compose.e2e.yml`
Keep test isolation but align naming:
- Use `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, `DATABASE_URL`, `AUTH_DATABASE_URL`, `TASKS_DATABASE_URL` consistently.
- Add `SERVICE_JWT` if e2e agent/service paths are enabled later.
- Add a matching `.env.e2e.example` so CI/local users do not infer variables from comments.
### 4. `build.sh`
Generated compose files must match source compose:
- Release `bundle` generated `docker-compose.enterprise-clean.yml`:
- Replace `JWT_SECRET` with canonical `AUTH_SECRET_KEY` for agent and backend.
- Add/pass backend `AUTH_SECRET_KEY` if missing.
- Ensure generated env variable names match `.env.enterprise-clean.example` exactly.
- `bundle:embeddings` generated compose:
- Apply the same env contract as default bundle.
- `bundle:light` generated compose/env:
- Ensure backend all-in-one receives `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, DB URLs, admin vars, LLM vars.
- Help text:
- State that `AUTH_SECRET_KEY` is required and `JWT_SECRET` is unsupported.
### 5. `.env.example`
Turn this into the canonical local/dev template with all Docker-relevant variables grouped consistently:
Sections:
1. Project/compose
- `COMPOSE_PROJECT_NAME`
2. Postgres
- `POSTGRES_IMAGE`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_HOST_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`
3. Database URLs
- `DATABASE_URL`, `TASKS_DATABASE_URL`, `AUTH_DATABASE_URL`
- Note: compose may derive URLs, direct backend local run uses URLs.
4. Security
- `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, `JWT_AUDIENCE`, `JWT_ISSUER`.
- Add an explicit comment: `JWT_SECRET` is unsupported; migrate to `AUTH_SECRET_KEY`.
5. Ports
- `BACKEND_HOST_PORT`, `FRONTEND_HOST_PORT`, `FRONTEND_SSL_PORT`, `AGENT_HOST_PORT`
6. Admin bootstrap
- `INITIAL_ADMIN_CREATE`, `INITIAL_ADMIN_USERNAME`, `INITIAL_ADMIN_PASSWORD`, `INITIAL_ADMIN_EMAIL`
7. LLM/provider
- `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL`, `LLM_SSL_VERIFY`, `LLM_CA_CERT_URLS`
8. Agent
- `SERVICE_JWT`, `GRADIO_SERVER_PORT`, `GRADIO_ALLOW_PORT_FALLBACK`, `AGENT_ENABLE_LLM_TITLES`, `AGENT_TITLE_GENERATION_TIMEOUT_S`, `AGENT_PREFETCH_DASHBOARD_LIMIT`, `AGENT_CONFIRM_TOOLS`, `AGENT_INTERRUPT_BEFORE`, `EMBEDDING_*`
9. Certificates/frontend
- `CERTS_PATH`, `SSL_KEY_PASSPHRASE`
10. Runtime behavior
- `ENABLE_BELIEF_STATE_LOGGING`, `TASK_LOG_LEVEL`, `APP_TIMEZONE`, `ALLOWED_ORIGINS`, `FORCE_HTTPS`
11. Features
- `FEATURES__DATASET_REVIEW`, `FEATURES__HEALTH_MONITOR`
12. Optional integrations
- `ADFS_*`, `OPENROUTER_*`, `GITEA_TOKEN` only where relevant.
### 6. `.env.enterprise-clean.example`
Mirror `.env.example` but with production/external-Postgres defaults and enterprise comments:
- Include every variable used by `docker-compose.enterprise-clean.yml` and generated bundle compose.
- Use placeholder secrets, not real values.
- Add explicit generation commands for `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, and `SERVICE_JWT`.
- Include `AGENT_HOST_PORT` (currently missing from example but used by compose).
- Include `LLM_SSL_VERIFY` (currently used by compose, missing from example).
- Include `AUTH_SECRET_KEY` (currently required by backend/agent, missing from enterprise example).
- Include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL` (compose/build uses them, example incomplete).
### 7. `backend/.env.example`
Keep backend-focused, but synchronize with canonical names:
- Include backend-only/source-run variables:
- `DATABASE_URL`, `TASKS_DATABASE_URL`, `AUTH_DATABASE_URL`
- `AUTH_SECRET_KEY`, `ENCRYPTION_KEY`, `JWT_AUDIENCE`, `JWT_ISSUER`
- `INITIAL_ADMIN_*`
- `LLM_SSL_VERIFY`, `LLM_CA_CERT_URLS`
- `ALLOWED_ORIGINS`, `FORCE_HTTPS`, `APP_TIMEZONE`
- `ADFS_*`, `OPENROUTER_*`
- Do not include frontend/agent-only host port variables except where helpful comments point users to root `.env.example`.
### 8. `docker/.env.agent.example`
Make it agent-only and current:
- Replace `AUTH_SECRET_KEY=<shared-with-backend-secret>` with canonical `AUTH_SECRET_KEY=...` and note it must match backend.
- Remove stale implication that `AUTH_DATABASE_URL` is needed by agent.
- Include:
- `AUTH_SECRET_KEY`
- `SERVICE_JWT`
- `FASTAPI_URL`
- `DATABASE_URL`
- `LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL`
- `GRADIO_SERVER_NAME`, `GRADIO_SERVER_PORT`, `GRADIO_ALLOW_PORT_FALLBACK`
- `AGENT_ENABLE_LLM_TITLES`, `AGENT_TITLE_GENERATION_TIMEOUT_S`, `AGENT_PREFETCH_DASHBOARD_LIMIT`, `AGENT_CONFIRM_TOOLS`, `AGENT_INTERRUPT_BEFORE`
- `EMBEDDING_SIMILARITY_THRESHOLD`, `EMBEDDING_TOP_K`
- Do not include `JWT_SECRET`; add a one-line warning comment that old `JWT_SECRET` deployments must rename it to `AUTH_SECRET_KEY`.
### 9. New example files
Add missing templates so every operational env file has an example:
- `.env.current.example`
- local current-branch port offsets and admin demo values.
- `.env.master.example`
- local master-branch port offsets.
- `.env.e2e.example`
- e2e stack ports and test credentials.
- `frontend/.env.example`
- frontend/e2e visible variables if any are used; otherwise a short file saying frontend runtime config is generated/proxied via nginx and API calls.
Do not modify real secret-bearing files:
- `.env.enterprise-clean`
- `backend/.env`
- `frontend/.env`
- `frontend/.env.bak`
- `frontend/e2e/.env.e2e`
### 10. Source code canonicalization
Update `backend/src/agent/_jwt_decoder.py` to enforce the final canonical contract:
- `AUTH_SECRET_KEY` is canonical.
- `JWT_SECRET` is not read and not accepted.
- If `AUTH_SECRET_KEY` is missing, fail fast with a clear `JWTError` / startup-visible error.
- If `JWT_SECRET` is present but `AUTH_SECRET_KEY` is absent, do not silently accept it; error message should instruct operator to rename `JWT_SECRET` to `AUTH_SECRET_KEY`.
Behavior change required: remove the current dual-key fallback (`JWT_SECRET` first, then `AUTH_SECRET_KEY`). Decode backend-issued tokens using only `AUTH_SECRET_KEY`.
## Validation plan
1. Static env inventory
- Search `${VAR}` in all compose files and generated compose sections.
- Search `os.getenv`, `validation_alias`, and frontend `process.env`/`import.meta.env` usage.
- Verify every deploy-relevant variable appears in at least one matching example file.
2. Compose config validation
- `docker compose --env-file .env.example -f docker-compose.yml config`
- `docker compose --env-file .env.enterprise-clean.example -f docker-compose.enterprise-clean.yml config`
- `docker compose --env-file .env.e2e.example -f docker-compose.e2e.yml config`
3. Build script generated compose validation
- Run `bash -n build.sh`.
- Dry/read review generated heredoc sections for `bundle`, `bundle:embeddings`, and `bundle:light` to confirm same env contract.
- Optionally build a lightweight tag and inspect generated `dist/docker/docker-compose*.yml`.
4. Agent smoke validation
- Run agent image import smoke test with only `AUTH_SECRET_KEY`, not `JWT_SECRET`.
- Run a negative smoke test with only `JWT_SECRET` and confirm startup/import path fails with migration guidance.
- Confirm no `AUTH_DATABASE_URL` is required by agent startup.
- Confirm JWT decoder accepts backend-issued tokens.
5. Backend validation
- Confirm backend starts with canonical `.env.example`/enterprise env contract.
- Run targeted tests:
- `pytest backend/tests/test_agent/`
- `pytest backend/tests/test_core/test_auth_config.py backend/tests/test_core/test_jwt.py`
6. Docs sanity
- Ensure no examples tell operators to add `AUTH_DATABASE_URL` to the agent.
- Ensure no compose/example/generated bundle file contains `JWT_SECRET` as a usable variable.
- Ensure generated bundle instructions mention `AUTH_SECRET_KEY` as required.
## Expected outcome
- One canonical security secret name: `AUTH_SECRET_KEY`.
- No `JWT_SECRET` fallback remains in agent runtime or Docker examples.
- All compose files and generated bundle compose use the same names.
- Every operational env file has a corresponding example.
- Enterprise operators no longer need to guess why `.env.enterprise-clean.example`, root `.env.example`, backend `.env.example`, and agent `.env.agent.example` differ.
- Agent remains slim and independent from backend auth DB config.

View File

@@ -1,22 +1,49 @@
# ======================================================================
# superset-tools — backend .env.example
# Только обязательные переменные. Остальное настраивается через web UI.
# Используется для прямого запуска бекенда (без docker compose).
# При использовании docker-compose — переменные задаются в compose/environment.
# ======================================================================
# ── Обязательно: секреты ──
# JWT-ключ подписи токенов (backend + agent используют один)
# Сгенерировать: python -c "import secrets; print(secrets.token_urlsafe(32))"
AUTH_SECRET_KEY=change-me-to-a-random-secret
# ── Безопасность (ОБЯЗАТЕЛЬНО) ─────────────────────────────────────────
# JWT-ключ подписи токенов — единый для backend и agent.
# Сгенерировать: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
AUTH_SECRET_KEY=change-me-to-a-random-secret-32-chars-min
# Fernet-ключ шифрования паролей подключений и API-ключей
# Fernet-ключ шифрования паролей подключений и API-ключей.
# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
# Пример (сгенерируйте свой для прода):
ENCRYPTION_KEY=3YIxOr3_GFht9ZyjRQMqOdtuO1CKOj8Y9mpY89iMbZY=
# ── Обязательно: база данных ──
# PostgreSQL (пример для локальной разработки через docker-compose)
# JWT audience / issuer (опционально)
# JWT_AUDIENCE=superset-tools-api
# JWT_ISSUER=superset-tools
# ── База данных (ОБЯЗАТЕЛЬНО) ──────────────────────────────────────────
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
# Отдельная БД для аутентификации (если не задана — требуется явно)
AUTH_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
# Отдельная БД для задач (если не задана — DATABASE_URL)
TASKS_DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
# ── Admin bootstrap ────────────────────────────────────────────────────
# INITIAL_ADMIN_CREATE=true
# INITIAL_ADMIN_USERNAME=admin
# INITIAL_ADMIN_PASSWORD=change-me
# ── LLM / провайдеры ───────────────────────────────────────────────────
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
LLM_SSL_VERIFY=true
LLM_CA_CERT_URLS=
# ── CORS / Безопасность ────────────────────────────────────────────────
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
FORCE_HTTPS=false
APP_TIMEZONE=Europe/Moscow
# ── ADFS SSO (опционально) ─────────────────────────────────────────────
# ADFS_CLIENT_ID=
# ADFS_CLIENT_SECRET=
# ADFS_METADATA_URL=
# ── OpenRouter (опционально) ───────────────────────────────────────────
# OPENROUTER_SITE_URL=
# OPENROUTER_APP_NAME=
# APP_BASE_URL=

View File

@@ -1,52 +1,50 @@
# backend/src/agent/_jwt_decoder.py
# #region AgentChat.JwtDecoder [C:1] [TYPE Module] [SEMANTICS agent-chat,jwt,decode]
# @BRIEF Lightweight JWT decode for agent — uses JWT_SECRET env var directly, avoids
# @BRIEF Lightweight JWT decode for agent — uses AUTH_SECRET_KEY env var, avoids
# pulling src.core.auth.jwt which requires AUTH_DATABASE_URL and ORM deps.
# @RATIONALE The agent only needs stateless JWT validation (exp, sub, signature).
# Token blacklisting is only relevant for backend auth flow — the agent
# processes one request at a time and doesn't need DB-backed revocation.
# @INVARIANT AUTH_SECRET_KEY is the ONLY accepted JWT signing key. JWT_SECRET is
# unsupported and triggers a migration message if present without
# AUTH_SECRET_KEY.
# @REJECTED Importing src.core.auth.jwt.decode_token was rejected — it drags in
# AuthConfig AUTH_DATABASE_URL/SECRET_KEY validators SQLAlchemy models,
# AuthConfig -> AUTH_DATABASE_URL/SECRET_KEY validators -> SQLAlchemy models,
# bloating the agent image with backend infrastructure it doesn't need.
# @REJECTED Fallback from JWT_SECRET to AUTH_SECRET_KEY was rejected — ambiguous
# naming caused operator confusion and secret drift between environments.
import os
from jose import JWTError, jwt
def decode_token(token: str) -> dict:
"""Decode and validate a JWT token. Tries JWT_SECRET first, then AUTH_SECRET_KEY.
"""Decode and validate a JWT token using AUTH_SECRET_KEY from environment.
Performs stateless validation: signature, expiration, and required claims
(exp, sub). Does NOT check token blacklist or audience/issuer.
"""
keys = []
jwt_key = os.getenv("JWT_SECRET", "")
auth_key = os.getenv("AUTH_SECRET_KEY", "")
if jwt_key:
keys.append(jwt_key)
if auth_key and auth_key != jwt_key:
keys.append(auth_key)
if not keys:
raise JWTError("Neither JWT_SECRET nor AUTH_SECRET_KEY is set")
algorithm = os.getenv("JWT_ALGORITHM", "HS256")
last_error = None
for key in keys:
try:
return jwt.decode(
token,
key,
algorithms=[algorithm],
options={
"verify_signature": True,
"verify_exp": True,
"verify_aud": False,
"require": ["exp", "sub"],
},
)
except JWTError as e:
last_error = e
raise last_error
Raises JWTError with migration guidance if JWT_SECRET is set but
AUTH_SECRET_KEY is missing (old deployments must rename the variable).
"""
secret = os.getenv("AUTH_SECRET_KEY", "")
jwt_secret_legacy = os.getenv("JWT_SECRET", "")
if not secret:
if jwt_secret_legacy:
raise JWTError("JWT_SECRET is no longer supported. Rename JWT_SECRET to AUTH_SECRET_KEY in your .env / docker-compose and restart the agent.")
raise JWTError("AUTH_SECRET_KEY environment variable is not set")
return jwt.decode(
token,
secret,
algorithms=[os.getenv("JWT_ALGORITHM", "HS256")],
options={
"verify_signature": True,
"verify_exp": True,
"verify_aud": False,
"require": ["exp", "sub"],
},
)
# #endregion AgentChat.JwtDecoder

View File

@@ -12,9 +12,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import jwt
# Set JWT_SECRET and LLM_API_KEY for tests
JWT_SECRET = "test-jwt-secret-key"
os.environ["JWT_SECRET"] = JWT_SECRET
# Set AUTH_SECRET_KEY and LLM_API_KEY for tests (match conftest)
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
os.environ["OPENAI_API_KEY"] = "sk-test-key"
os.environ["LLM_API_KEY"] = "sk-test-key"
os.environ["LLM_MODEL"] = "gpt-4o"
@@ -33,7 +33,7 @@ def mock_save_conversation():
def _make_test_jwt(user_id: str = "test-user") -> str:
return jwt.encode({"sub": user_id}, JWT_SECRET, algorithm="HS256")
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
def _empty_agent_state():
@@ -68,6 +68,8 @@ async def test_handler_empty_message_returns_immediately():
# create_agent should NOT be called for empty messages
# (it gets called currently — future optimization)
# mock_create.assert_not_called() # TODO: optimize to skip graph for empty msg
# #endregion TestAgentChat.Handler.EmptyMessage
@@ -92,10 +94,12 @@ async def test_handler_missing_auth_continues_gracefully():
# Patch create_agent to prevent LLM call — handler should proceed without JWT
with patch("src.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
"""Empty async generator — yields nothing."""
return
yield # make it a generator
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
@@ -107,10 +111,10 @@ async def test_handler_missing_auth_continues_gracefully():
# No UNAUTHORIZED error — handler proceeds with empty user_jwt
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", \
"Handler should not reject missing auth — JWT optional at Gradio layer"
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject missing auth — JWT optional at Gradio layer"
@pytest.mark.anyio
@@ -127,9 +131,11 @@ async def test_handler_invalid_jwt_continues_gracefully():
with patch("src.agent.app.create_agent") as mock_create:
mock_graph = AsyncMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_graph.aget_state = AsyncMock(return_value=_empty_agent_state())
mock_create.return_value = mock_graph
@@ -140,10 +146,12 @@ async def test_handler_invalid_jwt_continues_gracefully():
for r in results:
import json
parsed = json.loads(r) if isinstance(r, str) else r
meta = parsed.get("metadata", {})
assert meta.get("code") != "UNAUTHORIZED", \
"Handler should not reject invalid JWT — invalid token ignored at Gradio layer"
assert meta.get("code") != "UNAUTHORIZED", "Handler should not reject invalid JWT — invalid token ignored at Gradio layer"
# #endregion TestAgentChat.Handler.AuthError
@@ -180,11 +188,11 @@ async def test_handler_yields_stream_tokens():
assert len(results) > 0, "Should yield at least one chunk"
import json
stream_tokens = [
r for r in results
if json.loads(r)["metadata"]["type"] == "stream_token"
]
stream_tokens = [r for r in results if json.loads(r)["metadata"]["type"] == "stream_token"]
assert len(stream_tokens) > 0, "Should yield stream_token metadata"
# #endregion TestAgentChat.Handler.Streaming
@@ -207,9 +215,11 @@ async def test_handler_resume_confirm():
# imports create_agent directly from langgraph_setup
with patch("src.agent._confirmation.create_agent") as mock_create:
mock_graph = MagicMock()
async def _empty_stream(*_args, **_kwargs):
return
yield
mock_graph.astream_events = _empty_stream
mock_create.return_value = mock_graph
@@ -220,10 +230,13 @@ async def test_handler_resume_confirm():
# Should yield confirm_resolved metadata
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "confirmed"
assert mock_create.call_args.kwargs["interrupt_before"] == []
# #endregion TestAgentChat.Handler.ResumeConfirm
@@ -251,8 +264,11 @@ async def test_handler_resume_deny():
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["type"] == "confirm_resolved"
assert parsed["metadata"]["result"] == "denied"
# #endregion TestAgentChat.Handler.ResumeDeny
# #endregion TestAgentChat.Handler

View File

@@ -16,8 +16,8 @@ import pytest
import jwt
JWT_SECRET = "test-jwt-secret-key"
os.environ["JWT_SECRET"] = JWT_SECRET
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
AUTH_SECRET_KEY = os.environ["AUTH_SECRET_KEY"]
os.environ["OPENAI_API_KEY"] = "sk-test-key"
os.environ["LLM_API_KEY"] = "sk-test-key"
os.environ["LLM_MODEL"] = "gpt-4o"
@@ -25,7 +25,7 @@ os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
def _make_test_jwt(user_id: str = "test-user") -> str:
return jwt.encode({"sub": user_id}, JWT_SECRET, algorithm="HS256")
return jwt.encode({"sub": user_id}, AUTH_SECRET_KEY, algorithm="HS256")
@pytest.fixture(autouse=True)
@@ -37,6 +37,7 @@ def mock_save_conversation():
# #region TestAgentChat.Confirmations.Concurrent [C:2] [TYPE Function] [SEMANTICS test,confirmation,concurrent]
# @BRIEF Concurrent send lock prevents multiple simultaneous sends from same user.
@pytest.mark.asyncio
async def test_concurrent_send_lock():
"""Handler rejects concurrent sends from same user with CONCURRENT_SEND error."""
@@ -58,17 +59,21 @@ async def test_concurrent_send_lock():
assert len(results) == 1
import json
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
assert parsed["metadata"]["code"] == "CONCURRENT_SEND"
# Clean up
_user_locks.pop("admin", None)
# #endregion TestAgentChat.Confirmations.Concurrent
# #region TestAgentChat.Confirmations.UnknownAction [C:2] [TYPE Function] [SEMANTICS test,confirmation,unknown]
# @BRIEF Non-confirm/deny action values are treated as normal messages.
@pytest.mark.asyncio
async def test_handler_unknown_action_treated_as_normal():
"""Handler with unknown action string proceeds as normal message send."""
@@ -84,8 +89,10 @@ async def test_handler_unknown_action_treated_as_normal():
with patch("src.agent.app.create_agent") as mock_create:
mock_graph = MagicMock()
async def _mock_stream(*_args, **_kwargs):
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
mock_graph.astream_events = _mock_stream
state = MagicMock(spec_set=["next"])
state.next = ()
@@ -99,15 +106,19 @@ async def test_handler_unknown_action_treated_as_normal():
# Should produce stream tokens, not confirm_resolved
assert len(results) > 0
import json
meta_types = [json.loads(r)["metadata"]["type"] for r in results]
assert "stream_token" in meta_types
assert "confirm_resolved" not in meta_types
# #endregion TestAgentChat.Confirmations.UnknownAction
# #region TestAgentChat.Confirmations.ExpiredState [C:2] [TYPE Function] [SEMANTICS test,confirmation,expired]
# @BRIEF Stale confirmations — checkpoint no longer available.
@pytest.mark.asyncio
async def test_handler_confirm_no_graph():
"""Handler with action='confirm' but create_agent raises handles it gracefully."""
@@ -131,5 +142,7 @@ async def test_handler_confirm_no_graph():
# Exception may propagate or be caught — either is acceptable
except Exception:
pass
# #endregion TestAgentChat.Confirmations.ExpiredState
# #endregion TestAgentChat.Confirmations

View File

@@ -13,7 +13,7 @@ sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
import pytest
os.environ["JWT_SECRET"] = "test-jwt-secret-key"
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
os.environ["FASTAPI_URL"] = "http://test-backend:8000"
os.environ["SERVICE_JWT"] = "test-service-jwt"
os.environ["OPENAI_API_KEY"] = "sk-test-key"
@@ -27,6 +27,7 @@ def anyio_backend():
# #region TestAgentChat.Tools.DualAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth]
# @BRIEF Dual-identity auth headers built from ContextVar and env vars.
@pytest.mark.anyio
async def test_tool_dual_auth_headers():
"""Tools should build auth headers from ContextVar when set."""
@@ -56,12 +57,15 @@ async def test_tool_dual_auth_headers():
assert "Authorization" in headers, "Should include Authorization header"
assert headers["Authorization"] == "Bearer service-jwt-token"
assert headers["X-User-JWT"] == "user-jwt-token"
# #endregion TestAgentChat.Tools.DualAuth
# #region TestAgentChat.Tools.FallbackAuth [C:2] [TYPE Function] [SEMANTICS test,tools,auth,fallback]
# @BRIEF Dual-identity auth falls back to env var when ContextVar is not set.
@pytest.mark.anyio
async def test_tool_auth_fallback_to_env():
"""Tools should fall back to SERVICE_JWT env var when ContextVar is empty."""
@@ -74,8 +78,7 @@ async def test_tool_auth_fallback_to_env():
set_service_jwt("")
os.environ["SERVICE_JWT"] = "env-service-token"
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), \
patch("httpx.AsyncClient") as mock_client:
with patch.object(tools_mod, "FASTAPI_URL", "http://test-backend:8000"), patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value = Mock(
@@ -93,12 +96,15 @@ async def test_tool_auth_fallback_to_env():
headers = kwargs.get("headers", {})
# Should use env var
assert "Authorization" in headers
# #endregion TestAgentChat.Tools.FallbackAuth
# #region TestAgentChat.Tools.HttpFailure [C:2] [TYPE Function] [SEMANTICS test,tools,failure]
# @BRIEF Tool handles HTTP failure gracefully (returns error text, not exception).
@pytest.mark.anyio
async def test_tool_http_exception_handling():
"""Tool should propagate HTTP exception as error text."""
@@ -116,12 +122,15 @@ async def test_tool_http_exception_handling():
# Should propagate the exception (caller handles error)
with pytest.raises((Exception,)):
await search_dashboards.ainvoke({"query": "test"})
# #endregion TestAgentChat.Tools.HttpFailure
# #region TestAgentChat.Tools.GetAll [C:2] [TYPE Function] [SEMANTICS test,tools,registry]
# @BRIEF get_all_tools returns the expected list of tool functions.
def test_get_all_tools_returns_expected_list():
"""get_all_tools() should return search_dashboards, get_health_summary, etc."""
from src.agent.tools import get_all_tools
@@ -169,6 +178,7 @@ def test_get_all_tools_args_schema():
# Tool get_all — full catalog (replaces get_tools_for_query)
# ═══════════════════════════════════════════════════════════════════
def test_get_all_tools_returns_24_tools():
"""get_all_tools() returns full catalog — all 24 tools registered."""
from src.agent.tools import get_all_tools
@@ -181,12 +191,15 @@ def test_get_all_tools_returns_24_tools():
assert "get_health_summary" in tool_names
# Regression: minimum count — must have all 24
assert len(tools) >= 24, f"Expected ≥24 tools, got {len(tools)}: {tool_names}"
# #endregion TestAgentChat.Tools.GetAll
# #region TestAgentChat.Tools.ToolContracts [C:2] [TYPE Function] [SEMANTICS test,tools,contract]
# @BRIEF Tool contracts match @POST and @PRE declared in contracts/modules.md.
@pytest.mark.anyio
async def test_search_dashboards_correct_url():
"""search_dashboards calls GET /api/dashboards with query params."""
@@ -212,12 +225,15 @@ async def test_search_dashboards_correct_url():
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/dashboards" in url
# #endregion TestAgentChat.Tools.ToolContracts
# #region TestAgentChat.Tools.HealthSummary [C:2] [TYPE Function] [SEMANTICS test,tools,health]
# @BRIEF get_health_summary calls the correct FastAPI endpoint.
@pytest.mark.anyio
async def test_get_health_summary_calls_correct_url():
"""get_health_summary should call GET /api/health/summary."""
@@ -241,12 +257,15 @@ async def test_get_health_summary_calls_correct_url():
url = args[0] if args else kwargs.get("url", "")
assert "api/health/summary" in url
assert kwargs.get("params") == {"environment_id": "ss-dev"}
# #endregion TestAgentChat.Tools.HealthSummary
# #region TestAgentChat.Tools.ListEnvironments [C:2] [TYPE Function] [SEMANTICS test,tools,environments]
# @BRIEF list_environments calls the correct FastAPI endpoint.
@pytest.mark.anyio
async def test_list_environments_calls_correct_url():
"""list_environments should call GET /api/settings/environments."""
@@ -284,10 +303,7 @@ async def test_list_environments_redacts_sensitive_fields():
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.status_code = 200
mock_instance.get.return_value.text = (
'[{"id":"prod","password":"secret-pass","api_key":"secret-key",'
'"nested":{"token":"secret-token"},"name":"ss-prod"}]'
)
mock_instance.get.return_value.text = '[{"id":"prod","password":"secret-pass","api_key":"secret-key","nested":{"token":"secret-token"},"name":"ss-prod"}]'
result = await list_environments.ainvoke({})
@@ -295,12 +311,15 @@ async def test_list_environments_redacts_sensitive_fields():
assert "secret-key" not in result
assert "secret-token" not in result
assert result.count("[redacted]") == 3
# #endregion TestAgentChat.Tools.ListEnvironments
# #region TestAgentChat.Tools.TaskStatus [C:2] [TYPE Function] [SEMANTICS test,tools,task]
# @BRIEF get_task_status calls the correct FastAPI endpoint with task_id.
@pytest.mark.anyio
async def test_get_task_status_calls_correct_url():
"""get_task_status should call GET /api/tasks/{task_id}."""
@@ -323,6 +342,8 @@ async def test_get_task_status_calls_correct_url():
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/tasks/task-123" in url
# #endregion TestAgentChat.Tools.TaskStatus
@@ -380,6 +401,7 @@ async def test_deploy_dashboard_posts_git_endpoint():
# #region TestAgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS test,tools,auth,headers]
# @BRIEF _dual_auth_headers builds proper headers from ContextVars.
def test_dual_auth_headers_with_both_jwts():
"""_dual_auth_headers uses service auth plus user identity when both are set."""
from src.agent.context import set_service_jwt, set_user_jwt
@@ -418,5 +440,7 @@ def test_dual_auth_headers_no_jwts():
# (set at import time based on os.environ)
assert "Authorization" in headers
assert headers["Authorization"].startswith("Bearer ")
# #endregion TestAgentChat.Tools.DualAuthHeaders
# #endregion TestAgentChat.Tools

View File

@@ -264,6 +264,8 @@ services:
TASKS_DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools}
AUTH_DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools}
BACKEND_PORT: 8000
AUTH_SECRET_KEY: \${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env}
ENCRYPTION_KEY: \${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env}
ENABLE_BELIEF_STATE_LOGGING: \${ENABLE_BELIEF_STATE_LOGGING:-true}
TASK_LOG_LEVEL: \${TASK_LOG_LEVEL:-INFO}
INITIAL_ADMIN_CREATE: \${INITIAL_ADMIN_CREATE:-false}
@@ -306,8 +308,8 @@ services:
LLM_BASE_URL: \${LLM_BASE_URL:-https://api.openai.com/v1}
LLM_MODEL: \${LLM_MODEL:-gpt-4o}
FASTAPI_URL: http://backend:8000
JWT_SECRET: \${JWT_SECRET:-super-secret-key}
SERVICE_JWT: \${SERVICE_JWT:-agent-service-secret}
AUTH_SECRET_KEY: \${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env}
SERVICE_JWT: \${SERVICE_JWT:?Set SERVICE_JWT in .env}
DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools}
GRADIO_SERVER_PORT: 7860
ports:
@@ -568,6 +570,8 @@ services:
TASKS_DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools}
AUTH_DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools}
BACKEND_PORT: 8000
AUTH_SECRET_KEY: \${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env}
ENCRYPTION_KEY: \${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env}
ENABLE_BELIEF_STATE_LOGGING: \${ENABLE_BELIEF_STATE_LOGGING:-true}
TASK_LOG_LEVEL: \${TASK_LOG_LEVEL:-INFO}
INITIAL_ADMIN_CREATE: \${INITIAL_ADMIN_CREATE:-false}
@@ -610,8 +614,8 @@ services:
LLM_BASE_URL: \${LLM_BASE_URL:-https://api.openai.com/v1}
LLM_MODEL: \${LLM_MODEL:-gpt-4o}
FASTAPI_URL: http://backend:8000
JWT_SECRET: \${JWT_SECRET:-super-secret-key}
SERVICE_JWT: \${SERVICE_JWT:-agent-service-secret}
AUTH_SECRET_KEY: \${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env}
SERVICE_JWT: \${SERVICE_JWT:?Set SERVICE_JWT in .env}
DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools}
GRADIO_SERVER_PORT: 7860
ports:
@@ -709,12 +713,14 @@ Commands for release bundles:
Agent: NO sentence-transformers/embeddings (~500 MB saved).
Backend: NO agent/ML/dev deps.
NOTE: external PostgreSQL only — no bundled postgres image.
REQUIRED: AUTH_SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, SERVICE_JWT.
Example: ./build.sh bundle v1.0.0
bundle:embeddings <tag>
Enterprise bundle with semantic embedding routing.
Agent built WITH sentence-transformers+torch (larger image).
Same backend/frontend as default bundle.
REQUIRED: AUTH_SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, SERVICE_JWT.
Example: ./build.sh bundle:embeddings v1.0.0
bundle:light <tag> Lightweight all-in-one image (.tar.xz, <200 MB, no Playwright)

View File

@@ -34,11 +34,12 @@ services:
db:
condition: service_healthy
environment:
POSTGRES_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
TASKS_DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
AUTH_DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
BACKEND_PORT: 8000
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.e2e}
SERVICE_JWT: ${SERVICE_JWT:-e2e-service-secret}
INITIAL_ADMIN_CREATE: "true"
INITIAL_ADMIN_USERNAME: admin
INITIAL_ADMIN_PASSWORD: admin123

View File

@@ -42,15 +42,14 @@ services:
TASKS_DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:?Set POSTGRES_HOST in .env.enterprise-clean}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools}
AUTH_DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:?Set POSTGRES_HOST in .env.enterprise-clean}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools}
BACKEND_PORT: 8000
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.enterprise-clean}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY обязателен — сгенерируйте и укажите в .env.enterprise-clean}
ENABLE_BELIEF_STATE_LOGGING: ${ENABLE_BELIEF_STATE_LOGGING:-true}
TASK_LOG_LEVEL: ${TASK_LOG_LEVEL:-INFO}
INITIAL_ADMIN_CREATE: ${INITIAL_ADMIN_CREATE:-false}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
# Обязательно: задайте ENCRYPTION_KEY в .env.enterprise-clean
# Сгенерируйте: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY обязателен — сгенерируйте и укажите в .env.enterprise-clean}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
FEATURES__DATASET_REVIEW: ${FEATURES__DATASET_REVIEW:-true}
@@ -103,7 +102,7 @@ services:
LLM_BASE_URL: ${LLM_BASE_URL:-https://api.openai.com/v1}
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
FASTAPI_URL: http://backend:8000
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set — crash-early, no default fallback}
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env.enterprise-clean}
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:?Set POSTGRES_HOST in .env.enterprise-clean}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools}
GRADIO_SERVER_PORT: 7860

View File

@@ -59,7 +59,7 @@ services:
LLM_BASE_URL: ${LLM_BASE_URL:-https://api.openai.com/v1}
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
FASTAPI_URL: http://backend:8000
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set — crash-early, no default fallback}
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?Set AUTH_SECRET_KEY in .env}
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
GRADIO_SERVER_PORT: 7860

View File

@@ -1,6 +1,37 @@
# Agent container env vars
# LLM конфигурация берётся из FastAPI /api/agent/llm-config (настраивается через web UI).
AUTH_SECRET_KEY=<shared-with-backend-secret> # JWT-ключ (общий с backend)
FASTAPI_URL=http://backend:8000 # URL основного API
SERVICE_JWT=<agent-service-secret> # Сервисный токен для agent→backend
DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/ss_tools # БД для checkpoint'ов
# Используется для прямого запуска агента (без docker compose).
# При использовании docker-compose — переменные задаются в compose/environment.
# ВАЖНО: AUTH_SECRET_KEY должен совпадать с ключом бекенда.
# ── Безопасность (ОБЯЗАТЕЛЬНО) ─────────────────────────────────────────
# JWT-ключ подписи токенов — единый для backend и agent.
# Сгенерировать: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
AUTH_SECRET_KEY=<same-as-backend-AUTH_SECRET_KEY>
# Сервисный токен для agent→backend вызовов.
SERVICE_JWT=agent-service-secret
# ── Подключение ────────────────────────────────────────────────────────
FASTAPI_URL=http://backend:8000
DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
# ── LLM ────────────────────────────────────────────────────────────────
LLM_API_KEY=
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
# ── Gradio ─────────────────────────────────────────────────────────────
GRADIO_SERVER_NAME=0.0.0.0
GRADIO_SERVER_PORT=7860
# GRADIO_ALLOW_PORT_FALLBACK=true
# ── Тонкие настройки агента ────────────────────────────────────────────
# AGENT_ENABLE_LLM_TITLES=true
# AGENT_TITLE_GENERATION_TIMEOUT_S=0.25
# AGENT_PREFETCH_DASHBOARD_LIMIT=25
# AGENT_CONFIRM_TOOLS=false
# AGENT_INTERRUPT_BEFORE=
# ── Embedding routing (только для bundle:embeddings) ────────────────────
# EMBEDDING_SIMILARITY_THRESHOLD=0.65
# EMBEDDING_TOP_K=5

9
frontend/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Frontend dev config
# Используется при запуске npm run dev (Vite dev server).
# В production (docker compose) фронтенд обслуживается nginx,
# переменные окружения задаются docker-compose.yml или Dockerfile.
# URL бекенда для API-прокси в dev-режиме
PUBLIC_BACKEND_URL=http://127.0.0.1:8000
# URL агента для Gradio-прокси в dev-режиме
PUBLIC_GRADIO_URL=http://127.0.0.1:7860

16
run.sh
View File

@@ -202,14 +202,14 @@ ensure_jwt_secret() {
# Extract existing key from .env
if [ -f "$ENV_FILE" ]; then
# shellcheck disable=SC2013
for line in $(grep "^JWT_SECRET=" "$ENV_FILE" | head -1); do
key="${line#JWT_SECRET=}"
for line in $(grep "^AUTH_SECRET_KEY=" "$ENV_FILE" | head -1); do
key="${line#AUTH_SECRET_KEY=}"
done
fi
# Validate existing key (must be non-empty and reasonably long)
if [ -n "$key" ] && [ ${#key} -ge 16 ]; then
echo "JWT secret preflight: existing JWT_SECRET reused from $ENV_FILE"
echo "JWT secret preflight: existing AUTH_SECRET_KEY reused from $ENV_FILE"
return
fi
@@ -217,17 +217,17 @@ ensure_jwt_secret() {
local new_key
new_key=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
if [ -f "$ENV_FILE" ] && grep -q "^JWT_SECRET=" "$ENV_FILE"; then
if [ -f "$ENV_FILE" ] && grep -q "^AUTH_SECRET_KEY=" "$ENV_FILE"; then
# Replace invalid key
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$new_key/" "$ENV_FILE"
sed -i '' "s/^AUTH_SECRET_KEY=.*/AUTH_SECRET_KEY=$new_key/" "$ENV_FILE"
else
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$new_key/" "$ENV_FILE"
sed -i "s/^AUTH_SECRET_KEY=.*/AUTH_SECRET_KEY=$new_key/" "$ENV_FILE"
fi
else
echo "JWT_SECRET=$new_key" >> "$ENV_FILE"
echo "AUTH_SECRET_KEY=$new_key" >> "$ENV_FILE"
fi
echo "JWT secret preflight: JWT_SECRET generated and saved to $ENV_FILE"
echo "JWT secret preflight: AUTH_SECRET_KEY generated and saved to $ENV_FILE"
}
ensure_jwt_secret