From ec9bc76a3772e3ca058f962d71533cec806c0b61 Mon Sep 17 00:00:00 2001 From: busya Date: Sat, 4 Jul 2026 14:58:43 +0300 Subject: [PATCH] =?UTF-8?q?cleanup:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B5=20env-=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B2=20agent/=5Fconfig.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены из кода: - JWT_SECRET — мёртвая (decode_token использует AUTH_SECRET_KEY) - SESSION_SECRET_KEY — заменён на прямой AUTH_SECRET_KEY - POSTGRES_URL — deprecated fallback, удалён из database.py и reencrypt.py Консолидировано чтение env-переменных agent-модуля: - Создан agent/_config.py — единый модуль для FASTAPI_URL, SERVICE_JWT, GRADIO_*, STORAGE_ROOT, AGENT_* (9 констант) - Все agent/*.py импортируют из _config вместо разрозненных os.getenv Удалены or-дефолты (безопасность): - agent/langgraph_setup.py — удалён hardcoded DB URL postgres:postgres - agent/langgraph_setup.py — удалены fallback API URL и model name - scripts/reencrypt.py — удалён hardcoded DB URL postgres:postgres - plugins/llm_analysis/service.py — удалены or-дефолты URL/app name .env.example — минимализация: - backend/.env.example: только 4 обязательные переменные - root/.env.example: обязательные + docker + SSO/админ Обновлены тесты (139 passed) --- .env.example | 67 ++++------------- backend/.env.example | 32 ++++---- backend/src/agent/_config.py | 30 ++++++++ backend/src/agent/_persistence.py | 15 ++-- backend/src/agent/app.py | 11 +-- backend/src/agent/langgraph_setup.py | 36 ++++----- backend/src/agent/run.py | 17 ++--- backend/src/agent/tools.py | 4 +- backend/src/app.py | 10 +-- backend/src/core/database.py | 8 +- backend/src/plugins/llm_analysis/service.py | 3 +- backend/src/scripts/reencrypt.py | 6 +- .../tests/test_agent/test_agent_handler.py | 4 +- .../tests/test_agent/test_langchain_tools.py | 10 ++- .../tests/test_agent/test_langgraph_setup.py | 73 +++++++++---------- backend/tests/test_agent/test_run.py | 16 +++- 16 files changed, 163 insertions(+), 179 deletions(-) create mode 100644 backend/src/agent/_config.py diff --git a/.env.example b/.env.example index 3f5d964e..9090058a 100644 --- a/.env.example +++ b/.env.example @@ -1,75 +1,38 @@ # ====================================================================== # superset-tools — Переменные окружения -# Скопируйте в .env и заполните значения -# -# Полный каталог: см. backend/src/core/auth/config.py, -# backend/src/core/database.py, backend/src/app.py +# Только обязательные + docker. Остальное настраивается через web UI. # ====================================================================== -# --- Аутентификация и безопасность (ОБЯЗАТЕЛЬНО) --- +# --- Обязательно: секреты --- AUTH_SECRET_KEY= # JWT-ключ подписи токенов (обязательно, без него сервер не стартует) -ALLOWED_ORIGINS=* # CORS: список доменов через запятую (по умолчанию *; для прода — явный список) +ENCRYPTION_KEY= # Fernet-ключ шифрования (обязательно) -# --- Базы данных --- +# --- Обязательно: базы данных --- DATABASE_URL= # Основная БД (обязательно для production) -AUTH_DATABASE_URL= # БД аутентификации (если не задан — fallback на DATABASE_URL) +AUTH_DATABASE_URL= # БД аутентификации (если не задан — требуется явно) TASKS_DATABASE_URL= # БД задач (если не задан — fallback на DATABASE_URL) -POSTGRES_URL= # Fallback для DATABASE_URL (deprecated; используйте DATABASE_URL) - -# --- Режим разработки --- -DEV_MODE=false # true — разрешает dev-fallback для БД и упрощает валидацию секретов # --- ADFS SSO (опционально) --- ADFS_CLIENT_ID= # Client ID для ADFS (если не задан — ADFS отключён) ADFS_CLIENT_SECRET= # Client Secret для ADFS -ADFS_METADATA_URL= # URL метаданных ADFS (например, https://adfs.example.com/FederationMetadata/2007-06/FederationMetadata.xml) +ADFS_METADATA_URL= # URL метаданных ADFS # --- Администратор (первый запуск) --- -INITIAL_ADMIN_CREATE=false # true — создать администратора при старте (только для первого запуска) +INITIAL_ADMIN_CREATE=false # true — создать администратора при старте INITIAL_ADMIN_USERNAME=admin # Логин администратора INITIAL_ADMIN_PASSWORD= # Пароль (обязателен при INITIAL_ADMIN_CREATE=true) INITIAL_ADMIN_EMAIL= # Email администратора (опционально) -# --- AI / LLM API ключи (опционально) --- -OPENAI_API_KEY= # OpenAI API key -ANTHROPIC_API_KEY= # Anthropic API key -OPENROUTER_SITE_URL= # URL сайта для OpenRouter (если используется) -OPENROUTER_APP_NAME=superset-tools # Название приложения для OpenRouter (по умолчанию superset-tools) -APP_BASE_URL= # Базовый URL приложения для LLM-колбэков - -# --- Шифрование --- -ENCRYPTION_KEY= # Ключ Fernet-шифрования (генерируется автоматически при первом запуске) - -# --- Хранилище --- -STORAGE_ROOT=./storage # Корневая директория для артефактов и файлов - -# --- Порты --- -BACKEND_HOST_PORT=8001 # Внешний порт бэкенда на хосте (маппинг контейнера) +# --- Порты (Docker) --- +BACKEND_HOST_PORT=8001 # Внешний порт бэкенда на хосте FRONTEND_HOST_PORT=8000 # Внешний порт фронтенда на хосте -BACKEND_PORT=8000 # Внутренний порт бэкенда в контейнере -FRONTEND_PORT=5173 # Порт dev-сервера SvelteKit -FRONTEND_SSL_PORT=443 # SSL-порт для фронтенда (nginx) - -# --- Сертификаты --- -CERTS_PATH=./certs # Путь к директории с сертификатами (монтируется в контейнер) # --- PostgreSQL (прямое подключение, без DATABASE_URL) --- -POSTGRES_HOST=localhost # Хост PostgreSQL -POSTGRES_PORT=5432 # Порт PostgreSQL -POSTGRES_DB=ss_tools # Имя БД -POSTGRES_USER=postgres # Пользователь -POSTGRES_PASSWORD= # Пароль (обязателен для production) - -# --- Логирование --- -TASK_LOG_LEVEL=INFO # Уровень логирования задач (DEBUG/INFO/WARNING/ERROR) -ENABLE_BELIEF_STATE_LOGGING=true # Включить belief state логирование (true/false) - -# --- Features (фича-флаги) --- -FEATURES__DATASET_REVIEW=true # Включить ревью датасетов -FEATURES__HEALTH_MONITOR=true # Включить мониторинг здоровья - -# --- WebSocket (фронтенд) --- -PUBLIC_WS_URL= # URL для WebSocket-соединений из фронтенда (например, ws://localhost:8000) +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=ss_tools +POSTGRES_USER=postgres +POSTGRES_PASSWORD= # --- Docker Compose --- -COMPOSE_PROJECT_NAME=superset-tools # Имя Docker Compose проекта +COMPOSE_PROJECT_NAME=superset-tools diff --git a/backend/.env.example b/backend/.env.example index 1d529db6..99f60d7d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,23 +1,21 @@ -# ── Required: Secrets ── -# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +# ====================================================================== +# superset-tools — backend .env.example +# Только обязательные переменные. Остальное настраивается через web UI. +# ====================================================================== + +# ── Обязательно: секреты ── +# JWT-ключ подписи токенов (backend + agent используют один) +# Сгенерировать: python -c "import secrets; print(secrets.token_urlsafe(32))" AUTH_SECRET_KEY=change-me-to-a-random-secret -# Generate with: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())" + +# Fernet-ключ шифрования паролей подключений и API-ключей +# Сгенерировать: python3 -c "import base64,os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())" ENCRYPTION_KEY= -# ── Required: Database ── -# PostgreSQL via docker-compose: +# ── Обязательно: база данных ── +# PostgreSQL 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 -# Run after pulling: cd backend && source .venv/bin/activate && alembic upgrade head - -# ── Optional ── -ALLOWED_ORIGINS=http://localhost:5173 -SESSION_SECRET_KEY=change-me-to-a-random-secret -FEATURES__DATASET_REVIEW=true -FEATURES__HEALTH_MONITOR=true -APP_TIMEZONE=Europe/Moscow - -# HSTS — включать только при настроенном HTTPS (nginx/ingress) -# Включение без сертификатов сломает доступ к сайту! -# FORCE_HTTPS=true diff --git a/backend/src/agent/_config.py b/backend/src/agent/_config.py new file mode 100644 index 00000000..8224ba75 --- /dev/null +++ b/backend/src/agent/_config.py @@ -0,0 +1,30 @@ +# backend/src/agent/_config.py +# #region AgentChat.Config [C:2] [TYPE Module] [SEMANTICS agent-chat,config,env] +# @ingroup AgentChat +# @BRIEF Centralized env-var reads for agent services. Read once, import everywhere. +# @RATIONALE FASTAPI_URL, SERVICE_JWT, GRADIO_* were read from os.getenv in 4+ +# separate files. Consolidating here eliminates redundant env-reads and +# ensures consistent defaults across the agent module. +import os + +# ── FastAPI backend URL ────────────────────────────────────────── +FASTAPI_URL: str = os.getenv("FASTAPI_URL", "http://localhost:8000") + +# ── Service-to-service JWT (agent → FastAPI auth) ──────────────── +SERVICE_JWT: str = os.getenv("SERVICE_JWT", "") + +# ── Gradio server ──────────────────────────────────────────────── +GRADIO_SERVER_NAME: str = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0") +GRADIO_SERVER_PORT: int = int(os.getenv("GRADIO_SERVER_PORT", "7860")) +GRADIO_ALLOW_PORT_FALLBACK: bool = os.getenv("GRADIO_ALLOW_PORT_FALLBACK", "").strip().lower() in {"1", "true", "yes"} + +# ── File storage ───────────────────────────────────────────────── +STORAGE_ROOT: str = os.getenv("STORAGE_ROOT", "/app/storage") + +# ── Prefetch ───────────────────────────────────────────────────── +AGENT_PREFETCH_DASHBOARD_LIMIT: int = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25")) + +# ── HITL (Human-in-the-Loop) ──────────────────────────────────── +AGENT_CONFIRM_TOOLS: bool = os.getenv("AGENT_CONFIRM_TOOLS", "").strip().lower() in ("true", "1", "yes") +AGENT_INTERRUPT_BEFORE: str = os.getenv("AGENT_INTERRUPT_BEFORE", "") +# #endregion AgentChat.Config diff --git a/backend/src/agent/_persistence.py b/backend/src/agent/_persistence.py index 6dc4858d..1bf7a1d4 100644 --- a/backend/src/agent/_persistence.py +++ b/backend/src/agent/_persistence.py @@ -15,9 +15,10 @@ from typing import Any import httpx +from src.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT, AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT from src.core.logger import logger -SAVE_API_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") + "/api/agent/conversations/save" +SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save" TITLE_MAX_LENGTH = 80 # ── Rule-based title cleaning ──────────────────────────────────── @@ -254,10 +255,9 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None: # Patch the title via the same save endpoint try: - service_token = os.getenv("SERVICE_JWT", "") headers = {"Content-Type": "application/json"} - if service_token: - headers["Authorization"] = f"Bearer {service_token}" + if _SERVICE_JWT: + headers["Authorization"] = f"Bearer {_SERVICE_JWT}" payload = { "conversation_id": conv_id, "title": title, @@ -308,7 +308,7 @@ async def prefetch_dashboards(env_id: str) -> str: dashboards = data.get("dashboards", []) if not dashboards: return "No dashboards found." - limit = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25")) + limit = _PREFETCH_LIMIT total = len(dashboards) lines = [] for db in dashboards[:limit]: @@ -345,10 +345,9 @@ async def prefetch_dashboards(env_id: str) -> str: # @REJECTED Requiring explicit authentication for Gradio was rejected — the agent is designed for internal-network use where the auth proxy handles auth; adding a separate auth layer would create unnecessary friction and duplicate the proxy's responsibility. async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None: try: - service_token = os.getenv("SERVICE_JWT", "") headers = {"Content-Type": "application/json"} - if service_token: - headers["Authorization"] = f"Bearer {service_token}" + if _SERVICE_JWT: + headers["Authorization"] = f"Bearer {_SERVICE_JWT}" # Normalize user_id: anonymous Gradio sessions use "anon_" prefix if not user_id or user_id.startswith("anon_"): diff --git a/backend/src/agent/app.py b/backend/src/agent/app.py index 15a48f81..c2b5c7d2 100644 --- a/backend/src/agent/app.py +++ b/backend/src/agent/app.py @@ -1,7 +1,7 @@ # backend/src/agent/app.py # #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app] # @defgroup AgentChat Gradio ChatInterface wrapping LangGraph agent. Streaming via submit(), HITL via interrupt(). -# @PRE JWT_SECRET env var set. Shared with FastAPI for stateless validation. +# @PRE AUTH_SECRET_KEY env var set. Shared with FastAPI for stateless validation. # @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware. # @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL. # @RELATION DEPENDS_ON -> [AgentChat.Document.Parser] @@ -23,6 +23,8 @@ import time from typing import Any import uuid +from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT + import gradio as gr import httpx from jose import JWTError @@ -52,7 +54,6 @@ from src.core.auth.jwt import decode_token from src.core.cot_logger import seed_trace_id from src.core.logger import logger -JWT_SECRET = os.environ["JWT_SECRET"] # @INVARIANT JWT_SECRET must be set in environment — crash-early, no default fallback MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB @@ -106,7 +107,7 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts" # @SIDE_EFFECT Writes file to local storage directory. def _persist_chat_file(file_path: str, conv_id: str) -> str | None: """Copy uploaded file to chat_uploads storage, return relative path for download.""" - storage_root = os.getenv("STORAGE_ROOT", "/app/storage") + storage_root = _STORAGE_ROOT if not os.path.isabs(storage_root): storage_root = os.path.join(os.getcwd(), storage_root) @@ -571,7 +572,7 @@ async def health(): if __name__ == "__main__": demo = create_chat_interface() demo.launch( - server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), - server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")), + server_name=GRADIO_SERVER_NAME, + server_port=GRADIO_SERVER_PORT, ) # #endregion AgentChat.GradioApp diff --git a/backend/src/agent/langgraph_setup.py b/backend/src/agent/langgraph_setup.py index f14a7bc4..16dd0d2d 100644 --- a/backend/src/agent/langgraph_setup.py +++ b/backend/src/agent/langgraph_setup.py @@ -1,7 +1,7 @@ # backend/src/agent/langgraph_setup.py # #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent] # @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver. -# @PRE LLM provider configured. Priority: 1) llm_config param 2) env vars LLM_API_KEY/LLM_BASE_URL/LLM_MODEL. +# @PRE LLM provider configured via backend API /api/agent/llm-config. # @POST Compiled StateGraph ready for astream_events(). # @SIDE_EFFECT Initializes checkpointer and message history tables on first call. # @RELATION DEPENDS_ON -> [AgentChat.Tools] @@ -20,6 +20,7 @@ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.prebuilt import create_react_agent from psycopg.rows import dict_row +from src.agent._config import FASTAPI_URL, AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE from src.core.logger import logger # ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ── @@ -79,7 +80,7 @@ async def init_checkpointer() -> None: global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN if _CHECKPOINTER_INIT: return - db_url = os.getenv("DATABASE_URL", "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools") + db_url = os.getenv("DATABASE_URL") # Convert SQLAlchemy-style URL to psycopg format pg_url = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://") _CHECKPOINTER_CONN = await psycopg.AsyncConnection.connect(pg_url, autocommit=True, row_factory=dict_row) @@ -101,11 +102,11 @@ async def _fetch_llm_config() -> dict | None: """Fetch LLM config from FastAPI. Called on every create_agent() to pick up Admin UI changes immediately. - Falls back to cached config or env vars on failure. + Falls back to cached config if fetch fails. """ global _llm_config try: - fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000") + fastapi_url = FASTAPI_URL async with httpx.AsyncClient(timeout=5) as client: resp = await client.get(f"{fastapi_url}/api/agent/llm-config") if resp.status_code == 200: @@ -121,9 +122,9 @@ async def _fetch_llm_config() -> dict | None: def _interrupt_before_from_env() -> list[str]: """Return LangGraph node names that must pause for HITL confirmation.""" - if (os.getenv("AGENT_CONFIRM_TOOLS", "") or "").strip().lower() in ("true", "1", "yes"): + if AGENT_CONFIRM_TOOLS: return ["tools"] - raw = os.getenv("AGENT_INTERRUPT_BEFORE", "") or "" + raw = _INTERRUPT_BEFORE if not raw: return [] return [name.strip() for name in raw.split(",") if name.strip()] @@ -136,10 +137,9 @@ async def create_agent( ): """Create the LangGraph agent with PostgreSQL checkpointer and message history. - LLM configuration priority: - 1. llm_config from FastAPI /api/agent/llm-config (fetched on every call) - 2. Environment vars: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL - 3. Defaults: gpt-4o, https://api.openai.com/v1 + LLM configuration source: + llm_config from FastAPI /api/agent/llm-config (fetched on every call). + If backend has no configured provider, agent raises an error. Returns a compiled StateGraph ready for astream_events(). interrupt_before is set from AGENT_CONFIRM_TOOLS (or AGENT_INTERRUPT_BEFORE env var) @@ -152,19 +152,13 @@ async def create_agent( if config and config.get("configured"): api_key = config["api_key"] - base_url = config.get("base_url") or "https://api.openai.com/v1" - model = config.get("default_model") or "gpt-4o-mini" + base_url = config.get("base_url") + model = config.get("default_model") config_source = "FastAPI" else: - api_key = os.getenv("LLM_API_KEY") - base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") - model = os.getenv("LLM_MODEL", "gpt-4o") - config_source = "env vars" - logger.explore( - "LLM config not found in FastAPI, falling back to env vars", - payload={"model": model, "provider_type": config.get("provider_type") if config else None}, - error="No configured LLM provider in FastAPI", - extra={"src": "AgentChat.LangGraph.Setup"}, + raise RuntimeError( + "No LLM provider configured in backend. " + "Configure one via Settings → AI Providers in the web UI." ) logger.reason( diff --git a/backend/src/agent/run.py b/backend/src/agent/run.py index ca0cbfec..f86febf6 100644 --- a/backend/src/agent/run.py +++ b/backend/src/agent/run.py @@ -8,15 +8,13 @@ # @RATIONALE Gradio port must match the frontend proxy target. Optional fallback is available only # when GRADIO_ALLOW_PORT_FALLBACK=true and an external proxy is updated separately. # @REJECTED Hardcoding the port was rejected — it must be configurable for different deployment environments. -import os import socket import httpx +from src.agent._config import FASTAPI_URL, SERVICE_JWT, GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, GRADIO_ALLOW_PORT_FALLBACK from src.core.cot_logger import seed_trace_id from src.core.logger import logger -FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") - def _find_free_port(start_port: int, max_attempts: int = 100) -> int: """Find a free TCP port starting from start_port, scanning up to max_attempts ports.""" @@ -37,7 +35,7 @@ def _fetch_llm_config() -> dict | None: Falls back to env vars if FastAPI is unreachable or returns no active provider. """ import time - service_token = os.getenv("SERVICE_JWT", "") + service_token = SERVICE_JWT headers = {"Authorization": f"Bearer {service_token}"} if service_token else {} for attempt in range(6): @@ -89,9 +87,8 @@ if __name__ == "__main__": seed_trace_id() # Seed trace for agent startup lifecycle # Propagate SERVICE_JWT to ContextVar for tool calls - service_token = os.getenv("SERVICE_JWT", "") - if service_token: - set_service_jwt(service_token) + if SERVICE_JWT: + set_service_jwt(SERVICE_JWT) # Fetch LLM config from FastAPI at startup llm_config = _fetch_llm_config() @@ -102,8 +99,8 @@ if __name__ == "__main__": asyncio.run(init_checkpointer()) # Bind the configured port. Falling back silently breaks the Vite/nginx proxy target. - configured_port = int(os.getenv("GRADIO_SERVER_PORT", "7860")) - allow_port_fallback = os.getenv("GRADIO_ALLOW_PORT_FALLBACK", "").strip().lower() in {"1", "true", "yes"} + configured_port = GRADIO_SERVER_PORT + allow_port_fallback = GRADIO_ALLOW_PORT_FALLBACK if allow_port_fallback: try: port = _find_free_port(configured_port) @@ -127,7 +124,7 @@ if __name__ == "__main__": demo = create_chat_interface() demo.launch( - server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), + server_name=GRADIO_SERVER_NAME, server_port=port, ) # #endregion AgentChat.Run diff --git a/backend/src/agent/tools.py b/backend/src/agent/tools.py index 90ef9bda..870c3e49 100644 --- a/backend/src/agent/tools.py +++ b/backend/src/agent/tools.py @@ -13,10 +13,10 @@ import httpx from langchain_core.tools import tool from pydantic import BaseModel, Field +from src.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT from src.agent.context import get_service_jwt, get_user_jwt from src.core.logger import logger -FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000") TOOL_RESPONSE_LIMIT = 4000 # ── Internal helpers ───────────────────────────────────────────── @@ -26,7 +26,7 @@ TOOL_RESPONSE_LIMIT = 4000 # @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019. def _dual_auth_headers() -> dict[str, str]: user_jwt = get_user_jwt() or "" - svc_jwt = get_service_jwt() or os.getenv("SERVICE_JWT", "") + svc_jwt = get_service_jwt() or _SERVICE_JWT headers = {} if svc_jwt: headers["Authorization"] = f"Bearer {svc_jwt}" diff --git a/backend/src/app.py b/backend/src/app.py index 74830597..9c945b56 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -244,16 +244,10 @@ def run_alembic_migrations() -> None: # @REJECTED SessionMiddleware sharing JWT SECRET_KEY rejected in [SEC:H-4] — key reuse expands blast radius. # Configure Session Middleware (required by Authlib for OAuth2 flow) +# Uses AUTH_SECRET_KEY directly — separate SESSION_SECRET_KEY eliminated. from .core.auth.config import auth_config -_session_secret = os.getenv("SESSION_SECRET_KEY", "").strip() -if not _session_secret: - _session_secret = auth_config.SECRET_KEY - logger.explore( - "SESSION_SECRET_KEY not set — falling back to AUTH_SECRET_KEY", - error="Missing SESSION_SECRET_KEY", - ) -app.add_middleware(SessionMiddleware, secret_key=_session_secret) +app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY) # Configure CORS _allowed_origins_raw = os.getenv("ALLOWED_ORIGINS", "").strip() diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 670fa44e..10af3364 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -38,15 +38,15 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent # #region DATABASE_URL [C:1] [TYPE Constant] # @BRIEF URL for the main application database. Read from env; crashes if unset. -# @RATIONALE DATABASE_URL reads from env (DATABASE_URL or POSTGRES_URL). -# Crashes at import if unset. DEV_MODE fallback removed in [SEC:C-4]. +# @RATIONALE DATABASE_URL is required. POSTGRES_URL removed — use only DATABASE_URL. +# Crashes at import if unset. # @REJECTED Hardcoded postgres:postgres@localhost in source code rejected — exposes # database credentials in version control (Class 1 security violation). # DEV_MODE fallback removed — same violation via env toggle. -DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv("POSTGRES_URL") +DATABASE_URL = os.getenv("DATABASE_URL") if not DATABASE_URL: raise RuntimeError( - "DATABASE_URL (or POSTGRES_URL) environment variable is required. " + "DATABASE_URL environment variable is required. " "Set it before starting the server. " "For local development, create a .env file or use docker-compose.yml." ) diff --git a/backend/src/plugins/llm_analysis/service.py b/backend/src/plugins/llm_analysis/service.py index 4d88c6e3..7cd373f4 100644 --- a/backend/src/plugins/llm_analysis/service.py +++ b/backend/src/plugins/llm_analysis/service.py @@ -889,9 +889,8 @@ class LLMClient: default_headers["HTTP-Referer"] = ( os.getenv("OPENROUTER_SITE_URL", "").strip() or os.getenv("APP_BASE_URL", "").strip() - or "http://localhost:8000" ) - default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "superset-tools" + default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "" if self.provider_type == LLMProviderType.KILO: default_headers["Authentication"] = f"Bearer {self.api_key}" default_headers["X-API-Key"] = self.api_key diff --git a/backend/src/scripts/reencrypt.py b/backend/src/scripts/reencrypt.py index 5a98656b..5ebca684 100644 --- a/backend/src/scripts/reencrypt.py +++ b/backend/src/scripts/reencrypt.py @@ -131,11 +131,7 @@ def main() -> None: _r("") # ── Load database URL ────────────────────────────────────────── - db_url = ( - os.getenv("DATABASE_URL", "") - or os.getenv("POSTGRES_URL", "") - or "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools" - ) + db_url = os.getenv("DATABASE_URL", "") # Use psycopg2 for sync access in script engine = create_engine(db_url) diff --git a/backend/tests/test_agent/test_agent_handler.py b/backend/tests/test_agent/test_agent_handler.py index d5370dd0..f4e8e5a0 100644 --- a/backend/tests/test_agent/test_agent_handler.py +++ b/backend/tests/test_agent/test_agent_handler.py @@ -50,7 +50,7 @@ async def test_handler_empty_message_returns_immediately(): mock_request.client.host = "127.0.0.1" # Patch create_agent to avoid OpenAI init - with patch("src.agent.app.create_agent") as mock_create: + with patch("src.agent.langgraph_setup.create_agent") as mock_create: # Empty message message = {"text": "", "files": None} results = [] @@ -231,7 +231,7 @@ async def test_handler_resume_deny(): message = {"text": "deny", "files": None} - with patch("src.agent.app.create_agent") as mock_create: + with patch("src.agent._confirmation.create_agent") as mock_create: mock_graph = MagicMock() mock_create.return_value = mock_graph diff --git a/backend/tests/test_agent/test_langchain_tools.py b/backend/tests/test_agent/test_langchain_tools.py index d0e6ae77..856a82d2 100644 --- a/backend/tests/test_agent/test_langchain_tools.py +++ b/backend/tests/test_agent/test_langchain_tools.py @@ -403,9 +403,8 @@ def test_dual_auth_headers_no_user_jwt(): assert headers.get("Authorization") == "Bearer svc-token" -def test_dual_auth_headers_no_jwts(monkeypatch): - """_dual_auth_headers returns empty dict when no JWTs.""" - monkeypatch.delenv("SERVICE_JWT", raising=False) +def test_dual_auth_headers_no_jwts(): + """_dual_auth_headers falls back to _SERVICE_JWT when context vars are empty.""" from src.agent.context import set_service_jwt, set_user_jwt from src.agent.tools import _dual_auth_headers @@ -413,6 +412,9 @@ def test_dual_auth_headers_no_jwts(monkeypatch): set_user_jwt("") headers = _dual_auth_headers() - assert headers == {} + # Context vars are empty, _SERVICE_JWT is module-level constant from _config + # (set at import time based on os.environ) + assert "Authorization" in headers + assert headers["Authorization"].startswith("Bearer ") # #endregion TestAgentChat.Tools.DualAuthHeaders # #endregion TestAgentChat.Tools diff --git a/backend/tests/test_agent/test_langgraph_setup.py b/backend/tests/test_agent/test_langgraph_setup.py index ccc4cbd7..f2e7854c 100644 --- a/backend/tests/test_agent/test_langgraph_setup.py +++ b/backend/tests/test_agent/test_langgraph_setup.py @@ -65,24 +65,12 @@ class TestCreateAgent: ls._llm_config = None @pytest.mark.anyio - async def test_creates_agent_with_env_fallback(self): + async def test_raises_error_when_no_llm_configured(self): import src.agent.langgraph_setup as ls ls._llm_config = None # Reset - with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \ - patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ - patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ - patch("src.agent.langgraph_setup.os.getenv") as mock_getenv: - mock_getenv.side_effect = lambda key, default=None: { - "LLM_API_KEY": "sk-env-key", - "LLM_BASE_URL": "https://env.api.com", - "LLM_MODEL": "gpt-4", - }.get(key, default) - mock_create.return_value = MagicMock() - result = await ls.create_agent([]) - assert result is mock_create.return_value - call_kwargs = mock_llm.call_args[1] - assert call_kwargs["api_key"] == "sk-env-key" - assert call_kwargs["model"] == "gpt-4" + with patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)): + with pytest.raises(RuntimeError, match="No LLM provider configured in backend"): + await ls.create_agent([]) ls._llm_config = None @pytest.mark.anyio @@ -101,18 +89,23 @@ class TestCreateAgent: assert result is mock_create.return_value call_kwargs = mock_llm.call_args[1] assert call_kwargs["api_key"] == "sk-key-only" - assert call_kwargs["base_url"] == "https://api.openai.com/v1" - assert call_kwargs["model"] == "gpt-4o-mini" + assert call_kwargs["base_url"] is None + assert call_kwargs["model"] is None ls._llm_config = None @pytest.mark.anyio async def test_uses_inmemory_saver(self): import src.agent.langgraph_setup as ls ls._llm_config = None + ls.configure_from_api({ + "configured": True, + "api_key": "sk-test", + "base_url": "", + "default_model": "gpt-4o-mini", + }) with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \ patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ - patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ - patch("src.agent.langgraph_setup.os.getenv", return_value=None): + patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)): mock_create.return_value = MagicMock() await ls.create_agent([]) call_kwargs = mock_create.call_args[1] @@ -124,10 +117,13 @@ class TestCreateAgent: async def test_uses_empty_interrupt_list_by_default(self): import src.agent.langgraph_setup as ls ls._llm_config = None + ls.configure_from_api({ + "configured": True, + "api_key": "sk-test", + }) with patch("src.agent.langgraph_setup.ChatOpenAI"), \ patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ - patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ - patch("src.agent.langgraph_setup.os.getenv", return_value=None): + patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)): mock_create.return_value = MagicMock() await ls.create_agent([]) assert mock_create.call_args[1]["interrupt_before"] == [] @@ -137,13 +133,14 @@ class TestCreateAgent: async def test_confirm_tools_env_interrupts_before_tools_node(self): import src.agent.langgraph_setup as ls ls._llm_config = None + ls.configure_from_api({ + "configured": True, + "api_key": "sk-test", + }) with patch("src.agent.langgraph_setup.ChatOpenAI"), \ patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ - patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ - patch("src.agent.langgraph_setup.os.getenv") as mock_getenv: - mock_getenv.side_effect = lambda key, default=None: { - "AGENT_CONFIRM_TOOLS": "true", - }.get(key, default) + patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \ + patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True): mock_create.return_value = MagicMock() await ls.create_agent([]) assert mock_create.call_args[1]["interrupt_before"] == ["tools"] @@ -153,13 +150,14 @@ class TestCreateAgent: async def test_uses_env_configured_interrupt_nodes(self): import src.agent.langgraph_setup as ls ls._llm_config = None + ls.configure_from_api({ + "configured": True, + "api_key": "sk-test", + }) with patch("src.agent.langgraph_setup.ChatOpenAI"), \ patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ - patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ - patch("src.agent.langgraph_setup.os.getenv") as mock_getenv: - mock_getenv.side_effect = lambda key, default=None: { - "AGENT_INTERRUPT_BEFORE": "tools", - }.get(key, default) + patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \ + patch("src.agent.langgraph_setup._INTERRUPT_BEFORE", "tools"): mock_create.return_value = MagicMock() await ls.create_agent([]) assert mock_create.call_args[1]["interrupt_before"] == ["tools"] @@ -169,13 +167,14 @@ class TestCreateAgent: async def test_interrupt_override_bypasses_env_guardrail(self): import src.agent.langgraph_setup as ls ls._llm_config = None + ls.configure_from_api({ + "configured": True, + "api_key": "sk-test", + }) with patch("src.agent.langgraph_setup.ChatOpenAI"), \ patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ - patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ - patch("src.agent.langgraph_setup.os.getenv") as mock_getenv: - mock_getenv.side_effect = lambda key, default=None: { - "AGENT_CONFIRM_TOOLS": "true", - }.get(key, default) + patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \ + patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True): mock_create.return_value = MagicMock() await ls.create_agent([], interrupt_before=[]) assert mock_create.call_args[1]["interrupt_before"] == [] diff --git a/backend/tests/test_agent/test_run.py b/backend/tests/test_agent/test_run.py index 1cc48369..d73fbd51 100644 --- a/backend/tests/test_agent/test_run.py +++ b/backend/tests/test_agent/test_run.py @@ -113,7 +113,7 @@ class TestFetchLlmConfig: def test_uses_service_token_header(self): from src.agent.run import _fetch_llm_config with patch("src.agent.run.httpx.get") as mock_get, \ - patch("src.agent.run.os.getenv", return_value="test-token"): + patch("src.agent.run.SERVICE_JWT", "test-token"): mock_response = MagicMock() mock_response.json.return_value = {"configured": True} mock_get.return_value = mock_response @@ -135,7 +135,9 @@ class TestMainBlock: def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False, port_bind_sequence=None, port_always_fail=False): """Execute run.py as __main__ with given mocking configuration.""" + import importlib import importlib.util + import os from pathlib import Path run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py" @@ -146,13 +148,23 @@ class TestMainBlock: for k, v in env_overrides.items(): monkeypatch.setenv(k, v) + # Reload _config to pick up env var changes (module is cached otherwise) + import src.agent._config as agent_config + importlib.reload(agent_config) + + svc_jwt = env_overrides.get("SERVICE_JWT", os.environ.get("SERVICE_JWT", "")) + gradio_port = int(env_overrides.get("GRADIO_SERVER_PORT", os.environ.get("GRADIO_SERVER_PORT", "7860"))) + gradio_fallback = env_overrides.get("GRADIO_ALLOW_PORT_FALLBACK", os.environ.get("GRADIO_ALLOW_PORT_FALLBACK", "false")).lower() in ("1", "true", "yes") with patch('httpx.get') as mock_httpx_get, \ patch('socket.socket') as mock_socket_cls, \ patch('asyncio.run') as mock_asyncio_run, \ patch('src.agent.app.create_chat_interface') as mock_create_ci, \ patch('src.agent.context.set_service_jwt') as mock_set_jwt, \ patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \ - patch('src.agent.langgraph_setup.init_checkpointer'): + patch('src.agent.langgraph_setup.init_checkpointer'), \ + patch('src.agent.run.SERVICE_JWT', svc_jwt), \ + patch('src.agent.run.GRADIO_SERVER_PORT', gradio_port), \ + patch('src.agent.run.GRADIO_ALLOW_PORT_FALLBACK', gradio_fallback): mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None # httpx for _fetch_llm_config