Reverts merge commits0b867a7(#405) and474f5e4(#416), and removes the now-orphaned #406 artifact metadata store (src/reliability + tests, zero references left outside its own tests). Why: the stack wrapped the tool registry in every session path (even governance mode=off) without exposing the registry surface AgentContext reads, breaking all session-runtime chats within a day (#433, #431). It also modified protected core files (src/agent/loop.py, src/session/ service.py), shipped 12 internal-convention docs/ files into the public tree, and its enforcement layer already has reported bypass paths (#420). Trading safety stays on the single mandate-gate design (#31, #144). README news entries for 2026-07-07/07-06 updated accordingly.
1102 lines
39 KiB
Python
1102 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
"""Vibe-Trading API Server - RESTful API for finance research and backtesting.
|
|
|
|
V5: ReAct Agent + async /run + CORS env + SSE tool events.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hmac
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import signal
|
|
import time
|
|
import csv
|
|
import uuid
|
|
import urllib.parse
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request, Security, status
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from pydantic import BaseModel, Field
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from rich.console import Console
|
|
|
|
from cli._version import __version__ as APP_VERSION
|
|
from src.ui_services import build_run_analysis, load_run_context
|
|
|
|
# UTF-8 on Windows
|
|
import sys as _sys
|
|
for _s in ("stdout", "stderr"):
|
|
_r = getattr(getattr(_sys, _s, None), "reconfigure", None)
|
|
if callable(_r):
|
|
_r(encoding="utf-8", errors="replace")
|
|
|
|
RUNS_DIR = Path(__file__).resolve().parent / "runs"
|
|
SESSIONS_DIR = Path(__file__).resolve().parent / "sessions"
|
|
UPLOADS_DIR = Path(__file__).resolve().parent / "uploads"
|
|
AGENT_DIR = Path(__file__).resolve().parent
|
|
ENV_PATH = AGENT_DIR / ".env"
|
|
ENV_EXAMPLE_PATH = AGENT_DIR / ".env.example"
|
|
|
|
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB
|
|
|
|
console = Console()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# Pydantic Models
|
|
# ============================================================================
|
|
|
|
class Artifact(BaseModel):
|
|
"""Artifact file metadata."""
|
|
name: str = Field(..., description="File name")
|
|
path: str = Field(..., description="File path")
|
|
type: str = Field(..., description="File type: csv, json, txt, etc.")
|
|
size: int = Field(..., description="Size in bytes")
|
|
exists: bool = Field(..., description="Whether the file exists")
|
|
|
|
|
|
class BacktestMetrics(BaseModel):
|
|
"""Backtest summary metrics."""
|
|
model_config = {"extra": "allow"}
|
|
|
|
final_value: float = Field(..., description="Ending portfolio value")
|
|
total_return: float = Field(..., description="Total return")
|
|
annual_return: float = Field(..., description="Annualized return")
|
|
max_drawdown: float = Field(..., description="Max drawdown")
|
|
sharpe: float = Field(..., description="Sharpe ratio")
|
|
win_rate: float = Field(..., description="Win rate")
|
|
trade_count: int = Field(..., description="Number of trades")
|
|
|
|
|
|
|
|
class RAGSelection(BaseModel):
|
|
"""RAG routing result."""
|
|
selected_api: str = Field(..., description="Selected API code")
|
|
selected_name: str = Field(..., description="Selected API name")
|
|
selected_score: float = Field(..., description="Match score")
|
|
|
|
|
|
class RunInfo(BaseModel):
|
|
"""Compact run row for list views."""
|
|
run_id: str
|
|
status: str
|
|
created_at: str
|
|
prompt: Optional[str] = None
|
|
total_return: Optional[float] = None
|
|
sharpe: Optional[float] = None
|
|
codes: List[str] = Field(default_factory=list)
|
|
start_date: Optional[str] = None
|
|
end_date: Optional[str] = None
|
|
|
|
|
|
class RunResponse(BaseModel):
|
|
"""API response payload for a single run."""
|
|
|
|
status: str = Field(..., description="Run status: success, failed, aborted")
|
|
run_id: str = Field(..., description="Run identifier")
|
|
elapsed_seconds: float = Field(..., description="Execution time in seconds")
|
|
reason: Optional[str] = Field(None, description="Failure reason when available")
|
|
|
|
planner_output: Optional[Dict[str, Any]] = Field(None, description="Planner output")
|
|
strategy_spec: Optional[Dict[str, Any]] = Field(None, description="Strategy specification")
|
|
rag_selection: Optional[RAGSelection] = Field(None, description="Selected RAG metadata")
|
|
|
|
metrics: Optional[BacktestMetrics] = Field(None, description="Backtest metrics")
|
|
artifacts: List[Artifact] = Field(default_factory=list, description="Run artifacts")
|
|
run_card: Optional[Dict[str, Any]] = Field(None, description="Trust Layer run card payload")
|
|
llm_usage: Optional[Dict[str, Any]] = Field(None, description="Provider-reported AgentLoop usage summary")
|
|
|
|
equity_curve: Optional[List[Dict[str, Any]]] = Field(None, description="Equity preview")
|
|
trade_log: Optional[List[Dict[str, Any]]] = Field(None, description="Trade preview")
|
|
|
|
artifacts_equity_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full equity rows")
|
|
artifacts_metrics_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full metrics rows")
|
|
artifacts_trades_csv: Optional[List[Dict[str, Any]]] = Field(None, description="Full trade rows")
|
|
validation: Optional[Dict[str, Any]] = Field(None, description="Statistical validation results")
|
|
|
|
run_directory: str = Field(..., description="Run directory path")
|
|
run_stage: Optional[str] = Field(None, description="UI-facing run stage")
|
|
run_context: Optional[Dict[str, Any]] = Field(None, description="Normalized request context")
|
|
price_series: Optional[Dict[str, List[Dict[str, Any]]]] = Field(None, description="Grouped OHLC series")
|
|
indicator_series: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = Field(
|
|
None,
|
|
description="Grouped indicator overlays",
|
|
)
|
|
trade_markers: Optional[List[Dict[str, Any]]] = Field(None, description="Trade markers for charts")
|
|
run_logs: Optional[List[Dict[str, Any]]] = Field(None, description="Structured stdout/stderr lines")
|
|
|
|
|
|
|
|
|
|
# Session/goal Pydantic models are defined in src/api/sessions_routes.py.
|
|
|
|
|
|
# Live-trading Pydantic models are defined in src/api/live_routes.py.
|
|
|
|
|
|
# ============================================================================
|
|
# FastAPI Application
|
|
# ============================================================================
|
|
|
|
app = FastAPI(
|
|
title="Vibe-Trading API",
|
|
description="Vibe-Trading API: natural-language finance research, backtesting, and swarm workflows",
|
|
version=APP_VERSION,
|
|
docs_url="/docs",
|
|
redoc_url="/redoc"
|
|
)
|
|
|
|
_DEFAULT_CORS_ORIGINS = [
|
|
"http://localhost:3000",
|
|
"http://localhost:5173",
|
|
"http://localhost:8000",
|
|
"http://127.0.0.1:3000",
|
|
"http://127.0.0.1:5173",
|
|
"http://127.0.0.1:8000",
|
|
]
|
|
|
|
_DEFAULT_LOOPBACK_HOSTS = frozenset({
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
"[::1]",
|
|
# Starlette/FastAPI TestClient default host; included so unit tests exercise
|
|
# the API without having to override Host on every request.
|
|
"testserver",
|
|
})
|
|
|
|
|
|
def _parse_cors_origins(raw: Optional[str]) -> List[str]:
|
|
"""Parse CORS origins and reject credentialed wildcard configuration.
|
|
|
|
Args:
|
|
raw: Comma-separated CORS origins from ``CORS_ORIGINS``. ``None`` or a
|
|
blank value uses the loopback development defaults.
|
|
|
|
Returns:
|
|
Explicit CORS origins accepted by the API server.
|
|
|
|
Raises:
|
|
RuntimeError: If a wildcard origin is configured while credentials are
|
|
enabled.
|
|
"""
|
|
if raw is None or not raw.strip():
|
|
return list(_DEFAULT_CORS_ORIGINS)
|
|
origins = [origin.strip() for origin in raw.split(",") if origin.strip()]
|
|
if "*" in origins:
|
|
raise RuntimeError(
|
|
"CORS_ORIGINS='*' is not allowed while credentials are enabled; "
|
|
"configure explicit Web UI origins instead."
|
|
)
|
|
return origins
|
|
|
|
|
|
def _parse_extra_loopback_hosts(raw: Optional[str]) -> set[str]:
|
|
"""Return additional trusted Host names for loopback API traffic."""
|
|
if raw is None or not raw.strip():
|
|
return set()
|
|
return {host.strip().lower().rstrip(".") for host in raw.split(",") if host.strip()}
|
|
|
|
|
|
_EXTRA_LOOPBACK_HOSTS = _parse_extra_loopback_hosts(os.getenv("API_ALLOWED_HOSTS"))
|
|
|
|
|
|
def _host_without_port(host: str) -> str:
|
|
"""Normalize a Host header to a lowercase hostname without a port."""
|
|
value = host.strip().lower().rstrip(".")
|
|
if not value:
|
|
return ""
|
|
if value.startswith("["):
|
|
end = value.find("]")
|
|
if end != -1:
|
|
return value[: end + 1]
|
|
return value
|
|
if value.count(":") == 1:
|
|
return value.rsplit(":", 1)[0]
|
|
return value
|
|
|
|
|
|
def _is_allowed_loopback_host(host: str) -> bool:
|
|
"""Return whether ``host`` is allowed for loopback-trusted API requests."""
|
|
normalized = _host_without_port(host)
|
|
return normalized in _DEFAULT_LOOPBACK_HOSTS or normalized in _EXTRA_LOOPBACK_HOSTS
|
|
|
|
|
|
def _is_loopback_bind_host(host: str) -> bool:
|
|
"""Return whether ``host`` resolves to a loopback interface."""
|
|
try:
|
|
return ipaddress.ip_address(host).is_loopback
|
|
except ValueError:
|
|
return host == "localhost"
|
|
|
|
|
|
# CORS: override with CORS_ORIGINS (comma-separated explicit origins)
|
|
_CORS_ORIGINS = _parse_cors_origins(os.getenv("CORS_ORIGINS"))
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=_CORS_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def _reject_untrusted_loopback_host(request: Request, call_next):
|
|
"""Block DNS-rebinding Host headers before loopback auth bypasses run."""
|
|
if _is_local_client(request) and not _is_allowed_loopback_host(request.headers.get("host", "")):
|
|
return JSONResponse(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
content={"detail": "Untrusted local API host"},
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# SPA deep-link fallback
|
|
# ----------------------------------------------------------------------------
|
|
# A handful of API routes share their path with frontend SPA routes (e.g.
|
|
# ``/runs/{id}`` and ``/correlation``). Because FastAPI matches registered
|
|
# routes before the static SPA mount, a browser that refreshes or bookmarks
|
|
# one of these URLs would receive JSON (or 401/422) instead of the SPA shell.
|
|
# The middleware below serves ``frontend/dist/index.html`` when the request
|
|
# clearly came from a browser (``Accept`` contains ``text/html``); programmatic
|
|
# clients are routed to the real API handler as before.
|
|
#
|
|
# Patterns are written narrowly so the SPA shell only shadows paths that
|
|
# actually correspond to frontend pages. In particular ``/runs/{id}`` is
|
|
# the RunDetail page, but ``/runs/{id}/code`` and ``/runs/{id}/pine`` are
|
|
# API-only endpoints with no SPA route — using a broad ``/runs/`` prefix
|
|
# here would incorrectly hijack those when the browser sets ``Accept:
|
|
# text/html`` (e.g. a user pasting the URL into the address bar).
|
|
|
|
_FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
|
_SPA_HTML_EXACT_PATHS: frozenset[str] = frozenset({"/correlation"})
|
|
# Each regex matches a complete request path. Trailing slash optional.
|
|
_SPA_HTML_PATH_REGEX: tuple[re.Pattern[str], ...] = (
|
|
# ``/runs/{run_id}`` — RunDetail page. Excludes ``/runs/{id}/code``,
|
|
# ``/runs/{id}/pine`` (API only) and ``/runs`` (collection endpoint).
|
|
re.compile(r"^/runs/[^/]+/?$"),
|
|
)
|
|
|
|
|
|
def _is_spa_html_route(path: str) -> bool:
|
|
"""Return True when ``path`` corresponds to a frontend SPA page that
|
|
shadows an API endpoint and should fall back to ``index.html`` on
|
|
browser navigation."""
|
|
if path in _SPA_HTML_EXACT_PATHS:
|
|
return True
|
|
return any(pattern.match(path) for pattern in _SPA_HTML_PATH_REGEX)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def _spa_html_deep_link_fallback(request: Request, call_next):
|
|
"""Serve ``frontend/dist/index.html`` when a browser navigates directly to
|
|
an SPA path that also exists as an API endpoint.
|
|
|
|
Conflicts: ``/runs/{id}`` (RunDetail page vs API) and ``/correlation``
|
|
(Correlation page vs API). Programmatic clients (``Accept: */*`` or
|
|
``application/json``) still hit the real API handler.
|
|
"""
|
|
if request.method == "GET":
|
|
accept = request.headers.get("accept", "")
|
|
if "text/html" in accept and _is_spa_html_route(request.url.path):
|
|
index = _FRONTEND_DIST / "index.html"
|
|
if index.exists():
|
|
return FileResponse(str(index))
|
|
return await call_next(request)
|
|
|
|
|
|
# ============================================================================
|
|
# Channel routes - defined in src/api/channels_routes.py
|
|
# Lifecycle functions imported early for startup/shutdown hooks
|
|
# ============================================================================
|
|
|
|
from src.api.channels_routes import ( # noqa: E402
|
|
_start_channel_runtime,
|
|
_stop_channel_runtime,
|
|
)
|
|
from src.api.scheduled_routes import ( # noqa: E402
|
|
_start_scheduled_research_executor,
|
|
_stop_scheduled_research_executor,
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def _run_startup_preflight() -> None:
|
|
"""Run preflight checks on server startup."""
|
|
from src.preflight import run_preflight
|
|
|
|
run_preflight(console)
|
|
_start_scheduled_research_executor()
|
|
if os.getenv("VIBE_TRADING_CHANNELS_AUTO_START", "").strip().lower() in {"1", "true", "yes"}:
|
|
await _start_channel_runtime()
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
async def _stop_scheduled_research_on_shutdown() -> None:
|
|
"""Stop the scheduled research executor on server shutdown."""
|
|
await _stop_channel_runtime()
|
|
await _stop_scheduled_research_executor()
|
|
|
|
|
|
# ============================================================================
|
|
# API Key Authentication
|
|
# ============================================================================
|
|
|
|
_security = HTTPBearer(auto_error=False)
|
|
_API_KEY = os.getenv("API_AUTH_KEY")
|
|
_SHELL_TOOLS_ENV = "VIBE_TRADING_ENABLE_SHELL_TOOLS"
|
|
_DOCKER_LOOPBACK_ENV = "VIBE_TRADING_TRUST_DOCKER_LOOPBACK"
|
|
|
|
|
|
def _configured_api_key() -> str:
|
|
"""Return the current API auth key, if configured."""
|
|
return os.getenv("API_AUTH_KEY") or _API_KEY or ""
|
|
|
|
|
|
async def require_auth(
|
|
request: Request,
|
|
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
|
|
) -> None:
|
|
"""Validate Bearer token for sensitive API endpoints.
|
|
|
|
Args:
|
|
request: Incoming HTTP request.
|
|
cred: HTTP Bearer credentials extracted from the Authorization header.
|
|
|
|
Raises:
|
|
HTTPException: 403 when dev-mode auth is reached from a non-local client.
|
|
HTTPException: 401 when API_AUTH_KEY is set but the token is missing or wrong.
|
|
"""
|
|
_validate_api_auth(request=request, cred=cred)
|
|
|
|
|
|
async def require_event_stream_auth(
|
|
request: Request,
|
|
api_key: Optional[str] = Query(None),
|
|
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
|
|
) -> None:
|
|
"""Validate auth for browser EventSource streams.
|
|
|
|
Native EventSource cannot send custom Authorization headers, so event
|
|
stream endpoints may accept the API key from the query string. Normal JSON
|
|
endpoints must continue to use Bearer auth only.
|
|
|
|
Args:
|
|
request: Incoming HTTP request.
|
|
api_key: Optional query-string API key for EventSource clients.
|
|
cred: HTTP Bearer credentials extracted from the Authorization header.
|
|
"""
|
|
_validate_api_auth(request=request, cred=cred, query_api_key=api_key, allow_query=True)
|
|
|
|
|
|
def _auth_credential_from_header_or_query(
|
|
cred: Optional[HTTPAuthorizationCredentials],
|
|
query_api_key: Optional[str],
|
|
*,
|
|
allow_query: bool,
|
|
) -> str:
|
|
"""Return the supplied API credential from the permitted source."""
|
|
if cred and cred.credentials:
|
|
return cred.credentials
|
|
if allow_query and query_api_key:
|
|
return query_api_key
|
|
return ""
|
|
|
|
|
|
def _is_loopback_origin(origin: str) -> bool:
|
|
"""Return whether a browser Origin header names a loopback web UI."""
|
|
try:
|
|
parsed = urllib.parse.urlsplit(origin)
|
|
except ValueError:
|
|
return False
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
return False
|
|
host = parsed.hostname.rstrip(".").lower()
|
|
if host == "localhost":
|
|
return True
|
|
try:
|
|
return ipaddress.ip_address(host).is_loopback
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _origin_matches_request_host(origin: str, request: Request) -> bool:
|
|
"""Return whether ``origin`` is the same site serving this request."""
|
|
try:
|
|
parsed = urllib.parse.urlsplit(origin)
|
|
except ValueError:
|
|
return False
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
return False
|
|
|
|
origin_host = parsed.hostname.rstrip(".").lower()
|
|
origin_port = parsed.port
|
|
request_host = _host_without_port(request.headers.get("host", ""))
|
|
if origin_host != request_host:
|
|
return False
|
|
|
|
if origin_port is None:
|
|
origin_port = 443 if parsed.scheme == "https" else 80
|
|
request_port = request.url.port
|
|
if request_port is None:
|
|
request_port = 443 if request.url.scheme == "https" else 80
|
|
return origin_port == request_port
|
|
|
|
|
|
def _reject_cross_site_browser_request(request: Request) -> None:
|
|
"""Reject unsafe browser requests from untrusted cross-site origins.
|
|
|
|
CORS protects response reads, not blind form/fetch side effects. Keep local
|
|
CLI/curl clients and same-origin browser UI deployments working while
|
|
refusing browser-originated cross-site POSTs to local control-plane actions
|
|
such as shutdown.
|
|
"""
|
|
sec_fetch_site = request.headers.get("sec-fetch-site", "").lower()
|
|
if sec_fetch_site == "cross-site":
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cross-site request denied")
|
|
|
|
origin = request.headers.get("origin")
|
|
if origin and not (_is_loopback_origin(origin) or _origin_matches_request_host(origin, request)):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cross-site request denied")
|
|
|
|
|
|
def _require_shutdown_authorization(
|
|
*,
|
|
request: Request,
|
|
cred: Optional[HTTPAuthorizationCredentials],
|
|
) -> None:
|
|
"""Authorize the local shutdown control-plane action.
|
|
|
|
Loopback peer IP alone is not enough for this browser-reachable, destructive
|
|
action. When API_AUTH_KEY is configured, require the Bearer token even for
|
|
loopback requests; otherwise preserve local dev-mode shutdown for direct
|
|
loopback clients while rejecting cross-site browser requests.
|
|
"""
|
|
_reject_cross_site_browser_request(request)
|
|
api_key = _configured_api_key()
|
|
if api_key:
|
|
token = _auth_credential_from_header_or_query(cred, None, allow_query=False)
|
|
if not token or not hmac.compare_digest(token, api_key):
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
return
|
|
if not _is_local_client(request):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="API_AUTH_KEY is required for non-local API access",
|
|
)
|
|
|
|
|
|
_SAFE_BROWSER_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
|
|
|
|
def _validate_api_auth(
|
|
*,
|
|
request: Request,
|
|
cred: Optional[HTTPAuthorizationCredentials],
|
|
query_api_key: Optional[str] = None,
|
|
allow_query: bool = False,
|
|
) -> None:
|
|
"""Validate configured auth, preserving loopback-only dev mode."""
|
|
# CORS protects response reads, not blind side effects. Reject unsafe
|
|
# browser-originated cross-site requests before honoring loopback dev-mode
|
|
# trust, otherwise a malicious page can drive local POST/PUT/DELETE routes.
|
|
if request.method.upper() not in _SAFE_BROWSER_METHODS:
|
|
_reject_cross_site_browser_request(request)
|
|
|
|
# Loopback clients are always trusted, even when API_AUTH_KEY is set.
|
|
# The key only gates non-local (LAN/remote) access.
|
|
if _is_local_client(request):
|
|
return
|
|
|
|
api_key = _configured_api_key()
|
|
if not api_key:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="API_AUTH_KEY is required for non-local API access",
|
|
)
|
|
|
|
token = _auth_credential_from_header_or_query(cred, query_api_key, allow_query=allow_query)
|
|
if not token or not hmac.compare_digest(token, api_key):
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
|
|
|
|
def _is_local_client(request: Request) -> bool:
|
|
"""Return whether the request originates from a loopback client."""
|
|
host = request.client.host if request.client else ""
|
|
if host in {"localhost", "testclient"}:
|
|
return True
|
|
try:
|
|
ip = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
return False
|
|
if ip.is_loopback:
|
|
return True
|
|
return _trusted_docker_loopback_ip(ip)
|
|
|
|
|
|
def _env_flag_enabled(name: str) -> bool:
|
|
"""Return whether a boolean environment flag is enabled."""
|
|
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _default_gateway_ips() -> set[ipaddress.IPv4Address]:
|
|
"""Return IPv4 default gateway addresses from Linux procfs."""
|
|
gateways: set[ipaddress.IPv4Address] = set()
|
|
try:
|
|
lines = Path("/proc/net/route").read_text(encoding="utf-8").splitlines()
|
|
except OSError:
|
|
return gateways
|
|
|
|
for line in lines[1:]:
|
|
fields = line.split()
|
|
if len(fields) < 3 or fields[1] != "00000000":
|
|
continue
|
|
try:
|
|
raw = int(fields[2], 16).to_bytes(4, byteorder="little")
|
|
gateways.add(ipaddress.IPv4Address(raw))
|
|
except ValueError:
|
|
continue
|
|
return gateways
|
|
|
|
|
|
def _trusted_docker_loopback_ip(ip: ipaddress._BaseAddress) -> bool:
|
|
"""Return whether an IP is the trusted Docker host gateway.
|
|
|
|
Docker Desktop presents host requests to a container as the bridge gateway
|
|
instead of 127.0.0.1. This escape hatch is safe only when the published
|
|
port is bound to host loopback, so the official compose file enables it
|
|
together with a 127.0.0.1 port binding.
|
|
"""
|
|
if not isinstance(ip, ipaddress.IPv4Address):
|
|
return False
|
|
if not _env_flag_enabled(_DOCKER_LOOPBACK_ENV):
|
|
return False
|
|
return ip in _default_gateway_ips()
|
|
|
|
|
|
def _env_shell_tools_enabled() -> bool:
|
|
"""Return whether server-side shell tools are explicitly enabled."""
|
|
return _env_flag_enabled(_SHELL_TOOLS_ENV)
|
|
|
|
|
|
def _shell_tools_enabled_for_request(request: Request) -> bool:
|
|
"""Return whether this API request may expose shell tools to the agent."""
|
|
# Shell-capable tools execute commands on the host as the API process user.
|
|
# Do not infer that privilege from peer IP alone: browser DNS rebinding can
|
|
# make attacker-controlled pages appear as loopback clients. Operators who
|
|
# intentionally want API-started agents or swarm workers to receive shell
|
|
# tools must opt in explicitly.
|
|
return _env_shell_tools_enabled()
|
|
|
|
|
|
async def require_local_or_auth(
|
|
request: Request,
|
|
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
|
|
) -> None:
|
|
"""Protect settings access when dev-mode auth is disabled.
|
|
|
|
If API_AUTH_KEY is configured, require the bearer token. If not, allow only
|
|
loopback clients so an API server bound to 0.0.0.0 cannot accept remote
|
|
credential reads or writes in dev mode.
|
|
"""
|
|
if _configured_api_key():
|
|
await require_auth(request, cred)
|
|
return
|
|
if not _is_local_client(request):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Settings access requires API_AUTH_KEY or a local loopback client",
|
|
)
|
|
|
|
|
|
async def require_settings_write_auth(
|
|
request: Request,
|
|
cred: Optional[HTTPAuthorizationCredentials] = Security(_security),
|
|
) -> None:
|
|
"""Require explicit authorization before changing credential-routing settings.
|
|
|
|
Settings writes can redirect stored provider credentials to a different
|
|
endpoint. When an API key is configured, loopback peer IP alone is not a
|
|
sufficient user-intent signal because a browser can reach local APIs after
|
|
DNS rebinding.
|
|
"""
|
|
api_key = _configured_api_key()
|
|
if api_key:
|
|
token = _auth_credential_from_header_or_query(cred, None, allow_query=False)
|
|
if not token or not hmac.compare_digest(token, api_key):
|
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
return
|
|
|
|
if not _is_local_client(request):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Settings writes require API_AUTH_KEY or a local loopback client",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Workflow Factory
|
|
# ============================================================================
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
|
|
|
|
def _ensure_agent_env_file() -> Path:
|
|
"""Ensure the project-local agent/.env exists."""
|
|
if not ENV_PATH.exists():
|
|
ENV_PATH.write_text("# Created by Vibe-Trading Web UI settings.\n", encoding="utf-8")
|
|
return ENV_PATH
|
|
|
|
|
|
def _strip_env_value(value: str) -> str:
|
|
"""Remove basic dotenv quotes and inline comments."""
|
|
value = value.strip()
|
|
if " #" in value:
|
|
value = value.split(" #", 1)[0].rstrip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
value = value[1:-1]
|
|
return value.strip()
|
|
|
|
|
|
def _read_env_values(path: Path) -> Dict[str, str]:
|
|
"""Read active KEY=value entries from a dotenv file."""
|
|
values: Dict[str, str] = {}
|
|
if not path.exists():
|
|
return values
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
if key:
|
|
values[key] = _strip_env_value(value)
|
|
return values
|
|
|
|
|
|
def _project_relative_path(path: Path) -> str:
|
|
"""Return a project-relative display path without leaking an absolute path."""
|
|
try:
|
|
return path.resolve().relative_to(AGENT_DIR.parent.resolve()).as_posix()
|
|
except ValueError:
|
|
return path.name
|
|
|
|
|
|
def _format_env_value(value: str) -> str:
|
|
"""Format a dotenv value without allowing multiline injection."""
|
|
if "\n" in value or "\r" in value:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Environment values cannot contain newlines")
|
|
value = value.strip()
|
|
if not value:
|
|
return ""
|
|
if any(ch.isspace() for ch in value) or "#" in value:
|
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
return value
|
|
|
|
|
|
def _write_env_values(path: Path, updates: Dict[str, str]) -> None:
|
|
"""Upsert active dotenv values while preserving comments and ordering."""
|
|
_ensure_agent_env_file()
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
seen: set[str] = set()
|
|
for index, raw in enumerate(lines):
|
|
stripped = raw.lstrip()
|
|
is_comment = stripped.startswith("#")
|
|
candidate = stripped[1:].lstrip() if is_comment else stripped
|
|
if "=" not in candidate:
|
|
continue
|
|
key = candidate.split("=", 1)[0].strip()
|
|
if key in updates and key not in seen:
|
|
lines[index] = f"{key}={_format_env_value(updates[key])}"
|
|
seen.add(key)
|
|
missing = [key for key in updates if key not in seen]
|
|
if missing:
|
|
if lines and lines[-1].strip():
|
|
lines.append("")
|
|
lines.append("# Updated from Web UI")
|
|
for key in missing:
|
|
lines.append(f"{key}={_format_env_value(updates[key])}")
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _is_configured_secret(value: str, placeholders: set[str]) -> bool:
|
|
"""Return True when a secret is set and not a documented placeholder."""
|
|
normalized = value.strip().strip('"').strip("'")
|
|
if not normalized:
|
|
return False
|
|
return normalized.lower() not in {placeholder.lower() for placeholder in placeholders}
|
|
|
|
|
|
def _coerce_float(value: str, default: float) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _coerce_int(value: str, default: int) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
# ============================================================================
|
|
# Path-parameter validation
|
|
# ============================================================================
|
|
|
|
# ``run_id`` and ``session_id`` flow directly into filesystem paths
|
|
# (``RUNS_DIR / run_id`` etc.). Restrict to a safe character class so that
|
|
# values like ``..`` or ``foo/../bar`` cannot escape the parent directory.
|
|
_SAFE_PATH_PARAM_RE = __import__("re").compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
|
|
|
|
def _validate_path_param(value: str, kind: str) -> None:
|
|
"""Reject path parameters that could escape the parent directory.
|
|
|
|
Args:
|
|
value: User-supplied path-parameter value.
|
|
kind: Parameter name, used in the error detail.
|
|
|
|
Raises:
|
|
HTTPException: 400 when ``value`` does not match the safe character
|
|
class, mirroring the existing ``_SHADOW_ID_RE`` check.
|
|
"""
|
|
if not _SAFE_PATH_PARAM_RE.fullmatch(value or ""):
|
|
raise HTTPException(status_code=400, detail=f"invalid {kind}")
|
|
|
|
|
|
# ============================================================================
|
|
# Runs routes - defined in src/api/runs_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.runs_routes import register_runs_routes # noqa: E402
|
|
register_runs_routes(app)
|
|
|
|
# Re-export for test access via api_server.*
|
|
from src.api.runs_routes import ( # noqa: F401, E402
|
|
_load_json_file,
|
|
_load_csv_to_dict,
|
|
_build_response_from_run_dir,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Session service (shared by session routes, channels, scheduled, live)
|
|
# ============================================================================
|
|
|
|
_session_service = None
|
|
_channel_runtime = None
|
|
_channel_bus = None
|
|
_channel_manager = None
|
|
|
|
|
|
def _get_session_service():
|
|
"""Lazy-init session service when ENABLE_SESSION_RUNTIME=true."""
|
|
global _session_service
|
|
if _session_service is not None:
|
|
return _session_service
|
|
|
|
if os.getenv("ENABLE_SESSION_RUNTIME", "true").lower() != "true":
|
|
return None
|
|
|
|
import asyncio
|
|
from src.session.store import SessionStore
|
|
from src.session.events import EventBus
|
|
from src.session.service import SessionService
|
|
|
|
store = SessionStore(base_dir=SESSIONS_DIR)
|
|
event_bus = EventBus()
|
|
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
event_bus.set_loop(loop)
|
|
except RuntimeError:
|
|
pass
|
|
|
|
_session_service = SessionService(
|
|
store=store,
|
|
event_bus=event_bus,
|
|
runs_dir=RUNS_DIR,
|
|
)
|
|
return _session_service
|
|
|
|
|
|
def _get_channel_runtime():
|
|
"""Lazy-init IM channel runtime without starting platform adapters."""
|
|
global _channel_runtime, _channel_bus, _channel_manager
|
|
if _channel_runtime is not None:
|
|
return _channel_runtime
|
|
|
|
from src.channels.bus.queue import MessageBus
|
|
from src.channels.config import load_channels_config
|
|
from src.channels.manager import ChannelManager
|
|
from src.channels.runtime import ChannelRuntime
|
|
|
|
svc = _get_session_service()
|
|
if not svc:
|
|
raise HTTPException(status_code=501, detail="Session runtime not enabled")
|
|
|
|
_channel_bus = MessageBus()
|
|
config = load_channels_config()
|
|
_channel_manager = ChannelManager(config, _channel_bus, session_service=svc)
|
|
_channel_runtime = ChannelRuntime(
|
|
bus=_channel_bus,
|
|
session_service=svc,
|
|
manager=_channel_manager,
|
|
reply_timeout_s=config["reply_timeout_s"],
|
|
)
|
|
return _channel_runtime
|
|
|
|
|
|
# ============================================================================
|
|
# Session routes - defined in src/api/sessions_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.sessions_routes import register_sessions_routes # noqa: E402
|
|
|
|
register_sessions_routes(app)
|
|
|
|
# Re-export for test monkeypatch compatibility
|
|
from src.api.sessions_routes import ( # noqa: F401, E402
|
|
_goal_store,
|
|
_live_action_frame_from_tool_result,
|
|
_mandate_proposal_frame_from_tool_result,
|
|
)
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# System routes - defined in src/api/system_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.system_routes import register_system_routes # noqa: E402
|
|
register_system_routes(app)
|
|
|
|
# Re-export for test monkeypatch compatibility
|
|
from src.api.system_routes import _terminate_current_process # noqa: F401, E402
|
|
|
|
|
|
# ============================================================================
|
|
# Settings routes - defined in src/api/settings_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.settings_routes import register_settings_routes # noqa: E402
|
|
register_settings_routes(app)
|
|
|
|
# Re-export for test monkeypatch compatibility
|
|
from src.api.settings_routes import ( # noqa: F401, E402
|
|
_baostock_supported,
|
|
_baostock_installed,
|
|
_load_llm_providers,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Upload routes - defined in src/api/uploads_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.uploads_routes import register_uploads_routes # noqa: E402
|
|
register_uploads_routes(app)
|
|
|
|
# Re-export upload constants for test access via ``api_server.*``.
|
|
from src.api.uploads_routes import ( # noqa: E402
|
|
MAX_UPLOAD_SIZE,
|
|
UPLOADS_DIR,
|
|
_BLOCKED_UPLOAD_EXT,
|
|
_BLOCKED_UPLOAD_NAMES,
|
|
_SHADOW_ID_RE,
|
|
_UPLOAD_CHUNK_SIZE,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Channel routes registration - after require_auth is defined
|
|
# ============================================================================
|
|
|
|
from src.api.channels_routes import register_channels_routes # noqa: E402
|
|
|
|
register_channels_routes(app)
|
|
from src.api.qveris_routes import qveris_router # noqa: E402 # QVERIS-INTEGRATION
|
|
app.include_router(qveris_router) # QVERIS-INTEGRATION
|
|
|
|
# Re-export for test monkeypatch compatibility
|
|
from src.api.channels_routes import ( # noqa: F401, E402
|
|
ChannelPairingCommandRequest,
|
|
)
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# Swarm routes - defined in src/api/swarm_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.swarm_routes import register_swarm_routes # noqa: E402
|
|
|
|
register_swarm_routes(app)
|
|
|
|
# Re-export for test monkeypatch compatibility
|
|
from src.api.swarm_routes import _get_swarm_runtime # noqa: F401, E402
|
|
|
|
|
|
# ============================================================================
|
|
# Live trading routes - defined in src/api/live_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.live_routes import register_live_routes # noqa: E402
|
|
|
|
register_live_routes(app)
|
|
|
|
# Re-export for test monkeypatch compatibility
|
|
from src.api.live_routes import ( # noqa: F401, E402
|
|
CommitMandateRequest,
|
|
LiveHaltRequest,
|
|
LiveAuthorizeRequest,
|
|
LiveRunnerControlRequest,
|
|
BrokerAuthState,
|
|
MandateLimits,
|
|
ActiveMandateState,
|
|
RunnerLivenessState,
|
|
LiveBrokerStatus,
|
|
LiveStatusResponse,
|
|
LiveRunnerUnavailable,
|
|
_runner_tasks,
|
|
_runner_factory,
|
|
_emit_live_event,
|
|
_fetch_broker_ceilings,
|
|
_known_live_brokers,
|
|
_oauth_token_present,
|
|
_active_mandate_state,
|
|
_runner_liveness_state,
|
|
_live_broker_adapter,
|
|
_build_live_runner,
|
|
_drive_runner,
|
|
)
|
|
|
|
# ============================================================================
|
|
# Alpha Zoo routes (Web UI) — defined in src/api/alpha_routes.py
|
|
# ============================================================================
|
|
|
|
from src.api.alpha_routes import register_alpha_routes # noqa: E402
|
|
register_alpha_routes(app)
|
|
|
|
|
|
# ============================================================================
|
|
# Scheduled Research Routes - defined in src/api/scheduled_routes.py
|
|
# ============================================================================
|
|
#
|
|
# Lightweight CRUD endpoints backed by ScheduledResearchJobStore. The endpoint
|
|
# handlers only record and expose jobs; the optional executor lifecycle is
|
|
# guarded separately by VIBE_TRADING_ENABLE_SCHEDULER.
|
|
|
|
from src.api.scheduled_routes import register_scheduled_routes # noqa: E402
|
|
|
|
register_scheduled_routes(app)
|
|
|
|
# Re-exported for backward-compatibility / external consumers
|
|
from src.api.scheduled_routes import ( # noqa: E402, F401
|
|
CreateScheduledRunRequest,
|
|
ScheduledRunResponse,
|
|
_dispatch_scheduled_research_job,
|
|
_get_scheduled_research_executor,
|
|
_get_scheduled_research_store,
|
|
_scheduled_research_scheduler_enabled,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Main Entry Point
|
|
# ============================================================================
|
|
|
|
def serve_main(argv: list[str] | None = None) -> int:
|
|
"""Start the API server from CLI-style arguments."""
|
|
import argparse
|
|
import subprocess
|
|
import uvicorn
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
class SPAStaticFiles(StaticFiles):
|
|
"""Serve index.html for browser refreshes on client-side routes."""
|
|
|
|
async def get_response(self, path: str, scope: Dict[str, Any]):
|
|
try:
|
|
return await super().get_response(path, scope)
|
|
except StarletteHTTPException as exc:
|
|
if exc.status_code != status.HTTP_404_NOT_FOUND:
|
|
raise
|
|
return await super().get_response("index.html", scope)
|
|
|
|
parser = argparse.ArgumentParser(description="Vibe-Trading Server")
|
|
parser.add_argument("--port", type=int, default=8000, help="Listen port (default 8000)")
|
|
parser.add_argument("--host", default="127.0.0.1", help="Bind address")
|
|
parser.add_argument("--dev", action="store_true", help="Dev mode: spawn Vite on :5173")
|
|
try:
|
|
args = parser.parse_args(argv)
|
|
except SystemExit as exc:
|
|
return int(exc.code) if isinstance(exc.code, int) else 2
|
|
|
|
if not _is_loopback_bind_host(args.host) and not _configured_api_key():
|
|
print(
|
|
f"[warn] Binding to {args.host} without API_AUTH_KEY set. "
|
|
f"Remote requests are rejected by the loopback peer-IP check, "
|
|
f"but consider using --host 127.0.0.1 for local-only access."
|
|
)
|
|
|
|
frontend_dist = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
|
frontend_root = Path(__file__).resolve().parent.parent / "frontend"
|
|
|
|
vite_proc = None
|
|
if args.dev and frontend_root.exists():
|
|
print("[dev] Starting Vite dev server on :5173 ...")
|
|
vite_proc = subprocess.Popen(
|
|
["npx", "vite", "--host", "0.0.0.0"],
|
|
cwd=str(frontend_root),
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
print(f"[dev] Vite PID={vite_proc.pid}")
|
|
print("[dev] Frontend: http://localhost:5173")
|
|
print(f"[dev] API: http://localhost:{args.port}")
|
|
elif frontend_dist.exists():
|
|
if not any(route.path == "/" for route in app.routes):
|
|
app.mount("/", SPAStaticFiles(directory=str(frontend_dist), html=True), name="frontend")
|
|
print(f"[prod] Frontend served from {frontend_dist}")
|
|
else:
|
|
print(f"[warn] No frontend build found at {frontend_dist}")
|
|
print("[warn] Run: cd frontend && npm run build")
|
|
|
|
print("=" * 50)
|
|
print(" Vibe-Trading Server")
|
|
print(f" http://127.0.0.1:{args.port}")
|
|
print("=" * 50)
|
|
|
|
try:
|
|
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
|
finally:
|
|
if vite_proc:
|
|
vite_proc.terminate()
|
|
print("[dev] Vite stopped")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(serve_main())
|