Files

1296 lines
54 KiB
Python
Raw Permalink Normal View History

2026-05-31 23:58:26 +09:00
# app.py — slim orchestrator
import mimetypes
2026-05-31 23:58:26 +09:00
import os
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
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
Fix email-thread HTML injection, attachment path traversal, and missing authz (#475) Hardens issues found in a security review of the current tree (separate from the cookbook SSH PR): - Email thread rendering (static/js/emailLibrary.js): the flat read path runs inbound HTML through the allowlist sanitizer, but the two threaded paths (_renderTurnsAsBubbles / _renderTurnsFromServer — the default view) injected server-parsed `body_html` raw into the DOM. A crafted inbound email could inject arbitrary markup (phishing/form/credential-capture/tracking; full XSS if a deployment relaxes the script CSP). Now sanitized on all paths. - Attachment extraction (routes/email_routes.py, routes/email_helpers.py): the on-disk extraction dir was `ATTACHMENTS_DIR / f"{folder}_{uid}"` with user-controlled folder/uid and no containment, so a folder like `../../tmp` could escape ATTACHMENTS_DIR. New attachment_extract_dir() flattens both to a single safe segment and asserts containment. - Diagnostics routes (routes/diagnostics_routes.py): /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research relied only on the global session check (any logged-in user). Now require_admin-gated. - Defense-in-depth HTML escaping: session HTML export escapes the session name (routes/session_routes.py); the MCP OAuth page escapes the reflected Host header / server_id (routes/mcp_routes.py). - Internal-tool token now compared with secrets.compare_digest (constant time) in core/middleware.py and app.py. Adds regression tests in tests/test_security_regressions.py.
2026-06-01 23:20:17 +10:00
import secrets
from datetime import datetime, timezone
2026-05-31 23:58:26 +09:00
from typing import Dict
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
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,
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
2026-05-31 23:58:26 +09:00
)
from core.database import SessionLocal, ApiToken
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
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
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 =========
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 =========
# 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 =========
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,
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
)
# ========= 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
"/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,
)
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())
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)
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"
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:
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()]
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
# 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:
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)
Fix email-thread HTML injection, attachment path traversal, and missing authz (#475) Hardens issues found in a security review of the current tree (separate from the cookbook SSH PR): - Email thread rendering (static/js/emailLibrary.js): the flat read path runs inbound HTML through the allowlist sanitizer, but the two threaded paths (_renderTurnsAsBubbles / _renderTurnsFromServer — the default view) injected server-parsed `body_html` raw into the DOM. A crafted inbound email could inject arbitrary markup (phishing/form/credential-capture/tracking; full XSS if a deployment relaxes the script CSP). Now sanitized on all paths. - Attachment extraction (routes/email_routes.py, routes/email_helpers.py): the on-disk extraction dir was `ATTACHMENTS_DIR / f"{folder}_{uid}"` with user-controlled folder/uid and no containment, so a folder like `../../tmp` could escape ATTACHMENTS_DIR. New attachment_extract_dir() flattens both to a single safe segment and asserts containment. - Diagnostics routes (routes/diagnostics_routes.py): /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research relied only on the global session check (any logged-in user). Now require_admin-gated. - Defense-in-depth HTML escaping: session HTML export escapes the session name (routes/session_routes.py); the MCP OAuth page escapes the reflected Host header / server_id (routes/mcp_routes.py). - Internal-tool token now compared with secrets.compare_digest (constant time) in core/middleware.py and app.py. Adds regression tests in tests/test_security_regressions.py.
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
# 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()
_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:
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)
chore: add warnings to silent except Exception blocks (#3212) * log(app): add warnings to silent except Exception blocks - Internal tool auth header failure now logs a warning instead of silently passing, making auth bypass easier to spot in logs. - Token last_used_at update failure now logs at DEBUG (fire-and-forget, non-critical, but useful when debugging token tracking issues). - Image ownership verification failure now logs a warning so unexpected access-check errors surface instead of silently allowing the request. * log(chat_routes): add warnings to silent except Exception blocks - clear_orphaned_session_endpoint: log before rollback so failures appear in traces when users see stale/deleted model options. - _endpoint_has_model (JSON parse): log malformed cached_models instead of silently treating endpoint as valid. - _has_any_visible_model (JSON parse): log malformed cached_models instead of silently returning empty list. - timezone header parse: log failure so time-zone-related tool bugs (wrong scheduled times, calendar events) are traceable. - attachments JSON parse: log failure so silently-dropped attachments are visible in server logs. * log(email_routes): add warnings to silent except Exception blocks - Email alias resolution failure now logs a warning instead of silently returning an empty list, making broken account configs diagnosable. * log(document_routes): add warnings to silent except Exception blocks - Export ZIP request body parse failure now logs a warning so empty exports caused by malformed requests are diagnosable. - clear_active_document failure on detach now logs a warning to help trace doc re-injection bugs like #1160. * log(agent_loop): add warnings to silent except Exception blocks - builtin tool overrides load failure now logs a warning so misconfigured settings don't silently fall back to defaults without a trace. - Timezone context injection failure now logs a warning to help debug incorrect scheduled times in agent-created tasks. - PDF form-backed document detection failure now logs a warning so broken form-doc UI is traceable to the root cause. * log(llm_core): add warnings to silent except Exception blocks - Malformed URL in _is_ollama_native_url now logs a warning so bad endpoint configs are traceable instead of silently returning False. - Model list fetch failure now logs a warning with the endpoint URL so endpoints that silently vanish from the model picker are diagnosable. * log: pass exception via exc_info instead of string interpolation * fix(logging): avoid logging raw URLs in llm_core error paths Drop the raw url/base_chat_url from the Ollama-detection and model-list-fetch warning logs added by this sweep, since these values can contain private hostnames, internal IPs, credentials, or other deployment details. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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)
chore: add warnings to silent except Exception blocks (#3212) * log(app): add warnings to silent except Exception blocks - Internal tool auth header failure now logs a warning instead of silently passing, making auth bypass easier to spot in logs. - Token last_used_at update failure now logs at DEBUG (fire-and-forget, non-critical, but useful when debugging token tracking issues). - Image ownership verification failure now logs a warning so unexpected access-check errors surface instead of silently allowing the request. * log(chat_routes): add warnings to silent except Exception blocks - clear_orphaned_session_endpoint: log before rollback so failures appear in traces when users see stale/deleted model options. - _endpoint_has_model (JSON parse): log malformed cached_models instead of silently treating endpoint as valid. - _has_any_visible_model (JSON parse): log malformed cached_models instead of silently returning empty list. - timezone header parse: log failure so time-zone-related tool bugs (wrong scheduled times, calendar events) are traceable. - attachments JSON parse: log failure so silently-dropped attachments are visible in server logs. * log(email_routes): add warnings to silent except Exception blocks - Email alias resolution failure now logs a warning instead of silently returning an empty list, making broken account configs diagnosable. * log(document_routes): add warnings to silent except Exception blocks - Export ZIP request body parse failure now logs a warning so empty exports caused by malformed requests are diagnosable. - clear_active_document failure on detach now logs a warning to help trace doc re-injection bugs like #1160. * log(agent_loop): add warnings to silent except Exception blocks - builtin tool overrides load failure now logs a warning so misconfigured settings don't silently fall back to defaults without a trace. - Timezone context injection failure now logs a warning to help debug incorrect scheduled times in agent-created tasks. - PDF form-backed document detection failure now logs a warning so broken form-doc UI is traceable to the root cause. * log(llm_core): add warnings to silent except Exception blocks - Malformed URL in _is_ollama_native_url now logs a warning so bad endpoint configs are traceable instead of silently returning False. - Model list fetch failure now logs a warning with the endpoint URL so endpoints that silently vanish from the model picker are diagnosable. * log: pass exception via exc_info instead of string interpolation * fix(logging): avoid logging raw URLs in llm_core error paths Drop the raw url/base_chat_url from the Ollama-detection and model-list-fetch warning logs added by this sweep, since these values can contain private hostnames, internal IPs, credentials, or other deployment details. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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
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."""
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
chore: add warnings to silent except Exception blocks (#3212) * log(app): add warnings to silent except Exception blocks - Internal tool auth header failure now logs a warning instead of silently passing, making auth bypass easier to spot in logs. - Token last_used_at update failure now logs at DEBUG (fire-and-forget, non-critical, but useful when debugging token tracking issues). - Image ownership verification failure now logs a warning so unexpected access-check errors surface instead of silently allowing the request. * log(chat_routes): add warnings to silent except Exception blocks - clear_orphaned_session_endpoint: log before rollback so failures appear in traces when users see stale/deleted model options. - _endpoint_has_model (JSON parse): log malformed cached_models instead of silently treating endpoint as valid. - _has_any_visible_model (JSON parse): log malformed cached_models instead of silently returning empty list. - timezone header parse: log failure so time-zone-related tool bugs (wrong scheduled times, calendar events) are traceable. - attachments JSON parse: log failure so silently-dropped attachments are visible in server logs. * log(email_routes): add warnings to silent except Exception blocks - Email alias resolution failure now logs a warning instead of silently returning an empty list, making broken account configs diagnosable. * log(document_routes): add warnings to silent except Exception blocks - Export ZIP request body parse failure now logs a warning so empty exports caused by malformed requests are diagnosable. - clear_active_document failure on detach now logs a warning to help trace doc re-injection bugs like #1160. * log(agent_loop): add warnings to silent except Exception blocks - builtin tool overrides load failure now logs a warning so misconfigured settings don't silently fall back to defaults without a trace. - Timezone context injection failure now logs a warning to help debug incorrect scheduled times in agent-created tasks. - PDF form-backed document detection failure now logs a warning so broken form-doc UI is traceable to the root cause. * log(llm_core): add warnings to silent except Exception blocks - Malformed URL in _is_ollama_native_url now logs a warning so bad endpoint configs are traceable instead of silently returning False. - Model list fetch failure now logs a warning with the endpoint URL so endpoints that silently vanish from the model picker are diagnosable. * log: pass exception via exc_info instead of string interpolation * fix(logging): avoid logging raw URLs in llm_core error paths Drop the raw url/base_chat_url from the Ollama-detection and model-list-fetch warning logs added by this sweep, since these values can contain private hostnames, internal IPs, credentials, or other deployment details. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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,
headers=GENERATED_IMAGE_HEADERS,
2026-05-31 23:58:26 +09:00
)
# ========= YOUTUBE INIT =========
from services.youtube import init_youtube
init_youtube()
Re-enable VectorRAG init with lazy retry Personal Docs (POST /api/personal/add_directory and friends) currently returns HTTP 503 'RAG system is not available' for every request, because get_rag_manager() and rag_manager are both hardcoded off. The disablement was added when chromadb 1.4.1 / pydantic 2.12 were mutually incompatible at the client init layer. That compat issue is fixed in the current pins (chromadb 1.5.x + pydantic 2.13.x). Verified by calling the original lazy initializer against a running chroma server — VectorRAG instantiates, reports healthy=True, and indexes successfully. This change: 1. src/rag_singleton.py — replace the hardcoded `return None` in get_rag_manager() with the original lazy init body. Keeps the 30s retry-throttle so a missing chroma server doesn't busy-retry on every request. 2. app.py — replace the parallel `rag_manager = None` / `rag_available = False` hardcoding with a get_rag_manager() call. Logs the resolved state at startup. If chroma isn't reachable yet, rag_manager stays None and personal-doc routes still return 503, but the *next* request will hit the retry-throttle path in get_rag_manager() and try to init again. Doesn't touch requirements.txt. Repos using docker-compose get chroma automatically; manual installs that want Personal Docs to work still need to either pip install chromadb (full package) and run `chroma run` or point at an external chroma instance via env. That can be a follow-up README / requirements-optional note.
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)
fix: session context drifting — messages leaking between chats (#135) (#267) * docs: add implementation plan for fixing chat context drifting (#135) * fix: make Session.history immutable + fix {}.history crash - Session.history now exposes a COPY of the internal _history list - add_message() replaces history with a fresh copy each time - get_context_messages() derives from _history directly - replace_messages() updates both _history and history - truncate_messages() updates both _history and history - _persist_message() line 207: fixed {}.history fallback crash - Added 11 tests for session isolation and edge cases Addresses #135 root cause #1: shared mutable references * fix: task scheduler uses SessionManager methods instead of overwriting sessions - Added ensure_task_session() to SessionManager (checks cache first) - Task scheduler now uses ensure_task_session() instead of direct dict assignment - Task scheduler now uses SessionManager.add_message() for message persistence - Removed direct sess_obj.history.append() that was silently losing data Addresses #135 root causes #2 and #3 * fix: add age guard to cleanup_empty_sessions — don't delete sessions <1h old Prevents the cleanup task from deleting sessions that were just created and haven't received any messages yet (message_count == 0). Addresses #135 root cause #5 * test: comprehensive session isolation tests (10/10 passing) * refactor: consolidate _session_manager into singleton pattern - Added set_session_manager_instance / get_session_manager_instance to core/models - kept backward-compat aliases (set_session_manager, get_session_manager) - session_manager.py re-exports the singleton functions - ai_interaction.set_session_manager now syncs with the core singleton - context_compactor uses get_session_manager_instance() instead of getattr hack - app.py initializes the singleton once Addresses #135 root cause #4: fragile global wiring * test: add concurrent session isolation integration tests Verifies: - Concurrent add_message to different sessions doesn't cross-contaminate - Rapid parallel writes maintain isolation - Read-write concurrent access is safe All 3 async tests pass, proving the immutable history fix works under concurrency * fix: pre-import core.models in conftest to prevent test pollution test_agent_loop.py stubs sys.modules['core.models'] = MagicMock() at module level during collection. Any test collected after it imports Session as a MagicMock. Pre-importing core.models in conftest.py before test_agent_loop.py's module-level code runs prevents this. * fix: make .history authoritative mutable list, address PR review Per review feedback: keep .history as the authoritative mutable list so existing code doing .history.pop(), .history = [...], etc. still works. Fix the cross-contamination bug by ensuring __post_init__() gives each Session its OWN unique history list (never shared). Changes: - core/models.py: .history IS the authoritative list. _history aliases it. Each Session gets its own list in __post_init__. - core/session_manager.py: add_message() delegates to Session.add_message() instead of appending directly — no double-append, single source of truth. - tests/test_session_manager.py: updated test to reflect that .history references see new messages (same list, not a snapshot). - docs/plans/2026-06-01-fix-chat-context-drifting.md: removed (not for shipping — useful design context but too much process/doc to ship). All 272 tests pass (3 pre-existing failures unrelated). * Fix session manager message persistence * Fix session history alias regressions * Fix session history aliasing and task delivery
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)
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"]
app.state.upload_handler = upload_handler
2026-05-31 23:58:26 +09:00
personal_docs_mgr = components["personal_docs_manager"]
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"]
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)
@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())
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
from routes.memory.memory_routes import setup_memory_routes
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)
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))
# 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
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)
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
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
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
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.
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())
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)
# 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):
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]:
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}
@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)
@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 =========
@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
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
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")
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")
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")