2026-05-31 23:58:26 +09:00
|
|
|
|
# app.py — slim orchestrator
|
2026-06-02 01:30:38 +02:00
|
|
|
|
import mimetypes
|
2026-05-31 23:58:26 +09:00
|
|
|
|
import os
|
2026-06-16 06:58:16 +03:00
|
|
|
|
import sys
|
2026-06-23 19:44:05 +03:00
|
|
|
|
import asyncio
|
2026-07-07 00:50:07 +00:00
|
|
|
|
import time
|
2026-06-23 19:44:05 +03:00
|
|
|
|
|
|
|
|
|
|
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
|
|
|
|
|
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
|
|
|
|
|
# automatically. But the VS Code debugger (and other non-uvicorn entrypoints)
|
|
|
|
|
|
# use the default SelectorEventLoop, which raises NotImplementedError on any
|
|
|
|
|
|
# subprocess call. Force ProactorEventLoop here so the right loop is always
|
|
|
|
|
|
# used, regardless of how the process is launched.
|
|
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
2026-06-01 15:09:47 +09:00
|
|
|
|
|
2026-06-02 01:30:38 +02:00
|
|
|
|
|
|
|
|
|
|
def register_static_mime_types() -> None:
|
|
|
|
|
|
"""Force stable JS module MIME types across platforms.
|
|
|
|
|
|
|
|
|
|
|
|
Some native Windows setups inherit stale/incorrect registry mappings for
|
|
|
|
|
|
``.js``/``.mjs``, which can make Starlette serve ES modules with a non-JS
|
|
|
|
|
|
``Content-Type`` and cause the UI to load but fail on click. Re-register the
|
|
|
|
|
|
standard MIME types at startup so static assets are served consistently.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
mimetypes.add_type("text/javascript", ".js")
|
|
|
|
|
|
mimetypes.add_type("application/javascript", ".mjs")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
register_static_mime_types()
|
|
|
|
|
|
|
2026-06-01 15:09:47 +09:00
|
|
|
|
# Windows: force HuggingFace/fastembed to COPY model files instead of symlinking.
|
|
|
|
|
|
# On a network-share/UNC data dir Windows can't follow HF's symlinks ([WinError
|
|
|
|
|
|
# 1463]), so the ONNX embedding model fails to load. huggingface_hub reads this
|
|
|
|
|
|
# at import time, so set it before anything pulls it in. (Mirrored in
|
|
|
|
|
|
# src/embeddings.py for non-server entrypoints.)
|
|
|
|
|
|
if os.name == "nt":
|
|
|
|
|
|
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1")
|
|
|
|
|
|
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
|
|
|
|
|
|
|
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
# encoding="utf-8-sig" tolerates a UTF-8 BOM in .env — a common Windows gotcha
|
|
|
|
|
|
# when the file is saved from Notepad. Without this, the first key parses as
|
|
|
|
|
|
# "AUTH_ENABLED" instead of "AUTH_ENABLED", so AUTH_ENABLED=false (etc.)
|
|
|
|
|
|
# is silently ignored and the user is unexpectedly forced to log in (issue #142).
|
|
|
|
|
|
# utf-8-sig reads plain UTF-8 (no BOM) identically, so this is safe everywhere.
|
|
|
|
|
|
load_dotenv(encoding="utf-8-sig")
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
2026-06-01 23:20:17 +10:00
|
|
|
|
import secrets
|
2026-06-19 02:58:25 +08:00
|
|
|
|
from datetime import datetime, timezone
|
2026-05-31 23:58:26 +09:00
|
|
|
|
from typing import Dict
|
|
|
|
|
|
|
2026-06-02 21:43:14 -07:00
|
|
|
|
from contextlib import asynccontextmanager
|
2026-05-31 23:58:26 +09:00
|
|
|
|
from fastapi import FastAPI, Request, HTTPException
|
fix(routes): log and cleanly 500 on unreadable HTML page (#4637)
* fix(routes): serve 404 instead of 500 when an HTML page file is missing
_serve_html_with_nonce opened the HTML file with no error handling, and
callers such as /backgrounds and /login pass their paths in with no
existence check, so a missing or unreadable file raised an unhandled
OSError that surfaced as a 500. Wrap the read and raise HTTPException(404)
instead; the normal render path (CSP-nonce substitution) is unchanged.
Fixes #4594
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): distinguish missing page (404) from read failure (500)
The previous fix caught a broad OSError and returned 404 for every
failure, which masks real server-side problems (permission errors, I/O
failures) as "not found" and lets them slip past error alerting. Split
FileNotFoundError (genuine 404) from other OSError, which now logs the
exception and returns a generic 500 — without leaking the OS error
string or file path into the response body.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): treat unreadable bundled HTML page as logged 500, not 404
Per PR #4637 review: every caller of the page-render helper serves a fixed,
server-owned template (index/login/backgrounds), never a client-supplied
path. So a missing or unreadable file is a server fault (broken deployment),
not a client "not found" — a 404 there mislabels a server error and hides a
missing core template from 5xx alerting, contradicting the OSError->500
rationale this PR is built on. Collapse both branches into a single logged,
leak-free 500.
Move the helper to src.app_helpers.serve_html_with_nonce so the behavior can
be unit-tested without importing the whole app (app.py is the slim
orchestrator; the test harness stubs src.database, so importing app in tests
is not viable). Add tests pinning missing/unreadable -> 500 (not 404) and
nonce injection on the happy path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:12:32 +03:00
|
|
|
|
from fastapi.responses import JSONResponse, FileResponse
|
2026-05-31 23:58:26 +09:00
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
2026-06-09 22:12:24 +02:00
|
|
|
|
from starlette.middleware.gzip import GZipMiddleware
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# Core imports
|
|
|
|
|
|
from core.constants import (
|
|
|
|
|
|
BASE_DIR, STATIC_DIR, SESSIONS_FILE,
|
2026-06-08 09:58:52 +02:00
|
|
|
|
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
|
2026-05-31 23:58:26 +09:00
|
|
|
|
)
|
|
|
|
|
|
from core.database import SessionLocal, ApiToken
|
2026-06-07 14:23:23 +01:00
|
|
|
|
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
|
2026-06-10 17:31:26 +03:00
|
|
|
|
from core.auth import AuthManager, normalize_known_username
|
2026-05-31 23:58:26 +09:00
|
|
|
|
from core.exceptions import (
|
|
|
|
|
|
SessionNotFoundError, InvalidFileUploadError,
|
|
|
|
|
|
LLMServiceError, WebSearchError,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
import bcrypt as _bcrypt
|
|
|
|
|
|
|
fix(routes): log and cleanly 500 on unreadable HTML page (#4637)
* fix(routes): serve 404 instead of 500 when an HTML page file is missing
_serve_html_with_nonce opened the HTML file with no error handling, and
callers such as /backgrounds and /login pass their paths in with no
existence check, so a missing or unreadable file raised an unhandled
OSError that surfaced as a 500. Wrap the read and raise HTTPException(404)
instead; the normal render path (CSP-nonce substitution) is unchanged.
Fixes #4594
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): distinguish missing page (404) from read failure (500)
The previous fix caught a broad OSError and returned 404 for every
failure, which masks real server-side problems (permission errors, I/O
failures) as "not found" and lets them slip past error alerting. Split
FileNotFoundError (genuine 404) from other OSError, which now logs the
exception and returns a generic 500 — without leaking the OS error
string or file path into the response body.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): treat unreadable bundled HTML page as logged 500, not 404
Per PR #4637 review: every caller of the page-render helper serves a fixed,
server-owned template (index/login/backgrounds), never a client-supplied
path. So a missing or unreadable file is a server fault (broken deployment),
not a client "not found" — a 404 there mislabels a server error and hides a
missing core template from 5xx alerting, contradicting the OSError->500
rationale this PR is built on. Collapse both branches into a single logged,
leak-free 500.
Move the helper to src.app_helpers.serve_html_with_nonce so the behavior can
be unit-tested without importing the whole app (app.py is the slim
orchestrator; the test harness stubs src.database, so importing app in tests
is not viable). Add tests pinning missing/unreadable -> 500 (not 404) and
nonce injection on the happy path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:12:32 +03:00
|
|
|
|
from src.app_helpers import abs_join, serve_html_with_nonce
|
2026-06-05 10:33:47 +02:00
|
|
|
|
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
2026-05-31 23:58:26 +09:00
|
|
|
|
from starlette.responses import RedirectResponse
|
|
|
|
|
|
|
|
|
|
|
|
# ========= LOGGING =========
|
2026-06-15 11:32:51 +03:00
|
|
|
|
import logging.handlers
|
|
|
|
|
|
from core.constants import DATA_DIR
|
|
|
|
|
|
|
|
|
|
|
|
_root_logger = logging.getLogger()
|
|
|
|
|
|
_root_logger.setLevel(logging.INFO)
|
|
|
|
|
|
_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
|
|
|
|
|
|
|
|
|
|
# Clear existing handlers to avoid duplicates
|
|
|
|
|
|
for _h in list(_root_logger.handlers):
|
|
|
|
|
|
_root_logger.removeHandler(_h)
|
|
|
|
|
|
|
|
|
|
|
|
_console_h = logging.StreamHandler()
|
|
|
|
|
|
_console_h.setFormatter(_formatter)
|
|
|
|
|
|
_root_logger.addHandler(_console_h)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
_log_dir = os.path.join(DATA_DIR, "logs")
|
|
|
|
|
|
os.makedirs(_log_dir, exist_ok=True)
|
|
|
|
|
|
_log_file = os.path.join(_log_dir, "app.log")
|
|
|
|
|
|
|
|
|
|
|
|
# RotatingFileHandler is not multi-process safe (e.g. if uvicorn is run with --workers N).
|
|
|
|
|
|
# Odysseus is single-process by convention, so this is acceptable, but be aware that
|
|
|
|
|
|
# concurrent log rotation issues can arise if multiple workers are configured.
|
|
|
|
|
|
_file_h = logging.handlers.RotatingFileHandler(
|
|
|
|
|
|
_log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
|
|
|
|
|
)
|
|
|
|
|
|
_file_h.setFormatter(_formatter)
|
|
|
|
|
|
_root_logger.addHandler(_file_h)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
_root_logger.warning(f"Failed to initialize file logging handler (falling back to console-only): {e}")
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# ========= APP =========
|
2026-06-02 21:43:14 -07:00
|
|
|
|
# Lifespan is defined below (after all helpers it references are in scope)
|
|
|
|
|
|
# and passed to FastAPI so we can use the modern context-manager lifecycle
|
|
|
|
|
|
# instead of the deprecated @app.on_event("startup"/"shutdown") decorators.
|
2026-05-31 23:58:26 +09:00
|
|
|
|
app = FastAPI(
|
|
|
|
|
|
title="AI Chat Application",
|
|
|
|
|
|
description="Comprehensive AI chat with memory, research, and multi-modal capabilities",
|
|
|
|
|
|
version="1.0.0",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ========= CORS =========
|
2026-06-16 06:03:43 +03:00
|
|
|
|
CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]
|
2026-05-31 23:58:26 +09:00
|
|
|
|
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
|
allow_origins=allowed_origins,
|
|
|
|
|
|
allow_credentials=True,
|
2026-06-16 06:03:43 +03:00
|
|
|
|
allow_methods=CORS_ALLOW_METHODS,
|
2026-06-01 10:54:08 +09:00
|
|
|
|
allow_headers=[
|
|
|
|
|
|
"Accept",
|
|
|
|
|
|
"Authorization",
|
|
|
|
|
|
"Content-Type",
|
|
|
|
|
|
"X-API-Key",
|
|
|
|
|
|
"X-Auth-Token",
|
|
|
|
|
|
"X-Odysseus-Internal-Token",
|
|
|
|
|
|
"X-Odysseus-Owner",
|
|
|
|
|
|
"X-Requested-With",
|
|
|
|
|
|
"X-TZ-Offset",
|
|
|
|
|
|
],
|
2026-05-31 23:58:26 +09:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-09 22:12:24 +02:00
|
|
|
|
# ========= RESPONSE COMPRESSION (gzip) =========
|
|
|
|
|
|
# The frontend's text assets (style.css, index.html, the JS bundles) shipped
|
|
|
|
|
|
# uncompressed on every cold load. gzip cuts CSS/JS/HTML by ~75-85% on the wire
|
|
|
|
|
|
# with no behavioural change. Starlette's GZipMiddleware excludes
|
|
|
|
|
|
# `text/event-stream` by default, so the SSE streams (chat, shell, research,
|
|
|
|
|
|
# model-probe — all served with media_type="text/event-stream") are never
|
|
|
|
|
|
# compressed or buffered; only complete bodies over minimum_size are. The
|
|
|
|
|
|
# security-header middleware composes cleanly on top.
|
|
|
|
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# ========= SECURITY HEADERS MIDDLEWARE =========
|
|
|
|
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ========= REQUEST TIMEOUT (FALLBACK FOR HUNG HANDLERS) =========
|
|
|
|
|
|
# If a single request takes longer than REQUEST_HARD_TIMEOUT, abort it and
|
|
|
|
|
|
# return 504 instead of holding the event loop hostage. Whitelisted paths
|
|
|
|
|
|
# (streaming, long-running shell exec, research) are exempt because they
|
|
|
|
|
|
# legitimately stay open. Without this, a single hung subprocess.run or
|
|
|
|
|
|
# missing-timeout httpx call locks up the entire server for everyone.
|
|
|
|
|
|
import asyncio as _asyncio
|
|
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
|
|
|
|
|
|
from starlette.responses import JSONResponse as _JSONResponse
|
|
|
|
|
|
|
|
|
|
|
|
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
|
|
|
|
|
|
_TIMEOUT_EXEMPT_PREFIXES = (
|
|
|
|
|
|
"/api/chat", # streaming
|
|
|
|
|
|
"/api/shell/stream", # SSE
|
|
|
|
|
|
"/api/research", # multi-minute jobs
|
|
|
|
|
|
"/api/model/download", # tmux setup may run pip installs
|
|
|
|
|
|
"/api/model/probe", # SSE; iterates models with up to 8s timeout each
|
|
|
|
|
|
"/api/model-endpoints", # /probe sub-route also iterates models
|
|
|
|
|
|
"/api/cookbook/setup", # remote pacman/apt installs
|
|
|
|
|
|
"/api/upload", # large files
|
|
|
|
|
|
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
2026-06-15 07:27:46 -04:00
|
|
|
|
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
2026-05-31 23:58:26 +09:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _RequestTimeoutMiddleware(_BaseHTTPMiddleware):
|
|
|
|
|
|
async def dispatch(self, request, call_next):
|
|
|
|
|
|
path = request.url.path or ""
|
|
|
|
|
|
if any(path.startswith(p) for p in _TIMEOUT_EXEMPT_PREFIXES):
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await _asyncio.wait_for(call_next(request), timeout=REQUEST_HARD_TIMEOUT)
|
|
|
|
|
|
except _asyncio.TimeoutError:
|
|
|
|
|
|
return _JSONResponse(
|
|
|
|
|
|
{"detail": f"Request exceeded {REQUEST_HARD_TIMEOUT:.0f}s timeout"},
|
|
|
|
|
|
status_code=504,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 13:52:52 +00:00
|
|
|
|
class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
|
|
|
|
|
async def dispatch(self, request, call_next):
|
|
|
|
|
|
from src.interactive_gate import should_track_interactive_request, track_interactive_request
|
|
|
|
|
|
|
|
|
|
|
|
path = request.url.path or ""
|
|
|
|
|
|
if not should_track_interactive_request(path, request.method):
|
|
|
|
|
|
return await call_next(request)
|
2026-07-07 00:50:07 +00:00
|
|
|
|
async def _stop_background():
|
|
|
|
|
|
try:
|
|
|
|
|
|
await task_scheduler.stop_background_tasks_for_foreground(reason=f"foreground request {request.method} {path}")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logging.getLogger("app.foreground_gate").debug("foreground task stop failed", exc_info=True)
|
|
|
|
|
|
asyncio.create_task(_stop_background())
|
2026-06-29 13:52:52 +00:00
|
|
|
|
async with track_interactive_request(path, request.method):
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 00:50:07 +00:00
|
|
|
|
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
|
|
|
|
|
async def dispatch(self, request, call_next):
|
|
|
|
|
|
start = time.perf_counter()
|
|
|
|
|
|
status = 500
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
|
status = getattr(response, "status_code", 0) or 0
|
|
|
|
|
|
return response
|
|
|
|
|
|
finally:
|
|
|
|
|
|
elapsed = time.perf_counter() - start
|
|
|
|
|
|
try:
|
|
|
|
|
|
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
threshold = 0.75
|
|
|
|
|
|
if elapsed >= threshold:
|
|
|
|
|
|
logging.getLogger("app.slow_request").warning(
|
|
|
|
|
|
"slow_request method=%s path=%s status=%s elapsed=%.3fs",
|
|
|
|
|
|
request.method,
|
|
|
|
|
|
request.url.path,
|
|
|
|
|
|
status,
|
|
|
|
|
|
elapsed,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
app.add_middleware(_RequestTimeoutMiddleware)
|
2026-06-29 13:52:52 +00:00
|
|
|
|
app.add_middleware(_InteractiveActivityMiddleware)
|
2026-07-07 00:50:07 +00:00
|
|
|
|
app.add_middleware(_SlowRequestLogMiddleware)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# ========= AUTH =========
|
|
|
|
|
|
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
|
|
|
|
|
|
|
|
|
|
|
auth_manager = AuthManager()
|
|
|
|
|
|
app.state.auth_manager = auth_manager
|
|
|
|
|
|
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() != "false"
|
|
|
|
|
|
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
|
2026-06-01 16:08:01 +02:00
|
|
|
|
if LOCALHOST_BYPASS:
|
|
|
|
|
|
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
if AUTH_ENABLED:
|
|
|
|
|
|
AUTH_EXEMPT_EXACT = {
|
|
|
|
|
|
"/api/auth/setup",
|
|
|
|
|
|
"/api/auth/signup",
|
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
|
"/api/auth/logout",
|
|
|
|
|
|
"/api/auth/status",
|
|
|
|
|
|
"/api/auth/features",
|
|
|
|
|
|
"/api/auth/settings",
|
|
|
|
|
|
"/api/auth/integrations/presets",
|
|
|
|
|
|
"/api/health",
|
|
|
|
|
|
"/api/version",
|
|
|
|
|
|
"/login",
|
|
|
|
|
|
}
|
|
|
|
|
|
AUTH_EXEMPT_PREFIXES = ["/static"]
|
Exempt task webhook trigger from session auth (#784)
POSTing to the per-task webhook URL shown in the Tasks UI returned 401
Unauthorized even though the URL is labelled "no auth needed". The
trigger handler at routes/task_routes.py:873 (`POST
/api/tasks/{task_id}/webhook/{token}`) was written as an
unauthenticated endpoint — the 32-byte path-embedded `webhook_token`
generated by `secrets.token_urlsafe(32)` is the credential, and the
handler validates it against the row before doing anything. But
AuthMiddleware in app.py runs first and only knows about
AUTH_EXEMPT_EXACT (static path set) and AUTH_EXEMPT_PREFIXES (only
`/static`), so every external POST (curl, Zapier, n8n, Make,
Activepieces) got rejected before the route ever saw the request.
External callers can't supply a session cookie, which is precisely
why the per-task token exists.
Fix: add an AUTH_EXEMPT_PATTERNS list of compiled regexes for dynamic
public paths and route `^/api/tasks/[^/]+/webhook/[^/]+/?$` through
it. The route handler still enforces `ScheduledTask.webhook_token ==
token` and 404s on mismatch, so an attacker without the token gets a
404 (indistinguishable from a non-existent task), and a holder of the
token gets the documented "POST and a task fires" behaviour. The
sibling endpoint `/{task_id}/webhook-regenerate` is admin-gated and
deliberately does NOT match the pattern — it requires `_owner(request)`
and a session.
Tests: tests/test_webhook_trigger_auth_exempt.py extracts the regex
list out of app.py, applies it to a representative trigger path
(positive) and the four neighbouring task paths that must stay
authenticated (negative — `/api/tasks`, `/api/tasks/{id}`,
`/api/tasks/{id}/webhook-regenerate`, `/api/tasks/{id}/run`), and
pins the handler-side token check so a refactor of the route doesn't
quietly turn the endpoint into a truly anonymous one.
Closes #621.
2026-06-02 07:53:40 +05:30
|
|
|
|
# Dynamic paths whose own handler proves identity via a path-embedded
|
|
|
|
|
|
# secret instead of the session/bearer auth. The route handler at
|
|
|
|
|
|
# routes/task_routes.py validates the per-task `webhook_token` itself
|
|
|
|
|
|
# and returns 404 on mismatch, so the path is the credential — the
|
|
|
|
|
|
# UI labels these URLs "no auth needed" precisely because external
|
|
|
|
|
|
# callers (Zapier, n8n, curl) can't supply a session cookie. Without
|
|
|
|
|
|
# this exemption AuthMiddleware rejects every POST with 401 before
|
|
|
|
|
|
# the token is ever checked.
|
|
|
|
|
|
import re as _re
|
|
|
|
|
|
AUTH_EXEMPT_PATTERNS = [
|
|
|
|
|
|
_re.compile(r"^/api/tasks/[^/]+/webhook/[^/]+/?$"),
|
|
|
|
|
|
]
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
def _is_auth_exempt(path: str) -> bool:
|
Exempt task webhook trigger from session auth (#784)
POSTing to the per-task webhook URL shown in the Tasks UI returned 401
Unauthorized even though the URL is labelled "no auth needed". The
trigger handler at routes/task_routes.py:873 (`POST
/api/tasks/{task_id}/webhook/{token}`) was written as an
unauthenticated endpoint — the 32-byte path-embedded `webhook_token`
generated by `secrets.token_urlsafe(32)` is the credential, and the
handler validates it against the row before doing anything. But
AuthMiddleware in app.py runs first and only knows about
AUTH_EXEMPT_EXACT (static path set) and AUTH_EXEMPT_PREFIXES (only
`/static`), so every external POST (curl, Zapier, n8n, Make,
Activepieces) got rejected before the route ever saw the request.
External callers can't supply a session cookie, which is precisely
why the per-task token exists.
Fix: add an AUTH_EXEMPT_PATTERNS list of compiled regexes for dynamic
public paths and route `^/api/tasks/[^/]+/webhook/[^/]+/?$` through
it. The route handler still enforces `ScheduledTask.webhook_token ==
token` and 404s on mismatch, so an attacker without the token gets a
404 (indistinguishable from a non-existent task), and a holder of the
token gets the documented "POST and a task fires" behaviour. The
sibling endpoint `/{task_id}/webhook-regenerate` is admin-gated and
deliberately does NOT match the pattern — it requires `_owner(request)`
and a session.
Tests: tests/test_webhook_trigger_auth_exempt.py extracts the regex
list out of app.py, applies it to a representative trigger path
(positive) and the four neighbouring task paths that must stay
authenticated (negative — `/api/tasks`, `/api/tasks/{id}`,
`/api/tasks/{id}/webhook-regenerate`, `/api/tasks/{id}/run`), and
pins the handler-side token check so a refactor of the route doesn't
quietly turn the endpoint into a truly anonymous one.
Closes #621.
2026-06-02 07:53:40 +05:30
|
|
|
|
if path in AUTH_EXEMPT_EXACT:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# In-memory token cache: prefix → list[(token_id, token_hash, owner, scopes)]. The DB
|
|
|
|
|
|
# query was running on every API-bearer request and scanning bcrypt
|
|
|
|
|
|
# checks linearly. With this cache, we hit the DB only when the cache
|
|
|
|
|
|
# version bumps (token created/revoked) — see _token_cache_invalidate
|
|
|
|
|
|
# in app.state, called by routes/api_token_routes.
|
|
|
|
|
|
_token_cache: dict = {}
|
|
|
|
|
|
_token_cache_lock = _asyncio.Lock()
|
|
|
|
|
|
_token_cache_dirty = True
|
|
|
|
|
|
|
|
|
|
|
|
def _token_cache_invalidate():
|
|
|
|
|
|
nonlocal_dict = app.state.__dict__
|
|
|
|
|
|
nonlocal_dict["_token_cache_dirty"] = True
|
|
|
|
|
|
app.state.invalidate_token_cache = _token_cache_invalidate
|
|
|
|
|
|
app.state._token_cache = _token_cache
|
|
|
|
|
|
app.state._token_cache_dirty = True
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_token_cache():
|
|
|
|
|
|
"""Rebuild the prefix→[(id,hash)] map from the DB."""
|
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
|
new_map = defaultdict(list)
|
|
|
|
|
|
db = SessionLocal()
|
|
|
|
|
|
try:
|
|
|
|
|
|
rows = db.query(ApiToken).filter(ApiToken.is_active == True).all()
|
|
|
|
|
|
for r in rows:
|
2026-06-10 17:31:26 +03:00
|
|
|
|
owner_key = normalize_known_username(auth_manager.users, getattr(r, "owner", None))
|
|
|
|
|
|
if not owner_key:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Ignoring active API token '%s' for unknown auth user '%s'",
|
|
|
|
|
|
getattr(r, "id", ""),
|
|
|
|
|
|
getattr(r, "owner", None),
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
2026-05-31 23:58:26 +09:00
|
|
|
|
scopes = [s.strip() for s in (getattr(r, "scopes", "") or "chat").split(",") if s.strip()]
|
2026-06-10 17:31:26 +03:00
|
|
|
|
new_map[r.token_prefix].append((r.id, r.token_hash, owner_key, scopes))
|
2026-05-31 23:58:26 +09:00
|
|
|
|
finally:
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
_token_cache.clear()
|
|
|
|
|
|
_token_cache.update(new_map)
|
|
|
|
|
|
app.state._token_cache_dirty = False
|
|
|
|
|
|
|
2026-06-01 15:09:47 +09:00
|
|
|
|
# Headers that prove a request was forwarded by a proxy/tunnel (cloudflared,
|
|
|
|
|
|
# nginx, Caddy, Tailscale Funnel, …). cloudflared connects to the app FROM
|
|
|
|
|
|
# 127.0.0.1, so without this check every tunneled request would look like
|
|
|
|
|
|
# loopback and could bypass auth.
|
|
|
|
|
|
_PROXY_FWD_HEADERS = (
|
|
|
|
|
|
"cf-connecting-ip", "cf-ray", "cf-visitor",
|
|
|
|
|
|
"x-forwarded-for", "x-forwarded-host", "x-real-ip", "forwarded",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _is_trusted_loopback(request: Request) -> bool:
|
|
|
|
|
|
"""True ONLY for a DIRECT loopback connection with no proxy/tunnel
|
|
|
|
|
|
forwarding headers. A bare ``client.host in ('127.0.0.1','::1')`` check is
|
|
|
|
|
|
unsafe behind a Cloudflare tunnel / reverse proxy: those connect from
|
|
|
|
|
|
loopback, so a remote visitor would otherwise inherit local trust and
|
|
|
|
|
|
slip past LOCALHOST_BYPASS or spoof the internal-tool path. Odysseus's own
|
|
|
|
|
|
in-process agent loopback calls carry none of these headers, so they still
|
|
|
|
|
|
qualify."""
|
|
|
|
|
|
host = request.client.host if request.client else None
|
|
|
|
|
|
if host not in ("127.0.0.1", "::1"):
|
|
|
|
|
|
return False
|
|
|
|
|
|
for _h in _PROXY_FWD_HEADERS:
|
|
|
|
|
|
if request.headers.get(_h):
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
|
|
|
|
path = request.url.path
|
2026-06-07 14:23:23 +01:00
|
|
|
|
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
|
|
|
|
|
|
# carries no credentials by design and must reach CORSMiddleware to be
|
|
|
|
|
|
# answered. AuthMiddleware is the outermost middleware, so gating the
|
|
|
|
|
|
# preflight on auth 401s it before CORS can respond -- which blocks
|
|
|
|
|
|
# every cross-origin browser/WebView client before the real request
|
|
|
|
|
|
# is sent. Let real preflights through (only OPTIONS w/ the ACRM
|
|
|
|
|
|
# header; never a credentialed request).
|
|
|
|
|
|
if is_cors_preflight(request.method, request.headers):
|
|
|
|
|
|
return await call_next(request)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
if _is_auth_exempt(path):
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
# In-process internal-tool token bypass. Used by the agent
|
|
|
|
|
|
# tool layer when it HTTP-loopbacks to admin-gated routes
|
|
|
|
|
|
# (no admin cookie available in that context). Restricted to
|
|
|
|
|
|
# loopback clients + matching token to keep it locked down.
|
|
|
|
|
|
try:
|
2026-06-16 13:13:00 +02:00
|
|
|
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN as _ITT, INTERNAL_TOOL_USER
|
2026-05-31 23:58:26 +09:00
|
|
|
|
_hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
2026-06-01 23:20:17 +10:00
|
|
|
|
if _hdr and secrets.compare_digest(_hdr, _ITT) and _is_trusted_loopback(request):
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# Impersonation: when the agent's loopback call sets
|
2026-06-01 15:24:52 +09:00
|
|
|
|
# X-Odysseus-Owner, attribute the request to that user only
|
|
|
|
|
|
# if they exist. Authorization checks remain separate; this
|
|
|
|
|
|
# is just owner attribution for notes/calendar/etc.
|
2026-05-31 23:58:26 +09:00
|
|
|
|
_impersonate = (request.headers.get("X-Odysseus-Owner") or "").strip()
|
2026-06-01 15:24:52 +09:00
|
|
|
|
_auth_mgr = getattr(request.app.state, "auth_manager", None) or auth_manager
|
|
|
|
|
|
if _impersonate and _impersonate in getattr(_auth_mgr, "users", {}):
|
|
|
|
|
|
request.state.current_user = _impersonate
|
|
|
|
|
|
else:
|
2026-06-16 13:13:00 +02:00
|
|
|
|
request.state.current_user = INTERNAL_TOOL_USER
|
2026-05-31 23:58:26 +09:00
|
|
|
|
request.state.api_token = False
|
|
|
|
|
|
return await call_next(request)
|
2026-06-15 13:49:27 -03:00
|
|
|
|
except Exception as _e:
|
|
|
|
|
|
logger.warning("Internal tool auth header check failed", exc_info=_e)
|
2026-06-01 15:09:47 +09:00
|
|
|
|
# Allow DIRECT localhost requests (internal service calls from
|
|
|
|
|
|
# heartbeats etc.). Tunnel/proxy-forwarded requests are excluded by
|
|
|
|
|
|
# _is_trusted_loopback so LOCALHOST_BYPASS can't be abused over a
|
|
|
|
|
|
# Cloudflare tunnel / reverse proxy. Keep LOCALHOST_BYPASS=false for
|
|
|
|
|
|
# network-exposed deployments regardless.
|
|
|
|
|
|
if LOCALHOST_BYPASS and _is_trusted_loopback(request):
|
|
|
|
|
|
return await call_next(request)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
if not auth_manager.is_configured:
|
|
|
|
|
|
# No users yet — redirect to login for first-time setup
|
|
|
|
|
|
if not path.startswith("/api/"):
|
|
|
|
|
|
return RedirectResponse(url="/login", status_code=302)
|
|
|
|
|
|
return JSONResponse(status_code=401, content={"error": "Setup required"})
|
|
|
|
|
|
|
|
|
|
|
|
# --- Bearer token auth (API tokens for external integrations) ---
|
|
|
|
|
|
auth_header = request.headers.get("authorization", "")
|
|
|
|
|
|
if auth_header.startswith("Bearer ody_"):
|
|
|
|
|
|
raw_token = auth_header[7:]
|
|
|
|
|
|
# Sanity check: tokens are "ody_" + 43 chars of base64
|
|
|
|
|
|
if len(raw_token) < 12 or len(raw_token) > 100:
|
|
|
|
|
|
return JSONResponse(status_code=401, content={"error": "Invalid API token"})
|
|
|
|
|
|
prefix = raw_token[:8]
|
|
|
|
|
|
try:
|
|
|
|
|
|
if app.state._token_cache_dirty:
|
|
|
|
|
|
async with _token_cache_lock:
|
|
|
|
|
|
if app.state._token_cache_dirty:
|
|
|
|
|
|
await _asyncio.to_thread(_refresh_token_cache)
|
|
|
|
|
|
candidates = list(_token_cache.get(prefix, ()))
|
|
|
|
|
|
matched_id = None
|
|
|
|
|
|
matched_owner = None
|
|
|
|
|
|
matched_scopes = []
|
|
|
|
|
|
for tid, thash, owner, scopes in candidates:
|
|
|
|
|
|
if _bcrypt.checkpw(raw_token.encode(), thash.encode()):
|
|
|
|
|
|
matched_id = tid
|
|
|
|
|
|
matched_owner = owner
|
|
|
|
|
|
matched_scopes = scopes or []
|
|
|
|
|
|
break
|
|
|
|
|
|
if matched_id:
|
|
|
|
|
|
# Update last_used_at off the hot path. Doing it
|
|
|
|
|
|
# inline used to keep the request open across an
|
|
|
|
|
|
# extra commit; do it fire-and-forget instead.
|
|
|
|
|
|
async def _touch_last_used(tid: str):
|
|
|
|
|
|
def _do():
|
|
|
|
|
|
_db = SessionLocal()
|
|
|
|
|
|
try:
|
|
|
|
|
|
_db.query(ApiToken).filter(ApiToken.id == tid).update(
|
|
|
|
|
|
{"last_used_at": datetime.utcnow()}
|
|
|
|
|
|
)
|
|
|
|
|
|
_db.commit()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_db.close()
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _asyncio.to_thread(_do)
|
2026-06-15 13:49:27 -03:00
|
|
|
|
except Exception as _e:
|
|
|
|
|
|
logger.debug("Failed to update token last_used_at", exc_info=_e)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
_asyncio.create_task(_touch_last_used(matched_id))
|
|
|
|
|
|
# Keep bearer-token callers out of normal cookie/user
|
|
|
|
|
|
request.state.current_user = "api"
|
|
|
|
|
|
request.state.api_token = True
|
|
|
|
|
|
request.state.api_token_id = matched_id
|
|
|
|
|
|
request.state.api_token_owner = matched_owner
|
|
|
|
|
|
request.state.api_token_scopes = matched_scopes
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning("API token auth error", exc_info=False)
|
|
|
|
|
|
# Invalid bearer token — reject immediately
|
|
|
|
|
|
return JSONResponse(status_code=401, content={"error": "Invalid API token"})
|
|
|
|
|
|
|
|
|
|
|
|
# --- Cookie-based session auth ---
|
|
|
|
|
|
token = request.cookies.get(SESSION_COOKIE)
|
|
|
|
|
|
if not auth_manager.validate_token(token):
|
|
|
|
|
|
if path.startswith("/api/"):
|
|
|
|
|
|
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
|
|
|
|
|
return RedirectResponse(url="/login", status_code=302)
|
|
|
|
|
|
|
|
|
|
|
|
# Attach current username to request state for downstream routes
|
|
|
|
|
|
request.state.current_user = auth_manager.get_username_for_token(token)
|
|
|
|
|
|
request.state.api_token = False
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
app.add_middleware(AuthMiddleware)
|
|
|
|
|
|
logger.info("Auth middleware enabled (AUTH_ENABLED=true)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info("Auth middleware disabled (set AUTH_ENABLED=true to enable)")
|
|
|
|
|
|
|
|
|
|
|
|
# ========= STATIC FILES =========
|
|
|
|
|
|
os.makedirs(STATIC_DIR, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _RevalidatingStatic(StaticFiles):
|
|
|
|
|
|
"""Serve static assets normally, but force the browser to REVALIDATE
|
|
|
|
|
|
source files (.js/.css/.html) on every load instead of serving a stale
|
|
|
|
|
|
copy from disk cache. The app ships raw ES modules with no build step or
|
|
|
|
|
|
versioned URLs, so browsers were caching modules across deploys — a code
|
|
|
|
|
|
change wouldn't appear without a manual hard-refresh. `no-cache` keeps the
|
|
|
|
|
|
cached bytes but requires a conditional request; unchanged files still
|
|
|
|
|
|
return a cheap 304 (ETag/Last-Modified are preserved)."""
|
|
|
|
|
|
|
|
|
|
|
|
async def get_response(self, path, scope):
|
|
|
|
|
|
resp = await super().get_response(path, scope)
|
|
|
|
|
|
if path.endswith((".js", ".css", ".html")):
|
|
|
|
|
|
resp.headers["Cache-Control"] = "no-cache"
|
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 06:58:16 +03:00
|
|
|
|
app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static")
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# ========= GENERATED IMAGES =========
|
|
|
|
|
|
@app.get("/api/generated-image/{filename}")
|
|
|
|
|
|
async def serve_generated_image(filename: str, request: Request):
|
|
|
|
|
|
"""Serve generated images from the data directory."""
|
2026-06-05 10:33:47 +02:00
|
|
|
|
img_path = resolve_generated_image_path(filename)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# SECURITY: filename is the only key, so anyone who knows / guesses a
|
|
|
|
|
|
# 12-hex content hash could pull another user's image bytes. Require
|
|
|
|
|
|
# auth and verify ownership via the gallery row (when one exists).
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.auth_helpers import get_current_user
|
|
|
|
|
|
from core.database import SessionLocal as _SL, GalleryImage as _GI
|
|
|
|
|
|
_user = get_current_user(request)
|
|
|
|
|
|
if _user:
|
|
|
|
|
|
_db = _SL()
|
|
|
|
|
|
try:
|
|
|
|
|
|
_row = _db.query(_GI).filter(_GI.filename == filename).first()
|
|
|
|
|
|
# Generated-but-not-yet-imported images have no row → allow.
|
|
|
|
|
|
# Row exists with a different owner → 404 (don't confirm existence).
|
|
|
|
|
|
if _row is not None and _row.owner and _row.owner != _user:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Image not found")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_db.close()
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
2026-06-15 13:49:27 -03:00
|
|
|
|
except Exception as _e:
|
|
|
|
|
|
logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
ext = filename.rsplit('.', 1)[-1].lower()
|
|
|
|
|
|
mime = {
|
|
|
|
|
|
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
|
|
|
|
|
"webp": "image/webp", "gif": "image/gif",
|
|
|
|
|
|
"mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm",
|
|
|
|
|
|
"mkv": "video/x-matroska", "m4v": "video/mp4",
|
|
|
|
|
|
}.get(ext, "application/octet-stream")
|
|
|
|
|
|
# Generated-image filenames are content hashes → the bytes for a given
|
|
|
|
|
|
# filename never change. Cache them hard so the gallery doesn't
|
|
|
|
|
|
# re-download every full-size image each time it's opened. `immutable`
|
|
|
|
|
|
# tells the browser it never needs to revalidate within the max-age.
|
|
|
|
|
|
return FileResponse(
|
|
|
|
|
|
str(img_path),
|
|
|
|
|
|
media_type=mime,
|
2026-06-05 10:33:47 +02:00
|
|
|
|
headers=GENERATED_IMAGE_HEADERS,
|
2026-05-31 23:58:26 +09:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ========= YOUTUBE INIT =========
|
|
|
|
|
|
from services.youtube import init_youtube
|
|
|
|
|
|
init_youtube()
|
|
|
|
|
|
|
2026-05-31 22:32:13 -07:00
|
|
|
|
# ========= RAG (vector document RAG) =========
|
|
|
|
|
|
# VectorRAG (ChromaDB-backed personal-document semantic search). Initialized
|
|
|
|
|
|
# lazily via get_rag_manager() — returns None if ChromaDB isn't reachable
|
|
|
|
|
|
# (no server running on the configured host:port), in which case personal-doc
|
|
|
|
|
|
# routes return a clean 503 instead of busy-retrying every request.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Note: this was previously hardcoded off because chromadb 1.4.1 / pydantic
|
|
|
|
|
|
# 2.12 were mutually incompatible at the time. With the current pins
|
|
|
|
|
|
# (chromadb 1.5.x + pydantic 2.13.x) the init works and Personal Docs
|
|
|
|
|
|
# (POST /api/personal/add_directory etc.) is functional again.
|
|
|
|
|
|
from src.rag_singleton import get_rag_manager
|
|
|
|
|
|
rag_manager = get_rag_manager()
|
|
|
|
|
|
rag_available = rag_manager is not None
|
|
|
|
|
|
if rag_available:
|
|
|
|
|
|
logger.info("Vector document RAG initialized")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"Vector document RAG not available at startup "
|
|
|
|
|
|
"(ChromaDB may not be reachable yet — routes will retry lazily)"
|
|
|
|
|
|
)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# ========= IMPORT CONFIG =========
|
|
|
|
|
|
from src.config import config
|
|
|
|
|
|
|
|
|
|
|
|
# ========= COMPONENT INITIALIZATION =========
|
|
|
|
|
|
from src.app_initializer import initialize_managers
|
|
|
|
|
|
|
|
|
|
|
|
components = initialize_managers(BASE_DIR, rag_manager)
|
|
|
|
|
|
|
|
|
|
|
|
session_manager = components["session_manager"]
|
|
|
|
|
|
from src.assistant_log import set_session_manager as _set_asst_sm
|
|
|
|
|
|
_set_asst_sm(session_manager)
|
2026-06-09 21:12:52 +08:00
|
|
|
|
# Set the global session manager singleton (used by core.models.Session.add_message)
|
|
|
|
|
|
from core.models import set_session_manager_instance
|
|
|
|
|
|
set_session_manager_instance(session_manager)
|
2026-06-09 13:49:45 +05:30
|
|
|
|
app.state.session_manager = session_manager
|
2026-05-31 23:58:26 +09:00
|
|
|
|
memory_manager = components["memory_manager"]
|
|
|
|
|
|
memory_vector = components.get("memory_vector")
|
|
|
|
|
|
upload_handler = components["upload_handler"]
|
2026-06-11 17:01:04 +03:00
|
|
|
|
app.state.upload_handler = upload_handler
|
2026-05-31 23:58:26 +09:00
|
|
|
|
personal_docs_mgr = components["personal_docs_manager"]
|
2026-06-16 05:33:02 +03:00
|
|
|
|
app.state.personal_docs_manager = personal_docs_mgr
|
2026-05-31 23:58:26 +09:00
|
|
|
|
api_key_manager = components["api_key_manager"]
|
|
|
|
|
|
preset_manager = components["preset_manager"]
|
|
|
|
|
|
chat_processor = components["chat_processor"]
|
|
|
|
|
|
research_handler = components["research_handler"]
|
2026-06-11 02:17:02 +03:00
|
|
|
|
app.state.research_handler = research_handler
|
2026-05-31 23:58:26 +09:00
|
|
|
|
chat_handler = components["chat_handler"]
|
|
|
|
|
|
model_discovery = components["model_discovery"]
|
|
|
|
|
|
skills_manager = components["skills_manager"]
|
|
|
|
|
|
|
|
|
|
|
|
# TTS
|
|
|
|
|
|
from services.tts import get_tts_service
|
|
|
|
|
|
|
|
|
|
|
|
tts_service = get_tts_service()
|
|
|
|
|
|
logger.info("TTS service initialized (provider managed via admin settings)")
|
|
|
|
|
|
|
|
|
|
|
|
# ========= EXCEPTION HANDLERS =========
|
|
|
|
|
|
@app.exception_handler(SessionNotFoundError)
|
|
|
|
|
|
async def session_not_found_handler(request: Request, exc: SessionNotFoundError):
|
|
|
|
|
|
return JSONResponse(status_code=404, content={"error": "SESSION_NOT_FOUND", "message": str(exc)})
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(InvalidFileUploadError)
|
|
|
|
|
|
async def invalid_file_upload_handler(request: Request, exc: InvalidFileUploadError):
|
|
|
|
|
|
return JSONResponse(status_code=400, content={"error": "INVALID_FILE_UPLOAD", "message": str(exc)})
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(LLMServiceError)
|
|
|
|
|
|
async def llm_service_error_handler(request: Request, exc: LLMServiceError):
|
|
|
|
|
|
return JSONResponse(status_code=502, content={"error": "LLM_SERVICE_ERROR", "message": str(exc)})
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(WebSearchError)
|
|
|
|
|
|
async def web_search_error_handler(request: Request, exc: WebSearchError):
|
|
|
|
|
|
return JSONResponse(status_code=502, content={"error": "WEB_SEARCH_ERROR", "message": str(exc)})
|
|
|
|
|
|
|
|
|
|
|
|
# ========= WEBHOOK MANAGER =========
|
|
|
|
|
|
from src.webhook_manager import WebhookManager
|
|
|
|
|
|
|
|
|
|
|
|
webhook_manager = WebhookManager(api_key_manager=api_key_manager)
|
|
|
|
|
|
|
|
|
|
|
|
# ========= INCLUDE ROUTERS =========
|
|
|
|
|
|
|
|
|
|
|
|
# Auth
|
|
|
|
|
|
auth_router = setup_auth_routes(auth_manager)
|
|
|
|
|
|
app.include_router(auth_router)
|
|
|
|
|
|
|
2026-06-30 02:12:30 +00:00
|
|
|
|
|
|
|
|
|
|
@app.post("/api/activity/heartbeat")
|
|
|
|
|
|
async def activity_heartbeat():
|
|
|
|
|
|
from src.interactive_gate import mark_browser_activity
|
|
|
|
|
|
await mark_browser_activity()
|
2026-07-07 00:50:07 +00:00
|
|
|
|
async def _stop_background():
|
|
|
|
|
|
try:
|
|
|
|
|
|
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
|
|
|
|
|
|
asyncio.create_task(_stop_background())
|
2026-06-30 02:12:30 +00:00
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# Uploads
|
|
|
|
|
|
from routes.upload_routes import setup_upload_routes
|
|
|
|
|
|
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)
|
|
|
|
|
|
app.include_router(upload_router)
|
|
|
|
|
|
upload_cleanup_task = None
|
|
|
|
|
|
|
|
|
|
|
|
# Emoji SVG proxy (same-origin, lazy-cached Twemoji) — lets the chat render
|
|
|
|
|
|
# emojis as flat SVG instead of system color glyphs.
|
|
|
|
|
|
from routes.emoji_routes import setup_emoji_routes
|
|
|
|
|
|
app.include_router(setup_emoji_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Sessions
|
|
|
|
|
|
from routes.session_routes import setup_session_routes
|
|
|
|
|
|
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
|
|
|
|
|
app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
|
|
|
|
|
from routes.admin_wipe_routes import setup_admin_wipe_routes
|
|
|
|
|
|
app.include_router(setup_admin_wipe_routes(session_manager))
|
|
|
|
|
|
|
2026-07-08 14:36:41 +02:00
|
|
|
|
# Addons / manifest modloader
|
|
|
|
|
|
from routes.addon_routes import setup_addon_routes
|
|
|
|
|
|
app.include_router(setup_addon_routes(auth_manager))
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# Memory
|
refactor(routes): move memory domain into routes/memory/ subpackage (#5007)
Slice 2c of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves memory_routes.py into
routes/memory/, leaving a backward-compat sys.modules shim at the old path.
Pure file reorganization, no behavior change.
The shim uses sys.modules replacement (same pattern as the merged gallery
#4903 and research #4975 slices) so that `import routes.memory_routes`,
`from routes.memory_routes import X`, `importlib.import_module(...)`, and
the `import ... as mr` + `monkeypatch.setattr(mr, ...)` pattern used by
test_memory_routes_session_owner.py / test_memory_owner_isolation.py all
operate on the same module object the application uses.
The canonical module does NOT depend on the shim — routes/memory/
memory_routes.py imports only from services/, core/, src/, and stdlib (zero
internal routes/ coupling).
Four source-introspection test sites repointed to the new canonical path:
- test_direct_upload_limits.py
- test_upload_limits_centralized.py (two dict keys)
- test_vision_owner_scope.py
Adds tests/test_memory_routes_shim.py to pin the sys.modules shim contract
(legacy and canonical paths resolve to the same module object; monkeypatch
via legacy alias reaches the canonical module).
Verified: compileall clean; full suite 4219 passed, 3 skipped.
2026-06-30 23:52:14 +08:00
|
|
|
|
from routes.memory.memory_routes import setup_memory_routes
|
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
|
|
|
|
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)
|
|
|
|
|
|
app.include_router(memory_router)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
from routes.skills_routes import setup_skills_routes
|
|
|
|
|
|
app.include_router(setup_skills_routes(skills_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# Chat
|
|
|
|
|
|
from routes.chat_routes import setup_chat_routes
|
|
|
|
|
|
app.include_router(setup_chat_routes(
|
|
|
|
|
|
session_manager, chat_handler, chat_processor,
|
|
|
|
|
|
memory_manager, research_handler, upload_handler,
|
|
|
|
|
|
memory_vector=memory_vector,
|
|
|
|
|
|
webhook_manager=webhook_manager,
|
|
|
|
|
|
skills_manager=skills_manager,
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
# Research (background deep-research tasks)
|
2026-06-28 21:34:11 +08:00
|
|
|
|
from routes.research.research_routes import setup_research_routes
|
2026-05-31 23:58:26 +09:00
|
|
|
|
app.include_router(setup_research_routes(research_handler, session_manager=session_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# History
|
refactor(routes): move history domain into routes/history/ subpackage (#5090)
Slice 2d of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves history_routes.py into
routes/history/, leaving a backward-compat sys.modules shim at the old path.
Pure file reorganization, no behavior change.
The shim uses sys.modules replacement (same pattern as the merged gallery
#4903, research #4975, and memory #5007 slices) so that `import
routes.history_routes`, `from routes.history_routes import X`,
`importlib.import_module(...)`, and the `import ... as history_routes` +
`monkeypatch.setattr(history_routes, ...)` pattern used by
test_history_compact_tool_calls.py / test_fork_session_metadata.py all
operate on the same module object the application uses.
The canonical module does NOT depend on the shim — routes/history/
history_routes.py imports only from core/, src/, and routes.session_routes
(a sibling route module whose old import path stays valid via its own shim
when session is migrated later).
Three source-introspection test sites repointed to the new canonical path:
- test_history_db_fallback_hidden.py
- test_history_order_by_timestamp_regression.py
- test_model_helper_owner_scope.py
Adds tests/test_history_routes_shim.py to pin the sys.modules shim contract
(legacy and canonical paths resolve to the same module object; monkeypatch
via legacy alias reaches the canonical module).
Verified: compileall clean; full suite 4351 passed, 3 skipped.
2026-07-04 19:36:35 +08:00
|
|
|
|
from routes.history.history_routes import setup_history_routes
|
2026-05-31 23:58:26 +09:00
|
|
|
|
app.include_router(setup_history_routes(session_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# Search
|
|
|
|
|
|
from routes.search_routes import setup_search_routes
|
|
|
|
|
|
app.include_router(setup_search_routes(config))
|
|
|
|
|
|
|
|
|
|
|
|
# Presets
|
|
|
|
|
|
from routes.preset_routes import setup_preset_routes
|
|
|
|
|
|
app.include_router(setup_preset_routes(preset_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# Diagnostics
|
|
|
|
|
|
from routes.diagnostics_routes import setup_diagnostics_routes
|
feat(diagnostics): add consolidated service health endpoint for degraded-state reporting (#964)
* Add consolidated service health endpoint for degraded-state reporting
ROADMAP (High Priority) asks for "Better degraded-state reporting for
ChromaDB, SearXNG, email, ntfy, and provider probes." Until now there was no
single readout of which subsystems are actually working: /api/health is only a
liveness ping and each subsystem's signal lives in a different module, so a
misconfigured self-host install gives no consolidated picture.
This adds an admin-only GET /api/diagnostics/services endpoint backed by a new
src/service_health.py aggregator. Each subsystem reports a uniform
{name, status, detail, meta} where status is ok | degraded | down | disabled,
and the response rolls up an overall verdict (worst non-disabled status).
Probes are deliberately non-intrusive and safe to poll:
- ChromaDB: reads the .healthy flags on the RAG and memory vector stores.
- SearXNG: GET /healthz (2xx), falling back to the instance root (<500). No
search query is run.
- ntfy: GET the server's built-in /v1/health. No test notification is sent.
- email: short IMAP connect+logout per configured account (no credentials in
meta).
- providers: probe each enabled ModelEndpoint's model list (no api_key in meta).
Probe functions take their inputs as parameters and isolate the network call to
injectable callables, so they unit-test without touching the network (same
pattern as the merged provider-endpoint tests). Network probes run concurrently
off the event loop via asyncio.to_thread with bounded per-probe timeouts.
memory_vector is now passed into setup_diagnostics_routes (new optional param,
backward-compatible) so ChromaDB's vector-memory store can be reported too.
Tests: tests/test_service_health.py — 29 tests covering every status mapping
per subsystem, the overall rollup, and that no secrets leak into meta.
Verification:
python -m pytest tests/test_service_health.py -q # 29 passed
python -m py_compile src/service_health.py routes/diagnostics_routes.py app.py
python -m pytest tests/test_endpoint_resolver.py tests/test_provider_endpoints.py -q
Backend + tests only; an Admin/Settings UI badge that renders this endpoint is
a natural follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(diagnostics): bound service-health wall-clock and redact secrets
Addresses review on #964.
Blocker 1 — genuinely bounded wall-clock:
- providers_health and email_health now fan out per-item probes across a
bounded thread pool (_bounded_map) with a hard total budget (_FANOUT_BUDGET),
instead of probing endpoints/accounts sequentially. Stragglers are reported
as a controlled `timeout` and never block; the pool is shut down with
wait=False so the response returns on time regardless of endpoint/account
count.
- The IMAP connect path now honors the service-health budget: _imap_connect
gained a pass-through `timeout` param and the probe calls it with
_PROBE_TIMEOUT instead of the default 15s.
- collect_service_health runs the four network subsystems concurrently, each
under a per-subsystem deadline (_SUBSYSTEM_DEADLINE), with an overall
wait_for ceiling (_AGGREGATE_DEADLINE) as a backstop.
Blocker 2 — no secret/raw-error leakage in the response:
- _safe_url strips userinfo, query, and fragment from every URL surfaced in
meta (searxng instance, ntfy base, provider name fallback), keeping only
scheme/host/port/path.
- _classify_error maps every probe failure to a controlled category token
(timeout, connection_refused, dns_error, tls_error, network_error,
http_error, auth_or_protocol_error, …) — raw str(exception), which can embed
credentialed URLs or server text, is never returned.
Tests (tests/test_service_health.py, +tests/test_diagnostics_service_route.py):
- URL userinfo/query redaction for searxng/ntfy/providers.
- secret-bearing exception strings map to categories and don't leak.
- multiple slow providers/accounts stay bounded (single + 25-endpoint cases).
- subsystems run concurrently; aggregate deadline yields a controlled result.
- route-level unauthenticated (401) / non-admin (403) / admin (200) coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(diagnostics): isolate route tests so they don't leak module globals
The new route tests replaced src.service_health.collect_service_health and
routes.diagnostics_routes.require_admin via direct assignment, which persisted
for the rest of the pytest session. In CI's full alphabetical run that fake
collector (returning services=[]) leaked into the later collect_service_health
tests and failed them. Switch to monkeypatch.setattr so both are restored after
each test. No production code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-09 21:00:24 +06:00
|
|
|
|
app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector))
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# Cleanup
|
|
|
|
|
|
from routes.cleanup_routes import setup_cleanup_routes
|
|
|
|
|
|
app.include_router(setup_cleanup_routes(session_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# Personal docs
|
|
|
|
|
|
from routes.personal_routes import setup_personal_routes
|
|
|
|
|
|
app.include_router(setup_personal_routes(personal_docs_mgr, rag_manager, rag_available))
|
|
|
|
|
|
|
|
|
|
|
|
# Embedding model management
|
|
|
|
|
|
from routes.embedding_routes import setup_embedding_routes
|
|
|
|
|
|
app.include_router(setup_embedding_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Models
|
|
|
|
|
|
from routes.model_routes import setup_model_routes
|
|
|
|
|
|
app.include_router(setup_model_routes(model_discovery))
|
|
|
|
|
|
|
feat(provider): add GitHub Copilot provider with device-flow auth (#1480)
* feat(provider): add GitHub Copilot provider with device-flow auth
Adds GitHub Copilot as a model provider, so Copilot models (gpt-4o/4.1/5,
Claude, Gemini, …) work through the normal chat + agent loop, incl. native
tool calling and vision.
Auth is one-click via the GitHub OAuth device flow; the access token is stored
as the endpoint's (encrypted) api_key and sent directly as `Authorization:
Bearer` (no Copilot-token exchange, no refresh — matching how editors talk to
the Copilot API). Copilot is a normal ModelEndpoint detected by host; the only
provider-specific behaviour is a small set of required request headers,
injected centrally.
Sign-in is available from Settings → model endpoints ("Connect GitHub
Copilot") and from chat via `/setup copilot`.
- src/copilot.py (new), routes/copilot_routes.py (new): constants, header
builders, device-flow start/poll, model discovery, owner-scoped endpoint
provisioning.
- src/llm_core.py, src/endpoint_resolver.py: detect `copilot`, inject headers,
per-request x-initiator/vision.
- src/agent_loop.py: allowlist api.githubcopilot.com for native tool schemas.
- src/model_context.py: known context windows for Copilot (no unauthenticated
/models probe).
- static/, README, tests/test_copilot*.py.
* Tidy copilot_routes: clarify supports_tools, note _PENDING is per-process
2026-06-04 21:13:14 +02:00
|
|
|
|
# GitHub Copilot device-flow login
|
|
|
|
|
|
from routes.copilot_routes import setup_copilot_routes
|
|
|
|
|
|
app.include_router(setup_copilot_routes())
|
|
|
|
|
|
|
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
|
|
|
|
# ChatGPT Subscription device-flow login
|
|
|
|
|
|
from routes.chatgpt_subscription_routes import setup_chatgpt_subscription_routes
|
|
|
|
|
|
app.include_router(setup_chatgpt_subscription_routes())
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# TTS
|
|
|
|
|
|
from routes.tts_routes import setup_tts_routes
|
|
|
|
|
|
app.include_router(setup_tts_routes(tts_service))
|
|
|
|
|
|
|
|
|
|
|
|
# STT
|
|
|
|
|
|
from services.stt import get_stt_service
|
|
|
|
|
|
stt_service = get_stt_service()
|
|
|
|
|
|
from routes.stt_routes import setup_stt_routes
|
|
|
|
|
|
app.include_router(setup_stt_routes(stt_service))
|
|
|
|
|
|
logger.info("STT service initialized (provider managed via settings)")
|
|
|
|
|
|
|
|
|
|
|
|
# Documents (artifacts/canvas)
|
|
|
|
|
|
from routes.document_routes import setup_document_routes
|
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
|
|
|
|
document_router = setup_document_routes(session_manager, upload_handler)
|
|
|
|
|
|
app.include_router(document_router)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# Signatures (reusable image stamps)
|
|
|
|
|
|
from routes.signature_routes import setup_signature_routes
|
|
|
|
|
|
app.include_router(setup_signature_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Gallery (image library)
|
2026-06-28 17:40:34 +08:00
|
|
|
|
from routes.gallery.gallery_routes import setup_gallery_routes
|
2026-05-31 23:58:26 +09:00
|
|
|
|
app.include_router(setup_gallery_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Persisted image-editor drafts (server-backed projects)
|
|
|
|
|
|
from routes.editor_draft_routes import setup_editor_draft_routes
|
|
|
|
|
|
app.include_router(setup_editor_draft_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Scheduled tasks + event bus
|
|
|
|
|
|
from src.task_scheduler import TaskScheduler
|
|
|
|
|
|
task_scheduler = TaskScheduler(session_manager)
|
|
|
|
|
|
from src.event_bus import set_task_scheduler
|
|
|
|
|
|
set_task_scheduler(task_scheduler)
|
|
|
|
|
|
from routes.task_routes import setup_task_routes
|
|
|
|
|
|
app.include_router(setup_task_routes(task_scheduler))
|
|
|
|
|
|
|
|
|
|
|
|
from routes.assistant_routes import setup_assistant_routes
|
|
|
|
|
|
app.include_router(setup_assistant_routes(task_scheduler))
|
|
|
|
|
|
|
|
|
|
|
|
# Calendar (CalDAV)
|
|
|
|
|
|
from routes.calendar_routes import setup_calendar_routes
|
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
|
|
|
|
calendar_router = setup_calendar_routes()
|
|
|
|
|
|
app.include_router(calendar_router)
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
# Shell (user-facing command execution)
|
|
|
|
|
|
from routes.shell_routes import setup_shell_routes
|
|
|
|
|
|
app.include_router(setup_shell_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Cookbook (model download/serve/cache, cookbook state sync)
|
|
|
|
|
|
from routes.cookbook_routes import setup_cookbook_routes
|
|
|
|
|
|
app.include_router(setup_cookbook_routes())
|
|
|
|
|
|
|
feat(agent): confine agent file/shell tools to a selectable workspace (#3665)
* feat(agent): workspace confinement via context-local binding + get_workspace tool
Bind the per-turn workspace once in execute_tool_block; the shared path
resolvers (_resolve_tool_path / _resolve_search_root) and the subprocess cwd
helper (agent_cwd) read it, so file tools + bash/python are confined centrally
and a new tool that uses the shared helpers cannot accidentally bypass it.
Adds the admin-gated /api/workspace/browse picker, a workspace pill + directory
modal (reusing existing modal/button CSS), the /workspace slash command, and a
get_workspace tool (replaces a system-prompt block). Confinement is OS-agnostic
(realpath/normcase/commonpath) and docker-safe (container paths, no host
assumptions). Reopens #2023.
* ux(workspace): clarify workspace is not a sandbox
Picker modal note + pill tooltip + get_workspace tool/output wording now state
plainly: read_file/write_file/edit_file/grep/glob/ls are confined to the folder,
but bash/python only start there (cwd) and are not sandboxed. Modal note reuses
the existing .muted class.
* fix(agent): treat an active workspace as file-work intent
A vague low-signal message (e.g. "look at the local project") matches no
domain keywords, so tool retrieval is skipped and only always-available tools
are offered — leaving the agent with no file access even though a workspace is
set. When a workspace is active, include the file/code tools (incl.
get_workspace) on low-signal turns so the agent can act on the folder.
Also requires the tool index (ChromaDB) to be reachable for normal retrieval;
that is an environment dependency, not part of this change.
* ux(workspace): hide pill + overflow entry in chat mode
Workspace only scopes the agent's file/shell tools, so the pill and the
overflow 'Workspace' entry are agent-only now — hidden in chat mode like the
bash toggle. Mode read from the DOM in syncWorkspaceIndicator; applyMode() is
called from the agent/chat setMode handler.
* prompt(tools): steer bash/python to defer to the dedicated file tools
bash/python schema descriptions (what native-tool-calling models read) were
bare and gave no steer, so models would do file ops via the shell (e.g. writing
SVG/HTML, which then dumps raw markup into the tool preview). Tell bash/python
in the schema + tool-index + prompt section to prefer read_file/write_file/
edit_file/grep/glob/ls and only be used for what those do not cover.
* prompt(tools): keep bash/python deferral generic (no hardcoded tool names)
Reference 'a dedicated tool' rather than listing read_file/write_file/grep/etc.
by name, so the guidance does not go stale if those tools are renamed.
* style(workspace): drop em-dashes from added code comments/strings
* ux(workspace): terser non-sandbox note in picker (no tool-name list)
* ux(workspace): mirror terse non-sandbox wording in pill tooltip
* chore: untrack local venv symlink (run-only, not part of the feature)
* prompt(workspace): keep get_workspace text generic (no hardcoded tool names)
* fix(agent): low-signal + workspace surfaces only read-only file tools
Intersect the files tool group with PLAN_MODE_READONLY_TOOLS so a vague message
in a workspace exposes read_file/grep/glob/ls/get_workspace for exploration, but
not write_file/edit_file/bash/python -- those wait for a request that actually
calls for them (RAG retrieval still adds them on a real ask).
* feat(workspace): cap browse listing at 500 dirs with a truncated hint
Mirror the filesystem_tools._CODENAV_MAX_HITS pattern with a module-local
_MAX_BROWSE_DIRS so a directory with thousands of children does not dump every
row into the picker; the response carries a truncated flag and the modal tells
the user to type a path to jump in.
* chore: untrack local venv symlink (run-only artifact)
* fix(workspace): vet the workspace root against the sensitive-path deny list at bind time
The in-workspace resolver deny-lists sensitive paths inside the workspace,
but the empty-path search root is the workspace itself, so a workspace of
~/.ssh could be listed via ls with no path. vet_workspace() (public, in
tool_execution next to the resolvers) rejects non-directories and sensitive
roots before the path is ever bound; chat_routes uses it instead of its
inline isdir check.
* fix(workspace): reject filesystem roots and stop showing rejected workspaces as active
Review findings from #3665:
P2: vet_workspace accepted / (and would accept drive/UNC roots), which makes
every absolute path 'inside' the workspace and collapses confinement into
host-wide file access. A root is its own dirname, so reject when
dirname(resolved) == resolved; the browse response now carries a selectable
flag and the picker disables 'Use this folder' on unselectable dirs.
P3: /workspace set stored any string client-side and the chat route silently
dropped rejected values, so the pill could claim a confinement that was not
in effect. New admin-gated /api/workspace/vet validates manual paths before
they persist (canonical path returned), and when a posted workspace is
rejected at send time the stream emits workspace_rejected so the client
clears the stored value and toasts instead of continuing silently.
* fix(workspace): check caller privilege before vetting the posted workspace
Review finding: /api/chat_stream called vet_workspace() on the posted value
for every caller and emitted workspace_rejected on failure, so a non-admin
who can chat but cannot use file/shell tools could distinguish existing
directories from missing/file/sensitive/root paths by whether the event
appeared. The resolution now lives in _resolve_request_workspace, which
drops the submitted value uniformly for non-admin callers, with no vetting
and no event, before the path ever touches the filesystem. Admin and
single-user behavior is unchanged. Test pins that valid and invalid paths
are indistinguishable for a non-admin and that vet_workspace is never
invoked for them.
2026-06-11 18:17:54 +02:00
|
|
|
|
from routes.workspace_routes import setup_workspace_routes
|
|
|
|
|
|
app.include_router(setup_workspace_routes())
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# Hardware model fitting (cookbook "What Fits?" tab)
|
|
|
|
|
|
from routes.hwfit_routes import setup_hwfit_routes
|
|
|
|
|
|
app.include_router(setup_hwfit_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Model A/B Comparison
|
|
|
|
|
|
from routes.compare_routes import setup_compare_routes
|
|
|
|
|
|
app.include_router(setup_compare_routes(session_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# User Preferences
|
|
|
|
|
|
from routes.prefs_routes import setup_prefs_routes
|
|
|
|
|
|
app.include_router(setup_prefs_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Backup (export/import user data)
|
|
|
|
|
|
from routes.backup_routes import setup_backup_routes
|
|
|
|
|
|
app.include_router(setup_backup_routes(memory_manager, preset_manager, skills_manager))
|
|
|
|
|
|
|
|
|
|
|
|
from routes.font_routes import setup_font_routes
|
|
|
|
|
|
app.include_router(setup_font_routes())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# MCP (Model Context Protocol)
|
|
|
|
|
|
from src.mcp_manager import McpManager
|
|
|
|
|
|
from src.agent_tools import set_mcp_manager
|
|
|
|
|
|
from routes.mcp_routes import setup_mcp_routes
|
|
|
|
|
|
|
|
|
|
|
|
mcp_manager = McpManager()
|
|
|
|
|
|
set_mcp_manager(mcp_manager)
|
|
|
|
|
|
app.include_router(setup_mcp_routes(mcp_manager))
|
|
|
|
|
|
logger.info("MCP routes initialized")
|
|
|
|
|
|
|
|
|
|
|
|
# AI Interaction tools (debates, pipelines, self-managing AI, UI control)
|
|
|
|
|
|
from src.ai_interaction import set_session_manager as set_ai_session_manager, set_memory_manager as set_ai_memory_manager, set_rag_manager as set_ai_rag_manager
|
|
|
|
|
|
set_ai_session_manager(session_manager)
|
|
|
|
|
|
set_ai_memory_manager(memory_manager, memory_vector)
|
|
|
|
|
|
set_ai_rag_manager(rag_manager, personal_docs_mgr)
|
|
|
|
|
|
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
|
|
|
|
|
|
|
|
|
|
|
# Webhooks
|
|
|
|
|
|
from routes.webhook_routes import setup_webhook_routes
|
|
|
|
|
|
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
|
|
|
|
|
|
|
|
|
|
|
# API Tokens
|
|
|
|
|
|
from routes.api_token_routes import setup_api_token_routes
|
|
|
|
|
|
app.include_router(setup_api_token_routes())
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("Webhook & API token routes initialized")
|
|
|
|
|
|
|
|
|
|
|
|
# Notes (Google Keep-style notes/todos)
|
|
|
|
|
|
from routes.note_routes import setup_note_routes
|
|
|
|
|
|
app.include_router(setup_note_routes(task_scheduler))
|
|
|
|
|
|
|
|
|
|
|
|
# Email
|
|
|
|
|
|
from routes.email_routes import setup_email_routes
|
2026-06-03 22:38:05 +09:00
|
|
|
|
email_router = setup_email_routes()
|
|
|
|
|
|
app.include_router(email_router)
|
|
|
|
|
|
|
|
|
|
|
|
# Codex integration — HTTP surface for the Codex plugin/MCP bridge. Reuses
|
|
|
|
|
|
# api_token scopes (todos:read|write, email:read|draft|send) so external
|
|
|
|
|
|
# Codex sessions can only touch the data the user explicitly allowed. Mounted
|
|
|
|
|
|
# AFTER email so the codex_routes can borrow the email router for shared
|
|
|
|
|
|
# search/threading helpers.
|
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
|
|
|
|
from routes.codex_routes import setup_codex_routes, setup_claude_routes
|
|
|
|
|
|
app.include_router(setup_codex_routes(
|
|
|
|
|
|
email_router=email_router,
|
|
|
|
|
|
memory_router=memory_router,
|
|
|
|
|
|
calendar_router=calendar_router,
|
|
|
|
|
|
document_router=document_router,
|
|
|
|
|
|
))
|
|
|
|
|
|
app.include_router(setup_claude_routes())
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
from routes.vault_routes import setup_vault_routes
|
|
|
|
|
|
app.include_router(setup_vault_routes())
|
|
|
|
|
|
|
|
|
|
|
|
# Contacts (CardDAV)
|
refactor(routes): move contacts domain into routes/contacts/ subpackage (#5227)
Slice 2e of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves contacts_routes.py into
routes/contacts/, leaving a backward-compat sys.modules shim at the old path.
Pure file reorganization, no behavior change.
The shim uses sys.modules replacement (same pattern as the merged gallery
#4903, research #4975, memory #5007, and history #5090 slices) so that
`import routes.contacts_routes`, `from routes.contacts_routes import X`,
`importlib.import_module(...)`, the string-targeted
`monkeypatch.setattr("routes.contacts_routes.SETTINGS_FILE", ...)` used by
test_carddav_password_encryption.py, and the `import ... as cr` +
`setattr(cr, ...)` pattern in test_contacts_add_null_name.py all operate on
the same module object the application uses. This also keeps the mutable
module state `_contact_cache` identical across import paths.
The canonical module does NOT depend on the shim — routes/contacts/
contacts_routes.py imports only from core/, src/, and stdlib (zero internal
routes/ coupling). The inbound edge from routes/email_helpers.py (imports
_fetch_contacts) keeps working through the shim.
Zero source-introspection landmines — no test reads this file by path.
Adds tests/test_contacts_routes_shim.py to pin the sys.modules shim contract
(same-object + string-targeted monkeypatch reach-through).
Verified: compileall clean; full suite 4485 passed, 3 skipped.
2026-07-05 09:58:34 +08:00
|
|
|
|
from routes.contacts.contacts_routes import setup_contacts_routes
|
2026-05-31 23:58:26 +09:00
|
|
|
|
app.include_router(setup_contacts_routes())
|
|
|
|
|
|
|
2026-06-02 06:20:53 +04:00
|
|
|
|
from companion import setup_companion_routes
|
|
|
|
|
|
app.include_router(setup_companion_routes())
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# ========= ROUTES (kept in app.py) =========
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/")
|
|
|
|
|
|
async def serve_index(request: Request):
|
|
|
|
|
|
static_path = abs_join(BASE_DIR, "static/index.html")
|
|
|
|
|
|
if os.path.exists(static_path):
|
fix(routes): log and cleanly 500 on unreadable HTML page (#4637)
* fix(routes): serve 404 instead of 500 when an HTML page file is missing
_serve_html_with_nonce opened the HTML file with no error handling, and
callers such as /backgrounds and /login pass their paths in with no
existence check, so a missing or unreadable file raised an unhandled
OSError that surfaced as a 500. Wrap the read and raise HTTPException(404)
instead; the normal render path (CSP-nonce substitution) is unchanged.
Fixes #4594
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): distinguish missing page (404) from read failure (500)
The previous fix caught a broad OSError and returned 404 for every
failure, which masks real server-side problems (permission errors, I/O
failures) as "not found" and lets them slip past error alerting. Split
FileNotFoundError (genuine 404) from other OSError, which now logs the
exception and returns a generic 500 — without leaking the OS error
string or file path into the response body.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): treat unreadable bundled HTML page as logged 500, not 404
Per PR #4637 review: every caller of the page-render helper serves a fixed,
server-owned template (index/login/backgrounds), never a client-supplied
path. So a missing or unreadable file is a server fault (broken deployment),
not a client "not found" — a 404 there mislabels a server error and hides a
missing core template from 5xx alerting, contradicting the OSError->500
rationale this PR is built on. Collapse both branches into a single logged,
leak-free 500.
Move the helper to src.app_helpers.serve_html_with_nonce so the behavior can
be unit-tested without importing the whole app (app.py is the slim
orchestrator; the test harness stubs src.database, so importing app in tests
is not viable). Add tests pinning missing/unreadable -> 500 (not 404) and
nonce injection on the happy path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:12:32 +03:00
|
|
|
|
return serve_html_with_nonce(request, static_path)
|
fix(routes): 500 (not 404) when the app-shell index.html is missing (#4791)
Follow-up to #4637. serve_index — the handler for / and the SPA deep-link
routes (/notes, /calendar, /cookbook, /email, /memory, /gallery, /tasks,
/library) — pre-checked os.path.exists and raised its own
HTTPException(404, "index.html not found") when the bundle was missing. So a
missing core template returned 404 before serve_html_with_nonce's 500 could
fire, the one inconsistency left after #4637.
index.html is a fixed, app-bundled template; a missing one is a broken
deployment (server fault), not a client "not found", so it should surface as a
logged 500 in 5xx alerting rather than a 404. Keep the static->root fallback,
drop the redundant existence guard and the dead-end 404, and let the shared
helper handle the missing case.
Verified against the running app: / and /notes return 200 with the bundle
present and a logged 500 when index.html is absent.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:47:22 +03:00
|
|
|
|
# No static bundle — fall back to a root-level index.html if one is shipped.
|
|
|
|
|
|
# If neither exists, serve_html_with_nonce logs it and returns a generic 500:
|
|
|
|
|
|
# a missing index.html is a broken deployment (server fault), not a client
|
|
|
|
|
|
# "not found". This keeps the app-shell route consistent with the other
|
|
|
|
|
|
# bundled-template routes instead of mislabelling the fault as a 404.
|
|
|
|
|
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "index.html"))
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
@app.get("/notes")
|
|
|
|
|
|
async def serve_notes(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/calendar")
|
|
|
|
|
|
async def serve_calendar(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
# Per-tool deep-link routes — all serve the same SPA, the JS auto-opens
|
|
|
|
|
|
# the matching modal based on window.location.pathname. Each route also
|
|
|
|
|
|
# gets a unique favicon + page title via inline script in index.html so
|
|
|
|
|
|
# bookmarks render with tool-specific icons.
|
|
|
|
|
|
@app.get("/cookbook")
|
|
|
|
|
|
async def serve_cookbook(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/email")
|
|
|
|
|
|
async def serve_email(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/memory")
|
|
|
|
|
|
async def serve_memory(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/gallery")
|
|
|
|
|
|
async def serve_gallery(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/tasks")
|
|
|
|
|
|
async def serve_tasks(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/library")
|
|
|
|
|
|
async def serve_library(request: Request):
|
|
|
|
|
|
return await serve_index(request)
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/backgrounds")
|
|
|
|
|
|
async def serve_backgrounds(request: Request):
|
|
|
|
|
|
"""Sandbox page for prototyping background effects. No auth required."""
|
fix(routes): log and cleanly 500 on unreadable HTML page (#4637)
* fix(routes): serve 404 instead of 500 when an HTML page file is missing
_serve_html_with_nonce opened the HTML file with no error handling, and
callers such as /backgrounds and /login pass their paths in with no
existence check, so a missing or unreadable file raised an unhandled
OSError that surfaced as a 500. Wrap the read and raise HTTPException(404)
instead; the normal render path (CSP-nonce substitution) is unchanged.
Fixes #4594
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): distinguish missing page (404) from read failure (500)
The previous fix caught a broad OSError and returned 404 for every
failure, which masks real server-side problems (permission errors, I/O
failures) as "not found" and lets them slip past error alerting. Split
FileNotFoundError (genuine 404) from other OSError, which now logs the
exception and returns a generic 500 — without leaking the OS error
string or file path into the response body.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): treat unreadable bundled HTML page as logged 500, not 404
Per PR #4637 review: every caller of the page-render helper serves a fixed,
server-owned template (index/login/backgrounds), never a client-supplied
path. So a missing or unreadable file is a server fault (broken deployment),
not a client "not found" — a 404 there mislabels a server error and hides a
missing core template from 5xx alerting, contradicting the OSError->500
rationale this PR is built on. Collapse both branches into a single logged,
leak-free 500.
Move the helper to src.app_helpers.serve_html_with_nonce so the behavior can
be unit-tested without importing the whole app (app.py is the slim
orchestrator; the test harness stubs src.database, so importing app in tests
is not viable). Add tests pinning missing/unreadable -> 500 (not 404) and
nonce injection on the happy path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:12:32 +03:00
|
|
|
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
@app.get("/login")
|
|
|
|
|
|
async def serve_login(request: Request):
|
2026-06-07 17:47:21 +05:30
|
|
|
|
if not AUTH_ENABLED:
|
|
|
|
|
|
return RedirectResponse(url="/", status_code=302)
|
fix(routes): log and cleanly 500 on unreadable HTML page (#4637)
* fix(routes): serve 404 instead of 500 when an HTML page file is missing
_serve_html_with_nonce opened the HTML file with no error handling, and
callers such as /backgrounds and /login pass their paths in with no
existence check, so a missing or unreadable file raised an unhandled
OSError that surfaced as a 500. Wrap the read and raise HTTPException(404)
instead; the normal render path (CSP-nonce substitution) is unchanged.
Fixes #4594
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): distinguish missing page (404) from read failure (500)
The previous fix caught a broad OSError and returned 404 for every
failure, which masks real server-side problems (permission errors, I/O
failures) as "not found" and lets them slip past error alerting. Split
FileNotFoundError (genuine 404) from other OSError, which now logs the
exception and returns a generic 500 — without leaking the OS error
string or file path into the response body.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(routes): treat unreadable bundled HTML page as logged 500, not 404
Per PR #4637 review: every caller of the page-render helper serves a fixed,
server-owned template (index/login/backgrounds), never a client-supplied
path. So a missing or unreadable file is a server fault (broken deployment),
not a client "not found" — a 404 there mislabels a server error and hides a
missing core template from 5xx alerting, contradicting the OSError->500
rationale this PR is built on. Collapse both branches into a single logged,
leak-free 500.
Move the helper to src.app_helpers.serve_html_with_nonce so the behavior can
be unit-tested without importing the whole app (app.py is the slim
orchestrator; the test harness stubs src.database, so importing app in tests
is not viable). Add tests pinning missing/unreadable -> 500 (not 404) and
nonce injection on the happy path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:12:32 +03:00
|
|
|
|
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
@app.get("/api/version")
|
|
|
|
|
|
async def get_version():
|
2026-07-15 22:06:24 +02:00
|
|
|
|
from core.constants import (
|
|
|
|
|
|
APP_VERSION,
|
|
|
|
|
|
BUILD_FLAVOR,
|
|
|
|
|
|
BUILD_REVISION,
|
|
|
|
|
|
BUILD_VERSION,
|
|
|
|
|
|
FULL_VERSION,
|
|
|
|
|
|
UPSTREAM_VERSION,
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"version": BUILD_VERSION,
|
|
|
|
|
|
"upstream_version": UPSTREAM_VERSION,
|
|
|
|
|
|
"app_version": APP_VERSION,
|
|
|
|
|
|
"build_flavor": BUILD_FLAVOR,
|
|
|
|
|
|
"build_revision": BUILD_REVISION,
|
|
|
|
|
|
"build_version": BUILD_VERSION,
|
|
|
|
|
|
"full_version": FULL_VERSION,
|
|
|
|
|
|
}
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
@app.get("/api/health")
|
|
|
|
|
|
async def health_check() -> Dict[str, str]:
|
2026-06-19 02:58:25 +08:00
|
|
|
|
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
2026-07-07 00:50:07 +00:00
|
|
|
|
@app.post("/api/client-perf")
|
|
|
|
|
|
async def client_perf(request: Request):
|
|
|
|
|
|
"""Low-volume frontend timing reports for stalls that happen before SSE logs."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = await request.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
data = {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
kind = str(data.get("type") or "client").replace("\n", " ")[:80]
|
|
|
|
|
|
total_ms = float(data.get("total_ms") or 0)
|
|
|
|
|
|
stages = data.get("stages") if isinstance(data.get("stages"), list) else []
|
|
|
|
|
|
stage_txt = " ".join(
|
|
|
|
|
|
f"{str(s.get('name') or '')[:40]}={float(s.get('delta_ms') or 0):.0f}ms"
|
|
|
|
|
|
for s in stages[:20]
|
|
|
|
|
|
if isinstance(s, dict)
|
|
|
|
|
|
)
|
|
|
|
|
|
extra = str(data.get("extra") or "").replace("\n", " ")[:200]
|
|
|
|
|
|
logging.getLogger("app.client_perf").warning(
|
|
|
|
|
|
"client_perf type=%s total=%.0fms %s%s",
|
|
|
|
|
|
kind,
|
|
|
|
|
|
total_ms,
|
|
|
|
|
|
stage_txt,
|
|
|
|
|
|
f" extra={extra}" if extra else "",
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logging.getLogger("app.client_perf").debug("client_perf log failed", exc_info=True)
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
2026-06-02 22:33:22 +08:00
|
|
|
|
@app.get("/api/ready")
|
|
|
|
|
|
async def readiness_check() -> JSONResponse:
|
|
|
|
|
|
"""Readiness / integrity self-check — DB, data dir, local-first storage.
|
|
|
|
|
|
|
|
|
|
|
|
Unlike /api/health (liveness), this returns 503 unless every critical
|
|
|
|
|
|
subsystem is whole, so an orchestrator can gate traffic on real readiness.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from src.readiness import check_readiness
|
|
|
|
|
|
result = check_readiness()
|
|
|
|
|
|
return JSONResponse(status_code=200 if result.get("ready") else 503, content=result)
|
|
|
|
|
|
|
2026-06-01 10:00:15 +09:00
|
|
|
|
@app.get("/api/runtime")
|
|
|
|
|
|
async def runtime_info() -> Dict[str, object]:
|
|
|
|
|
|
in_docker = os.path.exists("/.dockerenv")
|
|
|
|
|
|
if not in_docker:
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open("/proc/1/cgroup", "r", encoding="utf-8", errors="ignore") as fh:
|
|
|
|
|
|
cg = fh.read()
|
|
|
|
|
|
in_docker = any(marker in cg for marker in ("docker", "containerd", "kubepods"))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
in_docker = False
|
|
|
|
|
|
ollama_url = (
|
|
|
|
|
|
os.getenv("OLLAMA_BASE_URL")
|
|
|
|
|
|
or os.getenv("OLLAMA_URL")
|
|
|
|
|
|
or ("http://host.docker.internal:11434/v1" if in_docker else "http://127.0.0.1:11434/v1")
|
|
|
|
|
|
)
|
|
|
|
|
|
return {
|
|
|
|
|
|
"in_docker": in_docker,
|
|
|
|
|
|
"ollama_base_url": ollama_url,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
# ========= LIFECYCLE =========
|
|
|
|
|
|
|
2026-06-02 21:43:14 -07:00
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def _lifespan(app):
|
|
|
|
|
|
"""Modern lifespan context manager replacing deprecated @app.on_event."""
|
|
|
|
|
|
# ── STARTUP ──
|
|
|
|
|
|
await _startup_event()
|
|
|
|
|
|
yield
|
|
|
|
|
|
# ── SHUTDOWN ──
|
|
|
|
|
|
await _shutdown_event()
|
|
|
|
|
|
|
|
|
|
|
|
app.router.lifespan_context = _lifespan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _startup_event():
|
2026-05-31 23:58:26 +09:00
|
|
|
|
global upload_cleanup_task
|
|
|
|
|
|
logger.info("Application starting up...")
|
|
|
|
|
|
webhook_manager.set_loop(asyncio.get_running_loop())
|
|
|
|
|
|
# Wipe any leftover incognito sessions from previous process — they're
|
|
|
|
|
|
# ephemeral by design and must not survive a restart.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from core.database import SessionLocal as _SL, Session as _DbSess, ChatMessage as _DbMsg
|
|
|
|
|
|
_db = _SL()
|
|
|
|
|
|
try:
|
|
|
|
|
|
_ghosts = _db.query(_DbSess).filter(_DbSess.name.in_(("Nobody", "Incognito"))).all()
|
|
|
|
|
|
for _g in _ghosts:
|
|
|
|
|
|
_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
|
|
|
|
|
|
_db.delete(_g)
|
|
|
|
|
|
if _ghosts:
|
|
|
|
|
|
_db.commit()
|
|
|
|
|
|
logger.info(f"Purged {len(_ghosts)} leftover incognito session(s)")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_db.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Incognito purge skipped: {e}")
|
|
|
|
|
|
# Strong refs to fire-and-forget startup tasks. Without this, Python may
|
|
|
|
|
|
# GC tasks created with `asyncio.create_task(...)` before they finish.
|
|
|
|
|
|
_startup_tasks: list[asyncio.Task] = getattr(app.state, "_startup_tasks", [])
|
|
|
|
|
|
app.state._startup_tasks = _startup_tasks
|
|
|
|
|
|
if upload_cleanup_func:
|
|
|
|
|
|
upload_cleanup_task = asyncio.create_task(upload_cleanup_func())
|
|
|
|
|
|
# Always-on monitor that auto-continues the agent when a background bash
|
|
|
|
|
|
# job (#!bg) finishes — re-invokes the turn with the job output.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.bg_monitor import start_bg_monitor
|
|
|
|
|
|
_startup_tasks.append(start_bg_monitor())
|
|
|
|
|
|
except Exception as _e:
|
|
|
|
|
|
logger.warning("Failed to start background-job monitor: %s", _e)
|
|
|
|
|
|
# MCP servers can be slow or blocked by local tooling. Connect them after
|
|
|
|
|
|
# the web server is accepting traffic instead of delaying the whole UI.
|
|
|
|
|
|
async def _startup_mcp_connections():
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.builtin_mcp import register_builtin_servers
|
|
|
|
|
|
await register_builtin_servers(mcp_manager)
|
|
|
|
|
|
except BaseException as e:
|
|
|
|
|
|
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.wait_for(mcp_manager.connect_all_enabled(), timeout=20)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
logger.warning("User MCP startup timed out (non-critical)")
|
|
|
|
|
|
except BaseException as e:
|
|
|
|
|
|
logger.warning(f"MCP startup failed (non-critical): {type(e).__name__}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
_startup_tasks.append(asyncio.create_task(_startup_mcp_connections()))
|
|
|
|
|
|
|
2026-07-07 00:50:07 +00:00
|
|
|
|
# Startup warmups are opt-in. They make later requests a little warmer, but
|
|
|
|
|
|
# they also compete with the first seconds of real UI use on slow or busy
|
|
|
|
|
|
# machines. Default to clear/idle startup and let requests warm what they use.
|
|
|
|
|
|
_startup_warmups_enabled = str(os.getenv("ODYSSEUS_STARTUP_WARMUPS", "")).lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
if _startup_warmups_enabled:
|
|
|
|
|
|
async def _warmup_tool_index():
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.tool_index import get_tool_index
|
|
|
|
|
|
idx = await asyncio.to_thread(get_tool_index)
|
|
|
|
|
|
if idx:
|
|
|
|
|
|
await asyncio.to_thread(idx.get_tools_for_query, "warmup", 8)
|
|
|
|
|
|
logger.info("[startup] Tool index pre-warmed")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
|
|
|
|
|
|
|
|
|
|
|
|
async def _warmup_endpoints():
|
|
|
|
|
|
try:
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
urls = (
|
|
|
|
|
|
await asyncio.to_thread(model_discovery.warmup_ping_urls)
|
|
|
|
|
|
if model_discovery else []
|
|
|
|
|
|
)
|
|
|
|
|
|
for url in urls:
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
|
|
|
|
await client.get(url)
|
|
|
|
|
|
logger.info(f"Warmup ping OK: {url}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Warmup ping failed for endpoint: {e}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Warmup ping skipped: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info("Startup warmups disabled (set ODYSSEUS_STARTUP_WARMUPS=1 to enable)")
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
2026-06-27 13:05:44 +00:00
|
|
|
|
# Keep-alive is opt-in. The ping path performs model discovery, and when
|
|
|
|
|
|
# stale LAN endpoints are configured it can add periodic backend pressure
|
|
|
|
|
|
# that delays unrelated UI requests such as Notes/Documents.
|
|
|
|
|
|
_keepalive_enabled = str(os.getenv("ODYSSEUS_MODEL_KEEPALIVE", "")).lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
if _keepalive_enabled:
|
|
|
|
|
|
async def _keepalive_loop():
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
await _warmup_endpoints()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Keepalive loop error: {e}")
|
|
|
|
|
|
await asyncio.sleep(300) # Back off on error
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
2026-06-27 13:05:44 +00:00
|
|
|
|
_startup_tasks.append(asyncio.create_task(_keepalive_loop()))
|
2026-05-31 23:58:26 +09:00
|
|
|
|
|
|
|
|
|
|
async def _ensure_default_tasks():
|
|
|
|
|
|
# Create/reconcile default automation tasks + personal assistant for every user.
|
|
|
|
|
|
owners = set()
|
|
|
|
|
|
try:
|
|
|
|
|
|
import json as _json
|
2026-06-08 09:58:52 +02:00
|
|
|
|
auth_path = AUTH_FILE
|
2026-06-01 15:09:47 +09:00
|
|
|
|
with open(auth_path, encoding="utf-8") as f:
|
2026-05-31 23:58:26 +09:00
|
|
|
|
users = _json.load(f).get("users", {})
|
|
|
|
|
|
owners.update(users.keys())
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Default task auth-owner scan: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# Also reconcile owners already present in scheduled_tasks. This cleans
|
|
|
|
|
|
# up stale/demo/deleted-user built-ins that are no longer in auth.json;
|
|
|
|
|
|
# otherwise their old scheduled rows can keep firing forever.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from core.database import SessionLocal, ScheduledTask
|
|
|
|
|
|
from src.task_scheduler import HOUSEKEEPING_DEFAULTS
|
|
|
|
|
|
builtin_names = []
|
|
|
|
|
|
for defs in HOUSEKEEPING_DEFAULTS.values():
|
|
|
|
|
|
builtin_names.append(defs["name"])
|
|
|
|
|
|
builtin_names.extend(defs.get("legacy_names") or [])
|
|
|
|
|
|
db_seed = SessionLocal()
|
|
|
|
|
|
try:
|
|
|
|
|
|
rows = db_seed.query(ScheduledTask.owner).filter(
|
|
|
|
|
|
(ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())))
|
|
|
|
|
|
| (ScheduledTask.name.in_(builtin_names))
|
|
|
|
|
|
).distinct().all()
|
|
|
|
|
|
owners.update(row[0] for row in rows if row[0])
|
|
|
|
|
|
finally:
|
|
|
|
|
|
db_seed.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Default task existing-owner scan: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
for uname in sorted(owners):
|
|
|
|
|
|
try:
|
|
|
|
|
|
await task_scheduler.ensure_defaults(uname)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"ensure_defaults({uname}): {e}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Default tasks: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# Reconcile built-in tasks before the runner starts. Otherwise legacy
|
|
|
|
|
|
# scheduled built-ins can fire once before being converted to event tasks.
|
|
|
|
|
|
await _ensure_default_tasks()
|
|
|
|
|
|
|
|
|
|
|
|
# Disk-backed skills are not covered by the DB legacy-owner sweep. Repair
|
|
|
|
|
|
# ownerless or deleted/test-owner SKILL.md files so strict owner filtering
|
|
|
|
|
|
# does not make an existing library look empty after auth/account changes.
|
|
|
|
|
|
try:
|
|
|
|
|
|
import json as _json
|
2026-06-08 09:58:52 +02:00
|
|
|
|
auth_path = AUTH_FILE
|
2026-06-01 15:09:47 +09:00
|
|
|
|
with open(auth_path, encoding="utf-8") as f:
|
2026-05-31 23:58:26 +09:00
|
|
|
|
users = _json.load(f).get("users", {})
|
|
|
|
|
|
primary_owner = None
|
|
|
|
|
|
for uname, udata in users.items():
|
|
|
|
|
|
if udata.get("is_admin") is True:
|
|
|
|
|
|
primary_owner = uname
|
|
|
|
|
|
break
|
|
|
|
|
|
if not primary_owner and users:
|
|
|
|
|
|
primary_owner = next(iter(users))
|
|
|
|
|
|
if primary_owner:
|
|
|
|
|
|
changed = skills_manager.backfill_owner(primary_owner, set(users.keys()))
|
|
|
|
|
|
if changed:
|
|
|
|
|
|
logger.info("Assigned %s legacy skill file(s) to %s", changed, primary_owner)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Skill owner backfill skipped: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# Start scheduled task runner — skip when running under a cron-driven
|
|
|
|
|
|
# deployment where an external worker drives task firing. Mirrors
|
|
|
|
|
|
# `ODYSSEUS_INPROCESS_POLLERS` from the email pollers.
|
|
|
|
|
|
_tasks_inprocess = os.environ.get("ODYSSEUS_INPROCESS_TASKS", "1").strip().lower()
|
|
|
|
|
|
if _tasks_inprocess not in ("0", "false", "no", "off", ""):
|
|
|
|
|
|
await task_scheduler.start()
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"In-process task scheduler disabled (ODYSSEUS_INPROCESS_TASKS=0); "
|
|
|
|
|
|
"drive task firing externally (e.g. cron)."
|
|
|
|
|
|
)
|
|
|
|
|
|
# Periodic null-owner sweep — re-runs the legacy-owner assignment hourly
|
|
|
|
|
|
# so any data created while auth was disabled / localhost-bypassed gets
|
|
|
|
|
|
# claimed by the admin instead of staying world-visible (M19).
|
|
|
|
|
|
async def _null_owner_sweep_loop():
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.sleep(3600)
|
|
|
|
|
|
from core.database import _migrate_assign_legacy_owner
|
|
|
|
|
|
await asyncio.to_thread(_migrate_assign_legacy_owner)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug(f"Null-owner sweep skipped: {e}")
|
|
|
|
|
|
await asyncio.sleep(3600)
|
|
|
|
|
|
|
|
|
|
|
|
_startup_tasks.append(asyncio.create_task(_null_owner_sweep_loop()))
|
|
|
|
|
|
|
|
|
|
|
|
# Nightly skill audit — at ~02:00 local, test + judge a batch of the
|
|
|
|
|
|
# least-recently-checked skills, auto-fixing/escalating weak ones (never
|
|
|
|
|
|
# deletes). Rotates through the library so each night covers different
|
|
|
|
|
|
# skills. Gated by the `skill_audit_nightly` setting (default on); hour via
|
|
|
|
|
|
# `skill_audit_hour` (default 2), batch size via `skill_audit_batch` (8).
|
|
|
|
|
|
async def _skill_audit_nightly_loop():
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.settings import get_setting
|
|
|
|
|
|
hour = int(get_setting("skill_audit_hour", 2) or 2)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
hour = 2
|
|
|
|
|
|
now = datetime.now()
|
|
|
|
|
|
nxt = now.replace(hour=hour % 24, minute=0, second=0, microsecond=0)
|
|
|
|
|
|
if nxt <= now:
|
|
|
|
|
|
nxt += timedelta(days=1)
|
|
|
|
|
|
await asyncio.sleep(max(60, (nxt - now).total_seconds()))
|
|
|
|
|
|
try:
|
|
|
|
|
|
from src.settings import get_setting
|
|
|
|
|
|
if not get_setting("skill_audit_nightly", True):
|
|
|
|
|
|
continue
|
|
|
|
|
|
batch = int(get_setting("skill_audit_batch", 8) or 8)
|
|
|
|
|
|
from routes.skills_routes import run_scheduled_skill_audit
|
|
|
|
|
|
await run_scheduled_skill_audit(skills_manager, owner=None, max_skills=batch)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Nightly skill audit failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
_startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop()))
|
Cookbook scheduler + serve: schedule via Tasks, Stop verifies kill, Ollama auto port-pick
- Schedule cookbook serves through the existing ScheduledTask system: the
serve preset gets a ^ button next to Launch that opens a daily/hourly/
weekly form mirroring the admin-switch style; the schedule action runs
action_cookbook_serve, which delegates to /api/model/serve and stamps
the resulting task with _scheduledStopAtMs. A background
cookbook_serve_lifecycle loop ticks every 60s and kills any serve
whose window has ended, also dropping the auto-registered endpoint
so the model picker doesn't keep pointing at a dead server.
- Stop and remove on a Running serve now awaits the SSH/tmux kill,
re-checks tmux has-session, and surfaces an error toast (leaving the
row) when the kill failed. Previously fire-and-forget, so a failed
SSH/tmux call silently left the live serve running while the row
vanished from the UI.
- Cookbook tasks/status orphan-adoption sweep no longer requires the
serve-/cookbook- session-id prefix; any tmux session whose pane is
running a known model-server process gets auto-pulled into Running.
Without this loosening, a cookbook-launched serve whose tmux id
fell back to a bare number was invisible — you couldn't see it,
let alone stop it.
- Ollama serve always launches a fresh process under cookbook's tmux
(no more monitor-mode reattach to a systemd/Docker ollama Stop can't
reach). The handler pre-picks a free port by probing the target
host over SSH and mutates req.cmd's OLLAMA_HOST so the runner script
AND the auto-registered endpoint agree on the same bind port.
- Auto-register uses host.docker.internal (when running inside Docker)
instead of localhost, matching the URL /setup adds for Ollama by
hand. Local cookbook serves now produce a chat-reachable endpoint
on first launch.
- Cascade-delete: removing a scheduled cookbook task also deletes any
linked calendar event (cookbook_task_id marker in the description).
- Tasks list groups cookbook_serve under a "Cookbook" category that
sorts above the rest, so scheduler-launched serves are easy to find.
2026-06-05 14:41:43 +09:00
|
|
|
|
|
|
|
|
|
|
# Cookbook serve lifecycle — kills scheduler-launched serves whose
|
|
|
|
|
|
# window-end has passed. Paired with the cookbook_serve builtin
|
|
|
|
|
|
# action; both are no-ops unless a scheduled task actually launches
|
|
|
|
|
|
# something with end_after_min set. Removing this line + the
|
|
|
|
|
|
# cookbook_serve entry in BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py
|
|
|
|
|
|
# removes the feature.
|
|
|
|
|
|
from src.cookbook_serve_lifecycle import cookbook_serve_lifecycle_loop
|
|
|
|
|
|
_startup_tasks.append(asyncio.create_task(cookbook_serve_lifecycle_loop()))
|
|
|
|
|
|
|
2026-05-31 23:58:26 +09:00
|
|
|
|
logger.info("Application startup complete")
|
|
|
|
|
|
|
2026-06-02 21:43:14 -07:00
|
|
|
|
async def _shutdown_event():
|
2026-05-31 23:58:26 +09:00
|
|
|
|
logger.info("Application shutting down...")
|
|
|
|
|
|
if upload_cleanup_task:
|
|
|
|
|
|
upload_cleanup_task.cancel()
|
|
|
|
|
|
try:
|
|
|
|
|
|
await upload_cleanup_task
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
# Stop task scheduler (no-op if it never started under the gate)
|
|
|
|
|
|
try:
|
|
|
|
|
|
await task_scheduler.stop()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
# Close webhook manager
|
|
|
|
|
|
try:
|
|
|
|
|
|
await webhook_manager.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Webhook manager shutdown error: {e}")
|
|
|
|
|
|
# Disconnect all MCP servers
|
|
|
|
|
|
try:
|
|
|
|
|
|
await mcp_manager.disconnect_all()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"MCP shutdown error: {e}")
|
|
|
|
|
|
logger.info("Application shutdown complete")
|
2026-06-16 06:58:16 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
|
|
|
|
|
bind_port = int(os.getenv("APP_PORT", "7000"))
|
|
|
|
|
|
|
|
|
|
|
|
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|