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 a99c1d6d01
commit ec9bc76a37
16 changed files with 163 additions and 179 deletions

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
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_"):

View File

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

View File

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

View File

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

View File

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

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.
# 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()

View File

@@ -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."
)

View File

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

View File

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