cleanup: убрать мёртвые env-переменные, консолидировать чтение в agent/_config.py

Удалены из кода:
- 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)
This commit is contained in:
2026-07-04 14:58:43 +03:00
parent b1efc38306
commit ece8d7d256
16 changed files with 163 additions and 179 deletions

View File

@@ -1,75 +1,38 @@
# ====================================================================== # ======================================================================
# superset-tools — Переменные окружения # superset-tools — Переменные окружения
# Скопируйте в .env и заполните значения # Только обязательные + docker. Остальное настраивается через web UI.
#
# Полный каталог: см. backend/src/core/auth/config.py,
# backend/src/core/database.py, backend/src/app.py
# ====================================================================== # ======================================================================
# --- Аутентификация и безопасность (ОБЯЗАТЕЛЬНО) --- # --- Обязательно: секреты ---
AUTH_SECRET_KEY= # JWT-ключ подписи токенов (обязательно, без него сервер не стартует) AUTH_SECRET_KEY= # JWT-ключ подписи токенов (обязательно, без него сервер не стартует)
ALLOWED_ORIGINS=* # CORS: список доменов через запятую (по умолчанию *; для прода — явный список) ENCRYPTION_KEY= # Fernet-ключ шифрования (обязательно)
# --- Базы данных --- # --- Обязательно: базы данных ---
DATABASE_URL= # Основная БД (обязательно для production) DATABASE_URL= # Основная БД (обязательно для production)
AUTH_DATABASE_URL= # БД аутентификации (если не задан — fallback на DATABASE_URL) AUTH_DATABASE_URL= # БД аутентификации (если не задан — требуется явно)
TASKS_DATABASE_URL= # БД задач (если не задан — fallback на 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 SSO (опционально) ---
ADFS_CLIENT_ID= # Client ID для ADFS (если не задан — ADFS отключён) ADFS_CLIENT_ID= # Client ID для ADFS (если не задан — ADFS отключён)
ADFS_CLIENT_SECRET= # Client Secret для 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_USERNAME=admin # Логин администратора
INITIAL_ADMIN_PASSWORD= # Пароль (обязателен при INITIAL_ADMIN_CREATE=true) INITIAL_ADMIN_PASSWORD= # Пароль (обязателен при INITIAL_ADMIN_CREATE=true)
INITIAL_ADMIN_EMAIL= # Email администратора (опционально) INITIAL_ADMIN_EMAIL= # Email администратора (опционально)
# --- AI / LLM API ключи (опционально) --- # --- Порты (Docker) ---
OPENAI_API_KEY= # OpenAI API key BACKEND_HOST_PORT=8001 # Внешний порт бэкенда на хосте
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 # Внешний порт бэкенда на хосте (маппинг контейнера)
FRONTEND_HOST_PORT=8000 # Внешний порт фронтенда на хосте FRONTEND_HOST_PORT=8000 # Внешний порт фронтенда на хосте
BACKEND_PORT=8000 # Внутренний порт бэкенда в контейнере
FRONTEND_PORT=5173 # Порт dev-сервера SvelteKit
FRONTEND_SSL_PORT=443 # SSL-порт для фронтенда (nginx)
# --- Сертификаты ---
CERTS_PATH=./certs # Путь к директории с сертификатами (монтируется в контейнер)
# --- PostgreSQL (прямое подключение, без DATABASE_URL) --- # --- PostgreSQL (прямое подключение, без DATABASE_URL) ---
POSTGRES_HOST=localhost # Хост PostgreSQL POSTGRES_HOST=localhost
POSTGRES_PORT=5432 # Порт PostgreSQL POSTGRES_PORT=5432
POSTGRES_DB=ss_tools # Имя БД POSTGRES_DB=ss_tools
POSTGRES_USER=postgres # Пользователь POSTGRES_USER=postgres
POSTGRES_PASSWORD= # Пароль (обязателен для production) POSTGRES_PASSWORD=
# --- Логирование ---
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)
# --- Docker Compose --- # --- Docker Compose ---
COMPOSE_PROJECT_NAME=superset-tools # Имя Docker Compose проекта COMPOSE_PROJECT_NAME=superset-tools

View File

@@ -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 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= ENCRYPTION_KEY=
# ── Required: Database ── # ── Обязательно: база данных ──
# PostgreSQL via docker-compose: # PostgreSQL
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools
# Отдельная БД для аутентификации (если не задана — требуется явно)
AUTH_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 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

View File

@@ -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

View File

@@ -15,9 +15,10 @@ from typing import Any
import httpx 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 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 TITLE_MAX_LENGTH = 80
# ── Rule-based title cleaning ──────────────────────────────────── # ── 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 # Patch the title via the same save endpoint
try: try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if service_token: if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {service_token}" headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
payload = { payload = {
"conversation_id": conv_id, "conversation_id": conv_id,
"title": title, "title": title,
@@ -308,7 +308,7 @@ async def prefetch_dashboards(env_id: str) -> str:
dashboards = data.get("dashboards", []) dashboards = data.get("dashboards", [])
if not dashboards: if not dashboards:
return "No dashboards found." return "No dashboards found."
limit = int(os.getenv("AGENT_PREFETCH_DASHBOARD_LIMIT", "25")) limit = _PREFETCH_LIMIT
total = len(dashboards) total = len(dashboards)
lines = [] lines = []
for db in dashboards[:limit]: 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. # @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: async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:
try: try:
service_token = os.getenv("SERVICE_JWT", "")
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if service_token: if _SERVICE_JWT:
headers["Authorization"] = f"Bearer {service_token}" headers["Authorization"] = f"Bearer {_SERVICE_JWT}"
# Normalize user_id: anonymous Gradio sessions use "anon_" prefix # Normalize user_id: anonymous Gradio sessions use "anon_" prefix
if not user_id or user_id.startswith("anon_"): if not user_id or user_id.startswith("anon_"):

View File

@@ -1,7 +1,7 @@
# backend/src/agent/app.py # backend/src/agent/app.py
# #region AgentChat.GradioApp [C:4] [TYPE Module] [SEMANTICS agent-chat,gradio,app] # #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(). # @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. # @POST Agent streams tokens via Gradio yield; audit logged via LoggingMiddleware.
# @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL. # @SIDE_EFFECT Calls LLM, invokes tools via FastAPI REST, writes checkpoints to PostgreSQL.
# @RELATION DEPENDS_ON -> [AgentChat.Document.Parser] # @RELATION DEPENDS_ON -> [AgentChat.Document.Parser]
@@ -23,6 +23,8 @@ import time
from typing import Any from typing import Any
import uuid import uuid
from src.agent._config import GRADIO_SERVER_NAME, GRADIO_SERVER_PORT, STORAGE_ROOT as _STORAGE_ROOT
import gradio as gr import gradio as gr
import httpx import httpx
from jose import JWTError 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.cot_logger import seed_trace_id
from src.core.logger import logger 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 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. # @SIDE_EFFECT Writes file to local storage directory.
def _persist_chat_file(file_path: str, conv_id: str) -> str | None: def _persist_chat_file(file_path: str, conv_id: str) -> str | None:
"""Copy uploaded file to chat_uploads storage, return relative path for download.""" """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): if not os.path.isabs(storage_root):
storage_root = os.path.join(os.getcwd(), storage_root) storage_root = os.path.join(os.getcwd(), storage_root)
@@ -571,7 +572,7 @@ async def health():
if __name__ == "__main__": if __name__ == "__main__":
demo = create_chat_interface() demo = create_chat_interface()
demo.launch( demo.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), server_name=GRADIO_SERVER_NAME,
server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")), server_port=GRADIO_SERVER_PORT,
) )
# #endregion AgentChat.GradioApp # #endregion AgentChat.GradioApp

View File

@@ -1,7 +1,7 @@
# backend/src/agent/langgraph_setup.py # backend/src/agent/langgraph_setup.py
# #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent] # #region AgentChat.LangGraph.Setup [C:4] [TYPE Module] [SEMANTICS agent-chat,langgraph,agent]
# @defgroup AgentChat LangGraph agent setup: create_react_agent with PostgresSaver. # @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(). # @POST Compiled StateGraph ready for astream_events().
# @SIDE_EFFECT Initializes checkpointer and message history tables on first call. # @SIDE_EFFECT Initializes checkpointer and message history tables on first call.
# @RELATION DEPENDS_ON -> [AgentChat.Tools] # @RELATION DEPENDS_ON -> [AgentChat.Tools]
@@ -20,6 +20,7 @@ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.prebuilt import create_react_agent from langgraph.prebuilt import create_react_agent
from psycopg.rows import dict_row 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 from src.core.logger import logger
# ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ── # ── Monkey-patch: OpenAI SDK for Pydantic BaseModel classes ──
@@ -79,7 +80,7 @@ async def init_checkpointer() -> None:
global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN
if _CHECKPOINTER_INIT: if _CHECKPOINTER_INIT:
return 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 # Convert SQLAlchemy-style URL to psycopg format
pg_url = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://") 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) _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. """Fetch LLM config from FastAPI.
Called on every create_agent() to pick up Admin UI changes immediately. 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 global _llm_config
try: try:
fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000") fastapi_url = FASTAPI_URL
async with httpx.AsyncClient(timeout=5) as client: async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{fastapi_url}/api/agent/llm-config") resp = await client.get(f"{fastapi_url}/api/agent/llm-config")
if resp.status_code == 200: if resp.status_code == 200:
@@ -121,9 +122,9 @@ async def _fetch_llm_config() -> dict | None:
def _interrupt_before_from_env() -> list[str]: def _interrupt_before_from_env() -> list[str]:
"""Return LangGraph node names that must pause for HITL confirmation.""" """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"] return ["tools"]
raw = os.getenv("AGENT_INTERRUPT_BEFORE", "") or "" raw = _INTERRUPT_BEFORE
if not raw: if not raw:
return [] return []
return [name.strip() for name in raw.split(",") if name.strip()] 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. """Create the LangGraph agent with PostgreSQL checkpointer and message history.
LLM configuration priority: LLM configuration source:
1. llm_config from FastAPI /api/agent/llm-config (fetched on every call) llm_config from FastAPI /api/agent/llm-config (fetched on every call).
2. Environment vars: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL If backend has no configured provider, agent raises an error.
3. Defaults: gpt-4o, https://api.openai.com/v1
Returns a compiled StateGraph ready for astream_events(). Returns a compiled StateGraph ready for astream_events().
interrupt_before is set from AGENT_CONFIRM_TOOLS (or AGENT_INTERRUPT_BEFORE env var) 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"): if config and config.get("configured"):
api_key = config["api_key"] api_key = config["api_key"]
base_url = config.get("base_url") or "https://api.openai.com/v1" base_url = config.get("base_url")
model = config.get("default_model") or "gpt-4o-mini" model = config.get("default_model")
config_source = "FastAPI" config_source = "FastAPI"
else: else:
api_key = os.getenv("LLM_API_KEY") raise RuntimeError(
base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") "No LLM provider configured in backend. "
model = os.getenv("LLM_MODEL", "gpt-4o") "Configure one via Settings → AI Providers in the web UI."
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"},
) )
logger.reason( logger.reason(

View File

@@ -8,15 +8,13 @@
# @RATIONALE Gradio port must match the frontend proxy target. Optional fallback is available only # @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. # 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. # @REJECTED Hardcoding the port was rejected — it must be configurable for different deployment environments.
import os
import socket import socket
import httpx 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.cot_logger import seed_trace_id
from src.core.logger import logger 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: 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.""" """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. Falls back to env vars if FastAPI is unreachable or returns no active provider.
""" """
import time import time
service_token = os.getenv("SERVICE_JWT", "") service_token = SERVICE_JWT
headers = {"Authorization": f"Bearer {service_token}"} if service_token else {} headers = {"Authorization": f"Bearer {service_token}"} if service_token else {}
for attempt in range(6): for attempt in range(6):
@@ -89,9 +87,8 @@ if __name__ == "__main__":
seed_trace_id() # Seed trace for agent startup lifecycle seed_trace_id() # Seed trace for agent startup lifecycle
# Propagate SERVICE_JWT to ContextVar for tool calls # Propagate SERVICE_JWT to ContextVar for tool calls
service_token = os.getenv("SERVICE_JWT", "") if SERVICE_JWT:
if service_token: set_service_jwt(SERVICE_JWT)
set_service_jwt(service_token)
# Fetch LLM config from FastAPI at startup # Fetch LLM config from FastAPI at startup
llm_config = _fetch_llm_config() llm_config = _fetch_llm_config()
@@ -102,8 +99,8 @@ if __name__ == "__main__":
asyncio.run(init_checkpointer()) asyncio.run(init_checkpointer())
# Bind the configured port. Falling back silently breaks the Vite/nginx proxy target. # Bind the configured port. Falling back silently breaks the Vite/nginx proxy target.
configured_port = int(os.getenv("GRADIO_SERVER_PORT", "7860")) configured_port = GRADIO_SERVER_PORT
allow_port_fallback = os.getenv("GRADIO_ALLOW_PORT_FALLBACK", "").strip().lower() in {"1", "true", "yes"} allow_port_fallback = GRADIO_ALLOW_PORT_FALLBACK
if allow_port_fallback: if allow_port_fallback:
try: try:
port = _find_free_port(configured_port) port = _find_free_port(configured_port)
@@ -127,7 +124,7 @@ if __name__ == "__main__":
demo = create_chat_interface() demo = create_chat_interface()
demo.launch( demo.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), server_name=GRADIO_SERVER_NAME,
server_port=port, server_port=port,
) )
# #endregion AgentChat.Run # #endregion AgentChat.Run

View File

@@ -13,10 +13,10 @@ import httpx
from langchain_core.tools import tool from langchain_core.tools import tool
from pydantic import BaseModel, Field 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.agent.context import get_service_jwt, get_user_jwt
from src.core.logger import logger from src.core.logger import logger
FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
TOOL_RESPONSE_LIMIT = 4000 TOOL_RESPONSE_LIMIT = 4000
# ── Internal helpers ───────────────────────────────────────────── # ── 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. # @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
def _dual_auth_headers() -> dict[str, str]: def _dual_auth_headers() -> dict[str, str]:
user_jwt = get_user_jwt() or "" 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 = {} headers = {}
if svc_jwt: if svc_jwt:
headers["Authorization"] = f"Bearer {svc_jwt}" headers["Authorization"] = f"Bearer {svc_jwt}"

View File

@@ -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. # @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) # 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 from .core.auth.config import auth_config
_session_secret = os.getenv("SESSION_SECRET_KEY", "").strip() app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
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)
# Configure CORS # Configure CORS
_allowed_origins_raw = os.getenv("ALLOWED_ORIGINS", "").strip() _allowed_origins_raw = os.getenv("ALLOWED_ORIGINS", "").strip()

View File

@@ -38,15 +38,15 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent
# #region DATABASE_URL [C:1] [TYPE Constant] # #region DATABASE_URL [C:1] [TYPE Constant]
# @BRIEF URL for the main application database. Read from env; crashes if unset. # @BRIEF URL for the main application database. Read from env; crashes if unset.
# @RATIONALE DATABASE_URL reads from env (DATABASE_URL or POSTGRES_URL). # @RATIONALE DATABASE_URL is required. POSTGRES_URL removed — use only DATABASE_URL.
# Crashes at import if unset. DEV_MODE fallback removed in [SEC:C-4]. # Crashes at import if unset.
# @REJECTED Hardcoded postgres:postgres@localhost in source code rejected — exposes # @REJECTED Hardcoded postgres:postgres@localhost in source code rejected — exposes
# database credentials in version control (Class 1 security violation). # database credentials in version control (Class 1 security violation).
# DEV_MODE fallback removed — same violation via env toggle. # 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: if not DATABASE_URL:
raise RuntimeError( raise RuntimeError(
"DATABASE_URL (or POSTGRES_URL) environment variable is required. " "DATABASE_URL environment variable is required. "
"Set it before starting the server. " "Set it before starting the server. "
"For local development, create a .env file or use docker-compose.yml." "For local development, create a .env file or use docker-compose.yml."
) )

View File

@@ -889,9 +889,8 @@ class LLMClient:
default_headers["HTTP-Referer"] = ( default_headers["HTTP-Referer"] = (
os.getenv("OPENROUTER_SITE_URL", "").strip() os.getenv("OPENROUTER_SITE_URL", "").strip()
or os.getenv("APP_BASE_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: if self.provider_type == LLMProviderType.KILO:
default_headers["Authentication"] = f"Bearer {self.api_key}" default_headers["Authentication"] = f"Bearer {self.api_key}"
default_headers["X-API-Key"] = self.api_key default_headers["X-API-Key"] = self.api_key

View File

@@ -131,11 +131,7 @@ def main() -> None:
_r("") _r("")
# ── Load database URL ────────────────────────────────────────── # ── Load database URL ──────────────────────────────────────────
db_url = ( db_url = os.getenv("DATABASE_URL", "")
os.getenv("DATABASE_URL", "")
or os.getenv("POSTGRES_URL", "")
or "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools"
)
# Use psycopg2 for sync access in script # Use psycopg2 for sync access in script
engine = create_engine(db_url) engine = create_engine(db_url)

View File

@@ -50,7 +50,7 @@ async def test_handler_empty_message_returns_immediately():
mock_request.client.host = "127.0.0.1" mock_request.client.host = "127.0.0.1"
# Patch create_agent to avoid OpenAI init # 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 # Empty message
message = {"text": "", "files": None} message = {"text": "", "files": None}
results = [] results = []
@@ -231,7 +231,7 @@ async def test_handler_resume_deny():
message = {"text": "deny", "files": None} 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_graph = MagicMock()
mock_create.return_value = mock_graph mock_create.return_value = mock_graph

View File

@@ -403,9 +403,8 @@ def test_dual_auth_headers_no_user_jwt():
assert headers.get("Authorization") == "Bearer svc-token" assert headers.get("Authorization") == "Bearer svc-token"
def test_dual_auth_headers_no_jwts(monkeypatch): def test_dual_auth_headers_no_jwts():
"""_dual_auth_headers returns empty dict when no JWTs.""" """_dual_auth_headers falls back to _SERVICE_JWT when context vars are empty."""
monkeypatch.delenv("SERVICE_JWT", raising=False)
from src.agent.context import set_service_jwt, set_user_jwt from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers from src.agent.tools import _dual_auth_headers
@@ -413,6 +412,9 @@ def test_dual_auth_headers_no_jwts(monkeypatch):
set_user_jwt("") set_user_jwt("")
headers = _dual_auth_headers() 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.DualAuthHeaders
# #endregion TestAgentChat.Tools # #endregion TestAgentChat.Tools

View File

@@ -65,24 +65,12 @@ class TestCreateAgent:
ls._llm_config = None ls._llm_config = None
@pytest.mark.anyio @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 import src.agent.langgraph_setup as ls
ls._llm_config = None # Reset ls._llm_config = None # Reset
with patch("src.agent.langgraph_setup.ChatOpenAI") as mock_llm, \ with patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)):
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ with pytest.raises(RuntimeError, match="No LLM provider configured in backend"):
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ await ls.create_agent([])
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"
ls._llm_config = None ls._llm_config = None
@pytest.mark.anyio @pytest.mark.anyio
@@ -101,18 +89,23 @@ class TestCreateAgent:
assert result is mock_create.return_value assert result is mock_create.return_value
call_kwargs = mock_llm.call_args[1] call_kwargs = mock_llm.call_args[1]
assert call_kwargs["api_key"] == "sk-key-only" assert call_kwargs["api_key"] == "sk-key-only"
assert call_kwargs["base_url"] == "https://api.openai.com/v1" assert call_kwargs["base_url"] is None
assert call_kwargs["model"] == "gpt-4o-mini" assert call_kwargs["model"] is None
ls._llm_config = None ls._llm_config = None
@pytest.mark.anyio @pytest.mark.anyio
async def test_uses_inmemory_saver(self): async def test_uses_inmemory_saver(self):
import src.agent.langgraph_setup as ls import src.agent.langgraph_setup as ls
ls._llm_config = None 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, \ 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.create_react_agent") as mock_create, \
patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=None)), \ patch("src.agent.langgraph_setup._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
patch("src.agent.langgraph_setup.os.getenv", return_value=None):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
await ls.create_agent([]) await ls.create_agent([])
call_kwargs = mock_create.call_args[1] call_kwargs = mock_create.call_args[1]
@@ -124,10 +117,13 @@ class TestCreateAgent:
async def test_uses_empty_interrupt_list_by_default(self): async def test_uses_empty_interrupt_list_by_default(self):
import src.agent.langgraph_setup as ls import src.agent.langgraph_setup as ls
ls._llm_config = None ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \ with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ 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._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)):
patch("src.agent.langgraph_setup.os.getenv", return_value=None):
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
await ls.create_agent([]) await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == [] 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): async def test_confirm_tools_env_interrupts_before_tools_node(self):
import src.agent.langgraph_setup as ls import src.agent.langgraph_setup as ls
ls._llm_config = None ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \ with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ 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._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv: patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_getenv.side_effect = lambda key, default=None: {
"AGENT_CONFIRM_TOOLS": "true",
}.get(key, default)
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
await ls.create_agent([]) await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"] assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
@@ -153,13 +150,14 @@ class TestCreateAgent:
async def test_uses_env_configured_interrupt_nodes(self): async def test_uses_env_configured_interrupt_nodes(self):
import src.agent.langgraph_setup as ls import src.agent.langgraph_setup as ls
ls._llm_config = None ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \ with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ 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._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv: patch("src.agent.langgraph_setup._INTERRUPT_BEFORE", "tools"):
mock_getenv.side_effect = lambda key, default=None: {
"AGENT_INTERRUPT_BEFORE": "tools",
}.get(key, default)
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
await ls.create_agent([]) await ls.create_agent([])
assert mock_create.call_args[1]["interrupt_before"] == ["tools"] assert mock_create.call_args[1]["interrupt_before"] == ["tools"]
@@ -169,13 +167,14 @@ class TestCreateAgent:
async def test_interrupt_override_bypasses_env_guardrail(self): async def test_interrupt_override_bypasses_env_guardrail(self):
import src.agent.langgraph_setup as ls import src.agent.langgraph_setup as ls
ls._llm_config = None ls._llm_config = None
ls.configure_from_api({
"configured": True,
"api_key": "sk-test",
})
with patch("src.agent.langgraph_setup.ChatOpenAI"), \ with patch("src.agent.langgraph_setup.ChatOpenAI"), \
patch("src.agent.langgraph_setup.create_react_agent") as mock_create, \ 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._fetch_llm_config", new=AsyncMock(return_value=ls._llm_config)), \
patch("src.agent.langgraph_setup.os.getenv") as mock_getenv: patch("src.agent.langgraph_setup.AGENT_CONFIRM_TOOLS", True):
mock_getenv.side_effect = lambda key, default=None: {
"AGENT_CONFIRM_TOOLS": "true",
}.get(key, default)
mock_create.return_value = MagicMock() mock_create.return_value = MagicMock()
await ls.create_agent([], interrupt_before=[]) await ls.create_agent([], interrupt_before=[])
assert mock_create.call_args[1]["interrupt_before"] == [] assert mock_create.call_args[1]["interrupt_before"] == []

View File

@@ -113,7 +113,7 @@ class TestFetchLlmConfig:
def test_uses_service_token_header(self): def test_uses_service_token_header(self):
from src.agent.run import _fetch_llm_config from src.agent.run import _fetch_llm_config
with patch("src.agent.run.httpx.get") as mock_get, \ 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 = MagicMock()
mock_response.json.return_value = {"configured": True} mock_response.json.return_value = {"configured": True}
mock_get.return_value = mock_response mock_get.return_value = mock_response
@@ -135,7 +135,9 @@ class TestMainBlock:
def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False, def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False,
port_bind_sequence=None, port_always_fail=False): port_bind_sequence=None, port_always_fail=False):
"""Execute run.py as __main__ with given mocking configuration.""" """Execute run.py as __main__ with given mocking configuration."""
import importlib
import importlib.util import importlib.util
import os
from pathlib import Path from pathlib import Path
run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py" run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py"
@@ -146,13 +148,23 @@ class TestMainBlock:
for k, v in env_overrides.items(): for k, v in env_overrides.items():
monkeypatch.setenv(k, v) 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, \ with patch('httpx.get') as mock_httpx_get, \
patch('socket.socket') as mock_socket_cls, \ patch('socket.socket') as mock_socket_cls, \
patch('asyncio.run') as mock_asyncio_run, \ patch('asyncio.run') as mock_asyncio_run, \
patch('src.agent.app.create_chat_interface') as mock_create_ci, \ 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.context.set_service_jwt') as mock_set_jwt, \
patch('src.agent.langgraph_setup.configure_from_api') as mock_configure, \ 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 mock_asyncio_run.side_effect = lambda coro: coro.close() if hasattr(coro, "close") else None
# httpx for _fetch_llm_config # httpx for _fetch_llm_config