diff --git a/.env.example b/.env.example index 2d1be3373..f5e369bef 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,10 @@ LLM_HOST=localhost # Do not commit real keys. Keep this commented until needed. # OPENAI_API_KEY=your_openai_api_key_here +# Model seeded as the default chat endpoint on first boot (OpenAI). Not a secret. +# Change here or in the app's model picker. Matches render.yaml's default. +# OPENAI_DEFAULT_MODEL=gpt-5.6-sol + # Research service LLM endpoint # RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions @@ -49,6 +53,14 @@ SEARXNG_INSTANCE=http://localhost:8080 # and stores it in the searxng-data volume. # SEARXNG_SECRET= +# Cloud web-search providers (all optional; the bundled SearXNG needs no key). +# Add one only to enrich research beyond SearXNG. Do not commit real keys. +# DATA_BRAVE_API_KEY=your_brave_search_api_key_here # https://brave.com/search/api/ +# TAVILY_API_KEY=your_tavily_api_key_here # https://tavily.com/ +# SERPER_API_KEY=your_serper_api_key_here # https://serper.dev/ +# GOOGLE_API_KEY=your_google_api_key_here # Google Programmable Search +# GOOGLE_PSE_CX=your_programmable_search_engine_id_here + # ============================================================ # Database # ============================================================ @@ -84,6 +96,14 @@ SEARXNG_INSTANCE=http://localhost:8080 # by a trusted reverse proxy or private access gateway. # SECURE_COOKIES=true +# Trusted proxy hops in front of the app, read from the RIGHT of X-Forwarded-For. +# Every IP-keyed rate limiter (auth login/signup/setup and the demo caps) uses it. +# 0 = no proxy, app is directly internet-facing (XFF is ignored, real TCP peer used) +# 1 = one trusted edge proxy (Render's default topology) +# N = N trusted proxies +# Leaving 1 on a proxy-less deploy lets clients spoof XFF and bypass IP limits. +# TRUSTED_PROXY_HOPS=1 + # Optional: pre-seed the first admin password during setup. # Do not commit a real password. # ODYSSEUS_ADMIN_PASSWORD=change_me_before_first_boot @@ -130,6 +150,24 @@ SEARXNG_INSTANCE=http://localhost:8080 # FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2 # FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed +# Hugging Face token — only for downloading gated Hugging Face models. Optional. +# Do not commit real tokens. https://huggingface.co/settings/tokens +# HF_TOKEN=your_huggingface_token_here + +# ============================================================ +# Demo mode (opt-in public showcase — default OFF) +# ============================================================ +# DEMO=false keeps the full authenticated app (this is the default). DEMO=true +# opens a public, no-signup, locked-down chat demo that spends OPENAI_API_KEY. +# The caps below bound burn RATE, not total dollars — set a monthly usage limit +# on your OpenAI project for the true ceiling. See the README "Demo mode" section. +# DEMO=false +# DEMO_MODEL=gpt-5.6-luna # cheap OpenAI tier pinned for the demo path +# DEMO_RATE_LIMIT_PER_MINUTE=10 # chat requests/min per client IP; 0 disables +# DEMO_MAX_MESSAGES_PER_SESSION=30 # messages per visitor cookie session; 0 disables +# DEMO_MAX_MESSAGES_PER_IP_PER_DAY=200 # hard per-IP daily ceiling (the real backstop); 0 disables +# DEMO_MAX_OUTPUT_TOKENS=512 # output-token cap per demo LLM call + # ============================================================ # Google OAuth2 (Google Workspace / .edu email accounts) # ============================================================ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7d3659e8..49470917f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.11" + python-version: "3.14" # Byte-compile sources — catches syntax errors without installing deps. - run: python -m compileall -q app.py core routes src services scripts tests @@ -138,9 +138,9 @@ jobs: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 if: steps.docs-check.outputs.docs_only != 'true' with: - python-version: "3.11" + python-version: "3.14" cache: pip - - run: pip install -r requirements.txt + - run: pip install -r requirements.txt -r requirements-dev.txt if: steps.docs-check.outputs.docs_only != 'true' - run: mkdir -p data # sqlite DB lives at ./data/app.db if: steps.docs-check.outputs.docs_only != 'true' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38586845f..3bd08c6bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ Manual development uses Python 3.11+: ```bash python3 -m venv venv source venv/bin/activate -pip install -r requirements.txt +pip install -r requirements.txt -r requirements-dev.txt # drop -dev to run the app without test tooling python -m uvicorn app:app --host 127.0.0.1 --port 7000 ``` diff --git a/Dockerfile.render b/Dockerfile.render new file mode 100644 index 000000000..1892f6b5a --- /dev/null +++ b/Dockerfile.render @@ -0,0 +1,38 @@ +# Slim image for hosting Odysseus on Render (render.com). +# +# Deliberately drops the local-model / GPU / image-upscaling / host-Docker +# tooling from the main Dockerfile (Real-ESRGAN wheel build, torch, opencv, +# cmake, build-essential, nodejs/npm, the Docker CLI) — none of it runs on +# Render's managed platform — so builds are fast and the image stays small. +# Core chat, agents, research, documents, email, notes, and calendar (via cloud +# LLM APIs, SearXNG web search, and ChromaDB) are unaffected. +FROM python:3.14-slim + +# Runtime shared libs only: +# libmagic1 -> python-magic content-based MIME sniffing (src/upload_handler.py) +# libgomp1 -> onnxruntime, used by fastembed for local ONNX embeddings +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmagic1 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python deps first for layer caching. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# python-magic resolves libmagic at import time; keep it image-only (paired with +# the libmagic1 system lib installed above) exactly as the main Dockerfile does. +RUN pip install --no-cache-dir python-magic==0.4.27 + +# Copy app code. +COPY . . + +# Data / log / cache dirs. /app/data is backed by a persistent Render disk. +RUN mkdir -p data logs services/cache/search + +COPY docker/entrypoint.render.sh /usr/local/bin/entrypoint.render.sh +RUN chmod +x /usr/local/bin/entrypoint.render.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.render.sh"] diff --git a/README.md b/README.md index 705ec6b68..55c4c7921 100644 --- a/README.md +++ b/README.md @@ -2,75 +2,137 @@ Odysseus

-

- A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows. -

+# Odysseus on Render -

- Quick Start · - Setup Guide · - Contributing · - Roadmap -

+Deploy **Odysseus** on Render in one click. Get a self-hosted AI workspace — chat, agents, deep research, documents, email, notes, and calendar — running on your own instance with your own API keys. -

- Packaging status -

+[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Ho1yShif/odysseus) + +https://github.com/user-attachments/assets/53277926-7b65-4687-8a0a-42878bd549a8

- Odysseus interface + The Odysseus workspace — chat composer with the sidebar of tools: chat, email, calendar, deep research, notes, tasks, and more

---- +## What you get -## Quick Start +This Blueprint provisions three services on Render: -> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/odysseus-dev/odysseus/tree/main) if you want the more curated branch. +| Service | What it is | +|---------|------------| +| `odysseus` | The web app (chat, agents, research, documents, email, notes, calendar). Persistent disk at `/app/data`. | +| `odysseus-searxng` | Bundled [SearXNG](https://github.com/searxng/searxng) for private web search — powers Deep Research with no extra key. | +| `odysseus-chromadb` | Bundled [ChromaDB](https://www.trychroma.com/) vector store for RAG and semantic memory. | -```bash -git clone https://github.com/odysseus-dev/odysseus.git -cd odysseus -cp .env.example .env -docker compose up -d --build +Auth is on by default (`AUTH_ENABLED=true`, secure cookies, a generated admin password), and both helper services are private — only the web app is exposed. + +## Architecture + +Only `odysseus` is public. It reaches the two helper services over Render's private network, and calls out to your LLM and (optional) search providers with your own API keys. + +``` + ┌─────────────────────────────┐ + Internet ───► │ odysseus (public web app) │ + │ disk: /app/data │ + └──────┬───────────────┬──────┘ + │ private │ private + ┌──────▼──────┐ ┌─────▼────────────┐ + │ searxng │ │ chromadb │ + │ web search │ │ vector store │ + └──────┬──────┘ └──────────────────┘ + │ + ┌─────────┴──────────────────────────────┐ + │ external APIs (your keys) │ + │ OpenAI · Brave · Tavily · Serper · … │ + └────────────────────────────────────────┘ ``` -Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`. +> This is the **hosted** build. Local-model serving (Cookbook/vLLM/llama.cpp), GPU inference, image upscaling, and host-Docker features from the [upstream project](https://github.com/odysseus-dev/odysseus) don't run on Render and are omitted here; Odysseus uses cloud LLM APIs instead. For the full self-hosted feature set, see the [upstream repo](https://github.com/odysseus-dev/odysseus). + +## Deploy + +1. Click **Deploy to Render** above. +2. Fill in the API keys you want (see below) in the deploy form, then apply the Blueprint. +3. Wait for all three services to go live. + +### Environment variables + +Set these as secrets in the deploy form. All are optional per feature — you only need the keys for the features you'll use. + +To restrict `OPENAI_API_KEY`, a key with only the **Chat completions** (`/v1/chat/completions`) permission is enough — embeddings run locally (fastembed) and no other OpenAI endpoint is used. Set everything else to **None**. + +| Variable | Needed for | Where to get it | +|----------|-----------|-----------------| +| `OPENAI_API_KEY` | Chat, agents, research (LLM calls) | [platform.openai.com](https://platform.openai.com/api-keys) | +| `OPENAI_DEFAULT_MODEL` | Model seeded as the default chat on first boot (default `gpt-5.6-sol`; change here or in the app) — not a secret | — | +| `DATA_BRAVE_API_KEY` | Brave web search (optional — SearXNG is bundled) | [brave.com/search/api](https://brave.com/search/api/) | +| `TAVILY_API_KEY` | Tavily search provider (optional) | [tavily.com](https://tavily.com/) | +| `SERPER_API_KEY` | Serper search provider (optional) | [serper.dev](https://serper.dev/) | +| `GOOGLE_API_KEY` + `GOOGLE_PSE_CX` | Google Programmable Search (optional) | [Google Cloud](https://developers.google.com/custom-search) | +| `HF_TOKEN` | Gated Hugging Face models (optional) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) | + +Set automatically — no action needed: `ODYSSEUS_ADMIN_PASSWORD` (generated), `SEARXNG_SECRET` (generated), plus the internal service wiring. + +**Advanced — `ALLOWED_ORIGINS` (CORS):** by default the app locks CORS to its own Render URL (it reads `RENDER_EXTERNAL_URL` automatically), so you don't need to set anything. Only set `ALLOWED_ORIGINS` if you serve the app from a **custom domain** or need to allow **additional origins** — provide a comma-separated list of the full origins (e.g. `https://app.example.com,https://www.example.com`). + +### Using the app + +1. Open the `odysseus` service URL once it's live. +2. Log in as **`admin`**. Your admin password is **created for you automatically** at deploy time — you don't set one. Find it in the Render Dashboard → the `odysseus` service → **Environment** → `ODYSSEUS_ADMIN_PASSWORD` (a strong, randomly generated 256-bit value). Copy it to log in, then change it from the app after first login. It's never printed to the logs. +3. To have chat work out of the gate, set `OPENAI_API_KEY` on the `odysseus` web service's env vars **before first boot**. On startup the deploy seeds an OpenAI endpoint from that key (default model `OPENAI_DEFAULT_MODEL`), so you can open **Chat**, send a message, and get a reply with nothing to wire up in the model picker. +4. Try **Deep Research**: click **Deep Research** in the tools menu on the left-hand side to open its modal, enter your question, and run it. It searches the web through the bundled SearXNG (no extra key) and generates a sourced report. + +> Want to let strangers try the app without an admin password? See **[Demo mode](#demo-mode)** below — a public, no-signup chat surface you can turn on with `DEMO=true`. It's **off by default**, so a fresh deploy stays fully authenticated. + +## Demo mode + +`DEMO=true` runs a **public, no-signup, locked-down chat demo** on **your** OpenAI key — so anyone with the URL can try the chat without logging in. It is **off by default** (`DEMO=false`): a fresh fork or deploy gets the full authenticated app, unchanged. Only a deliberate `DEMO=true` turns it on. + +**How it works.** With the flag on, the login gate opens *for chat only*. Each visitor gets an isolated, ephemeral demo session (an unguessable per-visitor cookie → a synthetic owner) under a least-privilege profile. Everything else — settings, admin, integrations, and every other API route — still requires the admin login exactly as before. The admin account and its password are untouched. + +**What the demo can and can't do:** + +| Capability | In demo | +|---|---| +| Chat (pinned cheap model, capped output) | ✅ on | +| Shell / code / file tools | ❌ off | +| File upload & personal-doc RAG | ❌ off | +| Image generation, TTS / STT | ❌ off | +| Deep research | ❌ off (expensive per run) | +| Email, MCP servers, cookbook, task scheduler | ❌ off | +| Memory writes, API-token minting | ❌ off | +| Settings / admin / integrations | ❌ off (admin login still required) | -Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md). +**Abuse & cost limits** (demo-only; the demo spends **your** key, so watch your [OpenAI usage console](https://platform.openai.com/usage)): -## Features +| Variable | Default | What it caps | +|---|---|---| +| `DEMO_MODEL` | `gpt-5.6-luna` | the pinned (cheap) chat model | +| `DEMO_MAX_OUTPUT_TOKENS` | `512` | output tokens per reply | +| `DEMO_RATE_LIMIT_PER_MINUTE` | `10` | chat sends per minute, per client IP | +| `DEMO_MAX_MESSAGES_PER_SESSION` | `30` | total messages per visitor cookie session (UX friction) | +| `DEMO_MAX_MESSAGES_PER_IP_PER_DAY` | `200` | hard per-IP daily message ceiling (the real backstop) | -- **Chat + Agents** — local/API models, tools, MCP, files, shell, skills, and memory. -- **Cookbook** — hardware-aware model recommendations, downloads, and serving. -- **Deep Research** — multi-step web research with source reading and report generation. -- **Compare** — blind side-by-side model testing and synthesis. -- **Documents** — writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting. -- **Email** — IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts. -- **Notes, Tasks + Calendar** — reminders, todos, scheduled agent tasks, and CalDAV sync. -- **Extras** — gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA. +Raise or lower these in the deploy form / `render.yaml`. Set a limit to `0` to disable that one dimension; an unset variable falls back to the default (it **never** means "unlimited"). When a visitor hits a cap they get a friendly "deploy your own to keep going" reply — never an error. -## Demo +The rate limit and the per-IP daily ceiling are keyed on the **trusted client IP** — the entry Render's proxy attests on `X-Forwarded-For`, read from the right (`TRUSTED_PROXY_HOPS` hops in, default `1`), never the spoofable leftmost value. So clearing cookies or churning the demo session **can't** reset them. The per-session cap is cookie-based, so it's UX friction only; the per-IP ceiling is the volume backstop. Visitors behind one NAT/IP share a bucket — that errs toward more limiting, which is what you want for a cost guard. -A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html). +> `TRUSTED_PROXY_HOPS` is app-wide, not demo-only: the auth login/signup/setup rate limiters key on the same trusted IP. On first traffic the app logs a one-time `[trusted-ip] X-Forwarded-For sample` line — check it once on your deploy to confirm `1` hop resolves your real client IP (retune if you front the service with extra proxies). +> +> **Deploying off Render?** Set `TRUSTED_PROXY_HOPS` to match your topology: `0` if the service is directly internet-facing with **no** proxy in front (the whole `X-Forwarded-For` header is then attacker-supplied, so it's ignored and the limiters key on the real TCP peer), or `N` for `N` trusted proxies. Leaving the default `1` on a proxy-less deploy lets a client spoof `X-Forwarded-For` and bypass every IP-keyed limit. -## Contributing +> **Your real spend ceiling is the OpenAI limit, not these counters.** The caps above bound the burn *rate*; they reduce how fast the key can be spent, they don't cap total dollars. The one true per-call ceiling is `DEMO_MAX_OUTPUT_TOKENS`. For a hard dollar cap, set a [monthly usage limit on your OpenAI project](https://platform.openai.com/settings/organization/limits) — that's what protects the bill if the demo URL is discovered or abused. -Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md). +**Session isolation & privacy.** Visitors can't see each other's chats (each is scoped to its own synthetic owner), and demo history is **ephemeral** — it lives in memory only and is never written to the deployer's disk. If you host a public demo URL, add a visible "public demo, may reset — don't submit anything sensitive" notice. -## Security +### Scaling for heavy workloads -Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes). +The Blueprint defaults the web service to `standard` (2 GB). Odysseus can be resource-hungry under heavy use — large deep-research runs, big documents, sizable embedding jobs, or many concurrent sessions. For those workloads, give the instance more resources: in the Render Dashboard, open the `odysseus` service → **Settings → Instance Type** and pick a larger plan (and bump `odysseus-chromadb` too if your vector store grows). You can downgrade later if the smaller plan proves sufficient. -## Star History +## Learn more - - - - - Star History Chart - - +Full documentation, the complete self-hosted feature set, and contributing guidelines live in the upstream project: [odysseus-dev/odysseus](https://github.com/odysseus-dev/odysseus). ## License -AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). +AGPL-3.0-or-later — see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). diff --git a/app.py b/app.py index 8363ba4e9..ba7103644 100644 --- a/app.py +++ b/app.py @@ -126,7 +126,12 @@ def register_static_mime_types() -> None: # ========= CORS ========= CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] -allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",") +# Honor ALLOWED_ORIGINS when set. Otherwise, on Render, default to this +# service's own public origin (RENDER_EXTERNAL_URL is injected automatically) so +# a fresh hosted deploy is locked to same-origin with zero configuration; fall +# back to localhost for local dev. Never a wildcard — allow_credentials is on. +_default_origins = os.getenv("RENDER_EXTERNAL_URL") or "http://localhost,http://127.0.0.1" +allowed_origins = [o.strip() for o in os.getenv("ALLOWED_ORIGINS", _default_origins).split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, @@ -253,6 +258,12 @@ async def dispatch(self, request, call_next): if LOCALHOST_BYPASS: logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.") +# DEMO mode: opt-in, default off. When on, AuthMiddleware mints a per-visitor +# locked-down demo session for unauthenticated visitors on the demo route +# whitelist only; everything else still 302→/login or 401. Forks leave DEMO +# unset and get the full authenticated app. See src/demo.py. +from src import demo as _demo + if AUTH_ENABLED: AUTH_EXEMPT_EXACT = { "/api/auth/setup", @@ -266,6 +277,11 @@ async def dispatch(self, request, call_next): "/api/health", "/api/version", "/login", + # Browsers auto-request the domain-root /favicon.ico for tabs and + # bookmarks. Without this exemption AuthMiddleware 302→/login, the + # browser gets HTML instead of an icon, and falls back to a stale + # cached favicon. See the /favicon.ico route below. + "/favicon.ico", } AUTH_EXEMPT_PREFIXES = ["/static"] # Dynamic paths whose own handler proves identity via a path-embedded @@ -458,15 +474,30 @@ def _do(): # --- 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) + if auth_manager.validate_token(token): + # 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) + + # --- Demo path (opt-in, default off) --- + # Reached only for unauthenticated visitors AFTER the is_configured + # check, so first-run setup/login is unaffected. Mints a per-visitor + # locked-down synthetic owner, but ONLY on the demo route whitelist; + # everything else still 302→/login or 401 below. + if _demo.DEMO_MODE and _demo.is_demo_allowed(request.method, path): + owner, new_cookie = _demo.resolve_demo_owner(request) + request.state.current_user = owner + request.state.api_token = False + request.state.is_demo = True + response = await call_next(request) + if new_cookie: + _demo.set_demo_cookie(response, new_cookie) + return response + + if path.startswith("/api/"): + return JSONResponse(status_code=401, content={"error": "Not authenticated"}) + return RedirectResponse(url="/login", status_code=302) app.add_middleware(AuthMiddleware) logger.info("Auth middleware enabled (AUTH_ENABLED=true)") @@ -917,6 +948,17 @@ async def serve_backgrounds(request: Request): """Sandbox page for prototyping background effects. No auth required.""" return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html")) +@app.get("/favicon.ico") +async def serve_favicon(): + """Serve the favicon at the domain root. Browsers request /favicon.ico + automatically (independent of the page's ); serving it + here — rather than letting auth redirect it to /login — stops browsers + from falling back to a stale cached icon.""" + return FileResponse( + abs_join(STATIC_DIR, "favicon.ico"), + media_type="image/x-icon", + ) + @app.get("/login") async def serve_login(request: Request): if not AUTH_ENABLED: @@ -1008,6 +1050,9 @@ async def _lifespan(app): async def _startup_event(): global upload_cleanup_task logger.info("Application starting up...") + # Announce which mode booted (normal vs. DEMO) so a misconfigured deploy is + # obvious in the logs. Inert unless DEMO=true. + _demo.log_startup_mode(logger) 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. @@ -1016,12 +1061,24 @@ async def _startup_event(): _db = _SL() try: _ghosts = _db.query(_DbSess).filter(_DbSess.name.in_(("Nobody", "Incognito"))).all() + # Demo sessions are ephemeral too: their chat history is never + # persisted, but the lightweight metadata rows accumulate one per + # visitor page-load. Wipe them on boot so a long-running public demo + # can't grow the sessions table without bound (bounded by the 1-day + # demo cookie + each restart). Owner-scoped, so no real user data. + # + # Gated on DEMO_MODE for the same reason src.demo.is_demo_owner is: + # on a normal deploy a `demo-`-prefixed username is an ordinary user + # (usernames are only lowercased, so `demo-team` is registerable) and + # purging their sessions + messages every boot would be data loss. + if _demo.DEMO_MODE: + _ghosts += _db.query(_DbSess).filter(_DbSess.owner.like("demo-%")).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)") + logger.info(f"Purged {len(_ghosts)} leftover ephemeral session(s)") finally: _db.close() except Exception as e: diff --git a/core/auth.py b/core/auth.py index 4bc9a70dd..c746986ac 100644 --- a/core/auth.py +++ b/core/auth.py @@ -18,9 +18,13 @@ logger = logging.getLogger(__name__) - from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402 from core.middleware import INTERNAL_TOOL_USER # noqa: E402 +# src.demo is a leaf module (stdlib + src.rate_limiter), so this is a plain +# module-top import — no cycle to dodge. Imported as a module, not by name, so +# DEMO_PRIVILEGES is read through it: a `from ... import DEMO_PRIVILEGES` would +# bind the dict object and go stale if the module is ever reloaded. +from src import demo as _demo # noqa: E402 DEFAULT_PRIVILEGES = { "can_use_agent": True, @@ -42,6 +46,7 @@ # Admins get everything ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()} + ADMIN_PRIVILEGES["allowed_models_restricted"] = False # Admins must never be blocked from using models — the generic dict # comprehension above flips every boolean default to True, which would be @@ -384,6 +389,11 @@ def list_users(self) -> List[Dict[str, Any]]: def get_privileges(self, username: str) -> Dict[str, Any]: """Get privileges for a user. Admins get all privileges.""" + # Demo owners (demo-) get the least-privilege profile regardless of + # any stored config — they have no user row anyway. This is the single + # choke point that drives per-tool enforcement in chat_routes. + if _demo.is_demo_owner(username): + return {**DEFAULT_PRIVILEGES, **_demo.DEMO_PRIVILEGES} user = self.users.get(username, {}) if user.get("is_admin"): return dict(ADMIN_PRIVILEGES) diff --git a/core/session_manager.py b/core/session_manager.py index 6eb493e95..134cafe0b 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -9,6 +9,7 @@ """ import json +import threading import uuid import logging from datetime import datetime, timezone, timedelta @@ -17,6 +18,7 @@ from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive from .models import Session, ChatMessage from src.attachment_refs import persistable_message_content +from src import demo as _demo from src.upload_handler import reserve_message_upload_references # Re-export singleton accessors from models for convenience @@ -233,9 +235,15 @@ def _persist_message(self, session_id: str, message: ChatMessage): logger.warning("Dropping message for deleted session %s", session_id) return + # Demo history is ephemeral: keep it in the in-memory SessionManager + # cache only and never write a stranger's chat to the deployer's disk. + owner = getattr(db_session, "owner", None) + if _demo.is_demo_owner(owner): + return + missing_upload_id = reserve_message_upload_references( getattr(self, "upload_handler", None), - getattr(db_session, "owner", None), + owner, message.content, message.metadata, ) @@ -445,6 +453,14 @@ def sync_session_metadata(self, session_id: str) -> bool: session.owner = getattr(db_session, "owner", None) session.is_important = getattr(db_session, "is_important", False) or False session.message_count = getattr(db_session, "message_count", session.message_count) or 0 + # For demo owners, force the pinned model + endpoint + env OPENAI key + # authoritatively on every read. This overrides whatever the client + # sent to /api/session and is never persisted (the key stays + # env-only). Deliberately unguarded: if pinning ever fails, the outer + # handler returns False rather than letting the client's own endpoint + # values survive on a demo session. + if _demo.is_demo_owner(session.owner): + _demo.apply_demo_session_config(session) return True except Exception as e: logger.error(f"Error syncing session metadata {session_id}: {e}") diff --git a/docker/entrypoint.render.sh b/docker/entrypoint.render.sh new file mode 100644 index 000000000..3d1e03b55 --- /dev/null +++ b/docker/entrypoint.render.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Entrypoint for the hosted Render image (Dockerfile.render). +# +# On Render there is no bind-mounted host volume (the persistent disk at +# /app/data is managed and already writable), so the PUID/PGID ownership-repair +# dance in docker/entrypoint.sh is unnecessary here — we run as root and bind +# the port Render injects via $PORT. +set -eu + +# First-time setup is idempotent (creates auth.json/.env only if missing). +# || true so a non-critical hiccup never blocks startup — matches entrypoint.sh. +python /app/setup.py || true + +# Guard the one invariant a hosted deploy can't recover from on its own: with +# auth enabled, an admin account must exist. setup.py swallows its own admin +# errors and exits 0, so on failure the app would boot healthy (the "/" health +# check passes) yet nobody could ever log in. Assert it here so a seeding +# failure surfaces as a failed deploy instead. Reuses src.constants so the +# path tracks ODYSSEUS_DATA_DIR exactly like the app resolves it. +if [ "$(printf '%s' "${AUTH_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')" != "false" ]; then + python - <<'PY' +import json +import sys + +from src.constants import AUTH_FILE + +try: + with open(AUTH_FILE, encoding="utf-8") as fh: + users = json.load(fh).get("users", {}) +except (OSError, ValueError) as exc: + sys.exit(f"[fatal] admin auth not initialized ({AUTH_FILE}): {exc}") + +if not users: + sys.exit(f"[fatal] admin auth file {AUTH_FILE} has no users — seeding failed") +PY +fi + +# Seed an OpenAI model endpoint from OPENAI_API_KEY on first boot so chat works +# out of the box — setting the key alone leaves the model_endpoints table empty, +# which surfaces as "No chat session active" in the composer. Idempotent (skips +# if the key is unset or an OpenAI endpoint already exists) and non-fatal: a seed +# hiccup must not block the deploy — the admin can always add the endpoint from +# the UI. +python /app/docker/seed_openai_endpoint.py || true + +exec uvicorn app:app --host 0.0.0.0 --port "${PORT:-7000}" diff --git a/docker/searxng-render-entrypoint.sh b/docker/searxng-render-entrypoint.sh new file mode 100644 index 000000000..f8d6d9b2d --- /dev/null +++ b/docker/searxng-render-entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Render entrypoint for the bundled SearXNG image. +# +# Odysseus requires SearXNG's `json` output format, which the stock image does +# not enable — so we ship config/searxng/settings.yml (baked in at build) and +# render it into place on boot, substituting the secret_key. Mirrors the wrapper +# in docker-compose.yml. Runs as root, writes /etc/searxng, then hands off to +# SearXNG's own entrypoint (which drops privileges). +set -eu + +if [ ! -s /etc/searxng/settings.yml ] || grep -q '__SEARXNG_SECRET__' /etc/searxng/settings.yml; then + secret="${SEARXNG_SECRET:-}" + if [ -z "$secret" ]; then + secret="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')" + fi + mkdir -p /etc/searxng + # Substitute via python (secret passed through the env, not interpolated) so a + # sed metachar in SEARXNG_SECRET (&, \, |) can't corrupt the rendered config. + SEARXNG_SECRET="$secret" python - <<'PY' > /etc/searxng/settings.yml +import os + +with open("/usr/local/share/searxng-settings.yml.template", encoding="utf-8") as fh: + template = fh.read() +print(template.replace("__SEARXNG_SECRET__", os.environ["SEARXNG_SECRET"]), end="") +PY +fi + +exec /usr/local/searxng/entrypoint.sh diff --git a/docker/seed_openai_endpoint.py b/docker/seed_openai_endpoint.py new file mode 100644 index 000000000..d8af1a90d --- /dev/null +++ b/docker/seed_openai_endpoint.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Seed an OpenAI model endpoint on first boot of the hosted Render image. + +Setting ``OPENAI_API_KEY`` alone does NOT make chat work: the chat send path +resolves the default model from the ``model_endpoints`` DB table (see +``routes/model_routes.py::get_default_chat``), which is empty on a fresh deploy. +With no endpoint, the composer shows "No chat session active" even though the key +is set. This registers an OpenAI endpoint from ``OPENAI_API_KEY`` and marks it the +global default so "open Chat, send a message, get a reply" works out of the box. + +Runs in the entrypoint before the app boots, in a separate process. It shares the +app's Fernet key (a file under the persistent-disk data dir, see +``src.secret_storage``), so ``api_key`` is encrypted at rest exactly as the app +would encrypt it. + +Idempotent and safe to run every boot: +- skips when ``OPENAI_API_KEY`` is unset, +- skips when an ``api.openai.com`` endpoint already exists (the DB lives on the + persistent disk and survives redeploys), so it never duplicates or clobbers an + endpoint the admin later edits. + +The default model is pinned (not discovered via ``/v1/models``) so chat works even +when the key is restricted to ``/v1/chat/completions`` — the probe would 403 and +return no models otherwise. Override the pinned model with ``OPENAI_DEFAULT_MODEL``. +""" + +import json +import logging +import os +import sys +import uuid +from pathlib import Path + +# Run standalone from the entrypoint (python /app/docker/seed_openai_endpoint.py): +# Python puts this file's dir (docker/) on sys.path, not the repo root, so the +# core/ and src/ packages wouldn't import. Add the repo root (this file's parent +# dir's parent) explicitly. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +logging.basicConfig(level=logging.INFO, format="%(message)s") +log = logging.getLogger("seed_openai_endpoint") + +DEFAULT_MODEL = "gpt-5.6-sol" + + +def main() -> int: + api_key = (os.getenv("OPENAI_API_KEY") or "").strip() + if not api_key: + log.info("[seed] OPENAI_API_KEY not set — skipping OpenAI endpoint seed.") + return 0 + + # Imported lazily (after the key check) so a keyless deploy pays no import cost. + from core.database import ModelEndpoint, SessionLocal, init_db + from src.settings import load_settings, save_settings + + # Tables + migrations are idempotent; the app re-runs init_db() at startup. + init_db() + + model = (os.getenv("OPENAI_DEFAULT_MODEL") or DEFAULT_MODEL).strip() or DEFAULT_MODEL + + db = SessionLocal() + try: + existing = ( + db.query(ModelEndpoint) + .filter(ModelEndpoint.base_url.like("%api.openai.com%")) + .first() + ) + if existing is not None: + log.info("[seed] OpenAI endpoint already present (%s) — nothing to do.", existing.id) + return 0 + + ep_id = str(uuid.uuid4())[:8] + ep = ModelEndpoint( + id=ep_id, + name="OpenAI", + base_url="https://api.openai.com/v1", + api_key=api_key, # EncryptedText encrypts at rest via the shared app key + is_enabled=True, + model_type="llm", + endpoint_kind="api", + # Pin (and cache) the default model so the picker + composer work even + # when the key can't list /v1/models. A key with Models-read permission + # still gets the full list via the app's background refresh. + pinned_models=json.dumps([model]), + cached_models=json.dumps([model]), + owner=None, # shared: visible to the admin and any additional users + ) + db.add(ep) + db.commit() + + settings = load_settings() + settings["default_endpoint_id"] = ep_id + settings["default_model"] = model + save_settings(settings) + + log.info("[seed] Seeded OpenAI endpoint %s (default model %r).", ep_id, model) + finally: + db.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/render.yaml b/render.yaml new file mode 100644 index 000000000..8f32b79b3 --- /dev/null +++ b/render.yaml @@ -0,0 +1,135 @@ +# Render Blueprint for Odysseus — deploy the self-hosted AI workspace in one click. +# https://render.com/docs/blueprint-spec +# +# Three services, grouped under an "odysseus" project: the Odysseus web app, a +# bundled SearXNG (web search) and a bundled ChromaDB (vector store). The web app +# talks to the two private services over Render's internal network. Fill the +# provider API keys in the deploy form (stored as Render secrets, never committed). + +previews: + generation: "off" + +projects: + - name: odysseus + environments: + - name: production + services: + # ---- Odysseus web app ------------------------------------------ + - type: web + name: odysseus + runtime: docker + dockerfilePath: ./Dockerfile.render + plan: standard # 2 GB — in-process fastembed embeddings need headroom + region: oregon + healthCheckPath: /api/health + disk: + name: odysseus-data # SQLite DB, encrypted key store, uploads, embed cache + mountPath: /app/data + sizeGB: 10 + envVars: + # Auth / security — locked down for a public host by default. + - key: AUTH_ENABLED + value: "true" + - key: LOCALHOST_BYPASS + value: "false" + - key: SECURE_COOKIES + value: "true" # Render serves HTTPS + # Trusted proxy hops in front of the app — used by every IP-keyed + # rate limiter (auth login/signup/setup AND the demo caps). Render + # runs one edge proxy that appends the real client IP to the RIGHT + # of X-Forwarded-For, so we read 1 hop from the right (the leftmost + # entry is client-spoofable). Bump only if you front this service + # with additional trusted proxies. On first traffic the app logs a + # one-time "[trusted-ip] X-Forwarded-For sample" line — check it to + # confirm this value resolves your true client IP. + - key: TRUSTED_PROXY_HOPS + value: "1" + - key: ODYSSEUS_ADMIN_USER + value: admin + - key: ODYSSEUS_ADMIN_PASSWORD + generateValue: true # strong first-login password; view it in the dashboard + # Storage. + - key: DATABASE_URL + value: sqlite:///./data/app.db + - key: FASTEMBED_CACHE_PATH + value: /app/data/fastembed # persist the local embedding model on the disk + # Bundled services (internal network). + - key: SEARXNG_INSTANCE + value: http://odysseus-searxng:8080 + - key: CHROMADB_HOST + fromService: + type: pserv + name: odysseus-chromadb + property: host + - key: CHROMADB_PORT + value: "8000" + # ---- Provider credentials — set these in the one-click deploy form. ---- + # All optional per feature: OpenAI powers chat/agents; a search key + # (Brave/Tavily/Serper/Google) enriches research beyond the bundled SearXNG; + # HF_TOKEN is only for gated Hugging Face models. + - key: OPENAI_API_KEY + sync: false + - key: OPENAI_DEFAULT_MODEL + value: gpt-5.6-sol # seeds the default chat model on first boot; change here or in the app + - key: DATA_BRAVE_API_KEY + sync: false + - key: TAVILY_API_KEY + sync: false + - key: SERPER_API_KEY + sync: false + - key: GOOGLE_API_KEY + sync: false + - key: GOOGLE_PSE_CX + sync: false + - key: HF_TOKEN + sync: false + # ---- Demo mode — opt-in public showcase (default OFF) ------- + # DEMO=false keeps the full authenticated app (forks get this). + # DEMO=true opens a public, no-signup, locked-down chat demo that + # spends this deploy's OPENAI_API_KEY. See README "Demo mode". + # NOTE: the rate limit and per-IP daily ceiling below are keyed on + # the trusted client IP (Render's proxy-attested XFF), so cookie + # clearing / owner churn can't reset them. They bound burn RATE; + # they don't replace a hard dollar cap. Set a monthly usage limit on + # your OpenAI project for the true ceiling before enabling this. + - key: DEMO + value: "false" # opt-in; not a secret. "true"/"1"/"yes" turns it on. + - key: DEMO_MODEL + value: gpt-5.6-luna # cheap current OpenAI tier for the demo path (env OPENAI_API_KEY) + - key: DEMO_RATE_LIMIT_PER_MINUTE + value: "10" # chat requests/min per client IP; 0 disables this dimension + - key: DEMO_MAX_MESSAGES_PER_SESSION + value: "30" # total messages per visitor cookie session (UX friction); 0 disables + - key: DEMO_MAX_MESSAGES_PER_IP_PER_DAY + value: "200" # hard per-IP daily message ceiling (the real backstop); 0 disables + - key: DEMO_MAX_OUTPUT_TOKENS + value: "512" # cost cap on output tokens per demo LLM call + + # ---- SearXNG (web search) — private ---------------------------- + - type: pserv + name: odysseus-searxng + runtime: docker + dockerfilePath: ./searxng.Dockerfile + plan: starter + region: oregon + envVars: + - key: SEARXNG_SECRET + generateValue: true + - key: SEARXNG_BASE_URL + value: http://odysseus-searxng:8080/ + + # ---- ChromaDB (vector store) — private ------------------------- + - type: pserv + name: odysseus-chromadb + runtime: image + image: + url: docker.io/chromadb/chroma:1.0.20 + plan: starter + region: oregon + envVars: + - key: ANONYMIZED_TELEMETRY + value: "FALSE" + disk: + name: chromadb-data + mountPath: /chroma/chroma + sizeGB: 5 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000..cdc3aa65b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,11 @@ +# Development / test dependencies — NOT installed in the production image. +# The hosted Render image (Dockerfile.render) installs only requirements.txt, +# and tests/ is excluded from the build context (.dockerignore), so this test +# tooling stays out of the shipped container. Install locally with: +# pip install -r requirements.txt -r requirements-dev.txt +pytest==9.1.1 +pytest-asyncio==1.4.0 +# starlette.testclient prefers httpx2 since Starlette 1.2.0 and warns on every +# TestClient import when only classic httpx is present. Runtime code keeps +# using `httpx` (in requirements.txt); this is test-client only. +httpx2==2.7.0 diff --git a/requirements.txt b/requirements.txt index 3c5114f53..458e64119 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,53 +1,52 @@ -fastapi -uvicorn -python-multipart -python-dotenv -httpx -httpcore>=1.0,<2.0 -pydantic>=2.13.4 -pydantic-settings>=2.14.1 -SQLAlchemy -pypdf -beautifulsoup4 -charset-normalizer -numpy +# Core runtime dependencies, pinned for reproducible builds. +# Tested against Python 3.14 (see Dockerfile / Dockerfile.render). Bump +# deliberately after verifying the app still boots and tests pass. +fastapi==0.139.2 +uvicorn==0.51.0 +python-multipart==0.0.32 +python-dotenv==1.2.2 +httpx==0.28.1 +httpcore==1.0.9 +pydantic==2.13.4 +pydantic-settings==2.14.2 +SQLAlchemy==2.0.51 +pypdf==6.14.2 +beautifulsoup4==4.15.0 +charset-normalizer==3.4.9 +numpy==2.5.1 # Vector store + local embeddings for RAG, semantic memory, and tool # selection. Used on core agent paths, so installed by default — the app # still degrades to keyword fallback if they're ever missing. # chromadb-client is the lightweight HTTP client (talks to a standalone # ChromaDB service); fastembed runs local ONNX embeddings. -chromadb-client -fastembed -youtube-transcript-api +chromadb-client==1.5.9 +fastembed==0.8.0 +youtube-transcript-api==1.2.4 # Markdown rendering for research reports (src/visual_report.py). # Imported at module-top so it's a hard core dep, not optional. -markdown +markdown==3.10.2 # HTML sanitizer for rendered research reports (src/visual_report.py). Report # content is untrusted (LLM output over crawled pages) and report pages run # under a relaxed CSP, so the rendered HTML is allowlist-sanitized. -nh3 +nh3==0.3.6 # Calendar .ics import/export (routes/calendar_routes.py). -icalendar +icalendar==7.2.0 # Recurrence rule expansion for calendar events (routes/calendar_routes.py). # Imported directly as dateutil.rrule — make it explicit even though caldav # pulls it in transitively. -python-dateutil +python-dateutil==2.9.0.post0 # CalDAV sync (src/caldav_sync.py). Handles PROPFIND discovery + REPORT # fetch across Radicale, Nextcloud, Apple, Fastmail; we'd be reinventing # the protocol without it. -caldav -cryptography -bcrypt +caldav==3.2.1 +cryptography==49.0.0 +bcrypt==5.0.0 # Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a # breaking rewrite, so keep fresh installs on the maintained v1 line until the # servers are migrated together. -mcp<2 -pyotp -qrcode[pil] -croniter -pytest -pytest-asyncio -# starlette.testclient prefers httpx2 since Starlette 1.2.0 and warns on every -# TestClient import when only classic httpx is present. Runtime code keeps -# using `httpx` above; this is test-client only. -httpx2 +mcp==1.28.1 +pyotp==2.10.0 +qrcode[pil]==8.2 +croniter==6.2.4 +# Test/dev tooling (pytest, pytest-asyncio, httpx2) lives in requirements-dev.txt +# so it stays out of the production image — see that file. diff --git a/routes/assistant_routes.py b/routes/assistant_routes.py index 0b609e37f..8c5f93343 100644 --- a/routes/assistant_routes.py +++ b/routes/assistant_routes.py @@ -16,6 +16,7 @@ from core.database import SessionLocal, CrewMember, ScheduledTask from src.auth_helpers import get_current_user +from src import demo as _demo from core.auth import RESERVED_USERNAMES from src.task_scheduler import compute_next_run @@ -94,7 +95,7 @@ def _owner(request: Request) -> str: async def _get_or_create(owner: str) -> CrewMember: """Return the per-owner assistant CrewMember, creating it on demand.""" - if not owner or owner in RESERVED_USERNAMES: + if not owner or owner in RESERVED_USERNAMES or _demo.is_demo_owner(owner): raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}") db = SessionLocal() try: diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 5c7a4e04a..33e460fb9 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -14,7 +14,8 @@ from core.atomic_io import atomic_write_json, atomic_write_text from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR -from src.rate_limiter import RateLimiter +from src import demo as _demo +from src.rate_limiter import RateLimiter, trusted_client_ip from src.settings_scrub import scrub_settings from src.settings import ( load_settings as _load_settings, @@ -98,7 +99,7 @@ def _get_current_user(request: Request) -> Optional[str]: @router.post("/setup") async def first_run_setup(body: SetupRequest, request: Request): """Create initial admin account. Only works if no accounts exist.""" - if not _setup_limiter.check(request.client.host): + if not _setup_limiter.check(trusted_client_ip(request)): raise HTTPException(429, "Too many requests — try again later") if auth_manager.is_configured: raise HTTPException(400, "Already configured") @@ -116,7 +117,7 @@ async def first_run_setup(body: SetupRequest, request: Request): @router.post("/signup") async def signup(body: SignupRequest, request: Request): """Create a new user account. Only works if signup is enabled by admin.""" - if not _signup_limiter.check(request.client.host): + if not _signup_limiter.check(trusted_client_ip(request)): raise HTTPException(429, "Too many requests — try again later") if not auth_manager.is_configured: raise HTTPException(400, "Run setup first") @@ -135,7 +136,7 @@ async def signup(body: SignupRequest, request: Request): @router.post("/login") async def login(body: LoginRequest, request: Request, response: Response): - if not _login_limiter.check(request.client.host): + if not _login_limiter.check(trusted_client_ip(request)): raise HTTPException(429, "Too many requests — try again later") # Verify password first username = body.username.strip().lower() @@ -174,10 +175,33 @@ async def logout(request: Request, response: Response): return {"ok": True} @router.get("/status") - async def auth_status(request: Request): + async def auth_status(request: Request, response: Response): token = request.cookies.get(SESSION_COOKIE) result = auth_manager.status(token) result["signup_enabled"] = auth_manager.signup_enabled + # Demo visitors reach this route (it's auth-exempt) with no session + # token, so auth_manager.status() reports them unauthenticated. When + # DEMO mode is on and the visitor carries a well-formed demo cookie, + # surface a synthetic authenticated status + the locked-down privilege + # profile so the SPA renders the chat UI (and hides disabled controls) + # instead of gating on login. This route is auth-exempt and runs BEFORE + # the middleware demo path, so resolve the demo owner from the cookie + # directly here. When the visitor has no demo cookie yet, resolve_demo_owner + # mints one; set it on the response so an owner is stable even if the SPA + # calls /status before GET / (the middleware demo path also sets it). Not + # doing so would mint a fresh owner on every such call. + if _demo.DEMO_MODE and not result.get("authenticated"): + owner, new_cookie = _demo.resolve_demo_owner(request) + if _demo.is_demo_owner(owner): + if new_cookie: + _demo.set_demo_cookie(response, new_cookie) + result["configured"] = True + result["authenticated"] = True + result["username"] = owner + result["is_admin"] = False + result["demo"] = True + result["privileges"] = auth_manager.get_privileges(owner) + return result # Include the caller's effective privileges so the frontend can # hide / dim UI controls the user isn't allowed to use. Admins get # ADMIN_PRIVILEGES (everything on), regular users get their stored diff --git a/routes/chat_routes.py b/routes/chat_routes.py index b081d5f1c..5e1fa1311 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -43,6 +43,7 @@ ) from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent from src.image_model_ids import looks_like_image_generation_model +from src import demo as _demo from src.tool_policy import ( WEB_TOOL_NAMES, build_effective_tool_policy, @@ -881,15 +882,30 @@ async def chat_stream(request: Request) -> StreamingResponse: sess = session_manager.get_session(session) owner = effective_user(request) _reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner) - if _clear_orphaned_session_endpoint(sess, owner=owner): - raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.") - # Issue #587: picker shows a model from the endpoint cache but - # s.model never made it onto the DB row (first-send race after - # endpoint setup, or a previous endpoint delete/recreate). Pull - # the first cached model off the matching endpoint so the - # upstream isn't called with model="" (which surfaces as a - # generic 401/503). - _recover_empty_session_model(sess, session, owner=owner) + # Demo caps: check rate limit + per-session message cap BEFORE any + # token spend. A tripped cap renders as a normal assistant turn + # (friendly SSE), never a 500 or hang. + if _demo.is_demo_owner(owner): + _demo_msg = _demo.check_demo_limits(owner, _demo.demo_client_ip(request)) + if _demo_msg: + return StreamingResponse( + _demo.demo_limit_sse(_demo_msg), media_type="text/event-stream" + ) + # Demo owners have no per-owner ModelEndpoint rows — the pinned + # model/endpoint/env-key are authoritative here. Apply them up + # front and SKIP the endpoint-row orphan/recovery checks, which + # would otherwise clear the (row-less) endpoint and 400. + _demo.apply_demo_session_config(sess) + else: + if _clear_orphaned_session_endpoint(sess, owner=owner): + raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.") + # Issue #587: picker shows a model from the endpoint cache but + # s.model never made it onto the DB row (first-send race after + # endpoint setup, or a previous endpoint delete/recreate). Pull + # the first cached model off the matching endpoint so the + # upstream isn't called with model="" (which surfaces as a + # generic 401/503). + _recover_empty_session_model(sess, session, owner=owner) if not getattr(sess, "model", "").strip(): raise HTTPException( 400, @@ -971,6 +987,23 @@ async def chat_stream(request: Request) -> StreamingResponse: ) allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls + # Demo lockdown — force plain chat before build_chat_context runs, since + # it (and auto-escalation above) can attach docs / run tools BEFORE the + # per-tool privilege gate fires. Belt-and-suspenders with DEMO_PRIVILEGES: + # this kills the write/execute/escalation surfaces for the turn. + # Web search is intentionally NOT disabled here — demo visitors may use + # it; the turn's `use_web` / `allow_web_search` flags are honored as-is. + if _demo.is_demo_owner(owner): + chat_mode = "chat" + auto_escalated = False + _tool_intent = None + use_research = "false" + use_rag = "false" + do_research = False + att_ids = [] + active_doc_id = "" + preset_id = None + # Build shared context (stream path uses enhanced_message for context preface) ctx = await build_chat_context( sess, request, chat_handler, chat_processor, @@ -994,6 +1027,11 @@ async def chat_stream(request: Request) -> StreamingResponse: allow_tool_preprocessing=allow_tool_preprocessing, ) + # Demo output-token cap: clamp to the tighter of the request value and + # DEMO_MAX_OUTPUT_TOKENS (0/None would mean "no cap" downstream). + if _demo.is_demo_owner(ctx.user): + ctx.preset.max_tokens = _demo.clamp_demo_output_tokens(ctx.preset.max_tokens) + _research_flags = {"do": do_research} # Mutable container for generator scope # Query active document — prefer explicit ID from frontend, fall back to session lookup diff --git a/routes/model_routes.py b/routes/model_routes.py index 600150a66..3630c50b3 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -30,6 +30,7 @@ build_headers, ) from src.auth_helpers import _auth_disabled, effective_user, owner_filter +from src import demo as _demo logger = logging.getLogger(__name__) @@ -320,8 +321,11 @@ def _rewrite_loopback_for_docker(base_url: str, *, container_local: bool = False # A model ID matches if it starts with or equals a curated entry. _PROVIDER_CURATED = { "openai": [ - "gpt-5.2", "gpt-5.2-pro", "gpt-5", "gpt-5-pro", "gpt-5-mini", "gpt-5-nano", - "gpt-4o", "gpt-4o-mini", "o3", "o4-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", + "gpt-5.6-sol", "gpt-5.6-luna", + "gpt-5.2", "gpt-5.2-pro", "gpt-5.2-codex", + "gpt-5", "gpt-5-pro", "gpt-5-mini", "gpt-5-nano", + "gpt-4o", "gpt-4o-mini", "o3", "o4-mini", + "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-image-1.5", "gpt-image-1", "dall-e-3", "tts-1", "whisper-1", ], "anthropic": [ @@ -2419,6 +2423,16 @@ def get_default_chat(request: Request): _user = _gcu(request) or "" except Exception: _user = "" + # Demo visitors share the deployer's pinned demo config, not any + # per-user pref or DB-resolved endpoint. A demo owner (demo-) owns + # no endpoints and has no prefs, so the owner-scoped resolution below + # returns empty — leaving the composer stuck on "No chat session + # active" even though chat works. sync_session_metadata's + # apply_demo_session_config overrides every demo session to + # OPENAI_CHAT_URL + DEMO_MODEL + the env key on read anyway, so hand the + # composer that same pinned pair so it can create the session at all. + if _demo.is_demo_request(request, _user): + return {"endpoint_id": "", "endpoint_url": _demo.OPENAI_CHAT_URL, "model": _demo.DEMO_MODEL} # Admins resolve via the global defaults (they own them, and the # scoped resolution was making the picker disappear for them). # Regular users get per-user prefs with NO global fallback for the diff --git a/routes/session_routes.py b/routes/session_routes.py index dc29a64e4..59188b929 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -12,6 +12,7 @@ from src.request_models import SessionResponse from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive from src.auth_helpers import effective_user, _auth_disabled, owner_filter +from src import demo as _demo from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs from src.session_actions import is_session_recently_active from src.upload_handler import reserve_message_upload_references @@ -342,7 +343,20 @@ def create_session( user = effective_user(request) endpoint_api_key = "" endpoint_base_url = "" - _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url) + # Demo visitors own no ModelEndpoint rows, and apply_demo_session_config + # force-pins every demo session to the env key + DEMO_MODEL on each read + # (see chat_routes). Discard whatever endpoint the composer posted, pin + # the trusted demo pair here, and skip validation + the raw-URL guard: + # the client URL is inert (never dialed) and the guard would otherwise + # 403 a non-admin demo owner out of ever creating the session — the bug + # behind the "No chat session active" composer message. + if _demo.is_demo_request(request, user): + endpoint_id = "" + endpoint_url = _demo.OPENAI_CHAT_URL + model = _demo.DEMO_MODEL + skip_val = True + else: + _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url) if endpoint_id and endpoint_id.strip(): from core.database import ModelEndpoint from src.auth_helpers import owner_filter @@ -912,7 +926,7 @@ def sessions_save_now(request: Request): def create_session_openai( request: Request, name: str = Form("New Chat (OpenAI)"), - model: str = Form("gpt-4o"), + model: str = Form("gpt-5.6-sol"), rag: str = Form(None) ): if not OPENAI_API_KEY: diff --git a/searxng.Dockerfile b/searxng.Dockerfile new file mode 100644 index 000000000..97c40c7c0 --- /dev/null +++ b/searxng.Dockerfile @@ -0,0 +1,17 @@ +# SearXNG image for hosting Odysseus on Render. +# +# The stock SearXNG image does not enable the `json` output format that Odysseus +# depends on, and on Render we can't bind-mount the repo's config file the way +# docker-compose.yml does. So bake Odysseus's settings.yml into the image and +# render it (with a per-deploy secret) at boot via the entrypoint below. +# +# Pinned deliberately (not :latest): Odysseus waits on SearXNG's health, so a +# broken upstream tag would block the whole app. 2026.6.2 crashes on boot +# (KeyError: 'default_doi_resolver'). Bump only after verifying a newer tag boots. +FROM docker.io/searxng/searxng:2026.5.31-7159b8aed + +COPY config/searxng/settings.yml /usr/local/share/searxng-settings.yml.template +COPY docker/searxng-render-entrypoint.sh /usr/local/bin/searxng-render-entrypoint.sh +RUN chmod +x /usr/local/bin/searxng-render-entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/searxng-render-entrypoint.sh"] diff --git a/src/app_helpers.py b/src/app_helpers.py index 1b915a5a2..c043628dc 100644 --- a/src/app_helpers.py +++ b/src/app_helpers.py @@ -2,6 +2,7 @@ import base64 import logging import os +import re from fastapi import HTTPException from fastapi.responses import HTMLResponse @@ -9,6 +10,9 @@ logger = logging.getLogger(__name__) +# Opening , with or without attributes (``, ``). +_HEAD_OPEN_RE = re.compile(r"]*>", re.IGNORECASE) + def read_if_exists(path: str) -> str: """Read file if it exists, return empty string otherwise.""" try: @@ -37,6 +41,13 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: template surfaces in 5xx alerting instead of hiding behind a 404. If a future caller serves a client-influenced path where 404 is correct, branch that at the call site rather than defaulting this shared helper to 404. + + When the request is a demo visitor (``request.state.is_demo``), a tiny + synchronous ``window.__ODYSSEUS_DEMO=true`` flag is injected right after + ```` so the SPA's very-early fetch wrapper knows not to bounce demo + visitors to /login on the 401s from locked-down endpoints. It's set BEFORE + any module script runs, so there's no race. A template with no ```` + raises 500 rather than injecting the flag too late to work. """ try: with open(file_path, "r", encoding="utf-8") as f: @@ -46,6 +57,17 @@ def serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: raise HTTPException(500, "Internal server error") nonce = getattr(request.state, "csp_nonce", "") html = html.replace("{{CSP_NONCE}}", nonce) + if getattr(request.state, "is_demo", False): + flag = f'' + # Insert immediately after the opening so it runs first. A + # template without a would put the flag after the fetch wrapper + # it's meant to gate, so fail loudly rather than shipping a demo page + # that silently bounces visitors to /login. + match = _HEAD_OPEN_RE.search(html) + if not match: + logger.error("No in %s — cannot inject the demo flag", file_path) + raise HTTPException(500, "Internal server error") + html = html[: match.end()] + flag + html[match.end() :] return HTMLResponse(html) diff --git a/src/chat_helpers.py b/src/chat_helpers.py index a8f5f54a8..84defbfdd 100644 --- a/src/chat_helpers.py +++ b/src/chat_helpers.py @@ -42,7 +42,8 @@ def extract_urls(text: str) -> List[str]: # models (Ollama/llama.cpp) that ship under many names. See issue #124. _VISION_MODEL_KEYWORDS = ( # hosted - "gpt-4o", "gpt-4.1", "gpt-4.5", "gpt-4-turbo", "gpt-4-vision", + "gpt-5.6-sol", "gpt-5.6-luna", "gpt-4o", "gpt-4.1", "gpt-4.5", + "gpt-4-turbo", "gpt-4-vision", "claude-sonnet", "claude-opus", "claude-haiku", "gemini", # open / local "vision", "multimodal", "llava", "bakllava", "moondream", "pixtral", "minicpm", diff --git a/src/demo.py b/src/demo.py new file mode 100644 index 000000000..185dc868f --- /dev/null +++ b/src/demo.py @@ -0,0 +1,326 @@ +"""Demo mode — an opt-in, public, locked-down chat showcase. + +Off by default (``DEMO=false``) so a fresh fork gets the full authenticated +app. When ``DEMO=true``, ``AuthMiddleware`` mints a per-visitor synthetic owner +and lets an unauthenticated visitor reach ONLY the core chat surface, under a +least-privilege profile, rate-limited, with ephemeral (in-memory) history that +is never written to the deployer's disk. + +Everything demo-specific lives here so the rest of the app calls into this +module rather than scattering ``if DEMO`` branches. When the flag is off, this +module is inert: ``DEMO_MODE`` is ``False`` and none of the hooks fire. + +Security notes: + * The pinned model + endpoint + API key are applied at read time + (``sync_session_metadata``) and never persisted — the key stays env-only. + * Demo owners are ``demo-`` strings; ``is_demo_owner`` is a prefix + check. The literal ``"demo"`` remains a RESERVED_USERNAME (a different + string), so there is no collision with the account sentinel. + * The route whitelist (``is_demo_allowed``) is the middleware boundary; the + privilege profile (``DEMO_PRIVILEGES``) is the in-handler boundary. Both + must hold for a capability to be reachable. +""" + +from __future__ import annotations + +import json +import os +import re +import threading +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple + +from src.rate_limiter import RateLimiter, trusted_client_ip + + +def _flag(name: str, default: str = "false") -> bool: + """Parse a boolean env flag. true/1/yes (any case) is on; all else off.""" + return os.getenv(name, default).strip().lower() in ("true", "1", "yes") + + +def _int_env(name: str, default: int) -> int: + """Parse a non-negative int env var. Unset/invalid falls back to `default` + (a missing var must NEVER mean "unlimited"); only an explicit 0 disables a + dimension. Negative values are treated as invalid → default.""" + raw = os.getenv(name) + if raw is None or not raw.strip(): + return default + try: + val = int(raw.strip()) + except ValueError: + return default + return val if val >= 0 else default + + +# --- The flag --------------------------------------------------------------- +DEMO_MODE: bool = _flag("DEMO", "false") + +# --- Per-visitor identity --------------------------------------------------- +DEMO_COOKIE = "odysseus_demo" # separate from the authed odysseus_session cookie +DEMO_OWNER_PREFIX = "demo-" # owner ids look like demo-<32 hex uuid> +_TOKEN_RE = re.compile(r"^[0-9a-f]{32}$") + +# --- Pinned model + endpoint (env key, never persisted) --------------------- +DEMO_MODEL = (os.getenv("DEMO_MODEL", "").strip() or "gpt-5.6-luna") +OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions" + +# --- Usage limits (only consulted when DEMO_MODE). 0 disables the dimension. - +DEMO_RATE_LIMIT_PER_MINUTE = _int_env("DEMO_RATE_LIMIT_PER_MINUTE", 10) +DEMO_MAX_MESSAGES_PER_SESSION = _int_env("DEMO_MAX_MESSAGES_PER_SESSION", 30) +# IP-scoped total ceiling — the real volume backstop. The per-session cap is +# cookie-based (ephemeral history) so it's UX friction; this one is keyed on the +# trusted client IP (see demo_client_ip) and survives cookie/owner churn. +DEMO_MAX_MESSAGES_PER_IP_PER_DAY = _int_env("DEMO_MAX_MESSAGES_PER_IP_PER_DAY", 200) +DEMO_MAX_OUTPUT_TOKENS = _int_env("DEMO_MAX_OUTPUT_TOKENS", 512) +if DEMO_MAX_OUTPUT_TOKENS <= 0: + # A 0/unset output cap would mean "no cap" downstream — keep a sane floor so + # the demo can never be turned into an unbounded free generator. + DEMO_MAX_OUTPUT_TOKENS = 512 + +LIMIT_MESSAGE = ( + "**Demo limit reached — deploy your own to keep going.**\n\n" + "This is a public demo with usage caps so it stays affordable. Click " + "**Deploy to Render** in the README to run your own private instance." +) + +# --- Least-privilege profile ------------------------------------------------ +# Consumed by AuthManager.get_privileges for demo owners; this drives the +# existing per-user enforcement in routes/chat_routes.py (which disables the +# matching tools) and _enforce_chat_privileges (allowed_models). Everything +# that writes, executes, spends extra, or reaches outward is OFF. +DEMO_PRIVILEGES: Dict[str, Any] = { + "can_use_agent": False, # forces plain chat mode (no tool loop) + "can_use_browser": False, # no builtin browser + "can_use_bash": False, # no shell / python / file tools + "can_use_documents": False, # no document create/edit + "can_use_research": False, # no deep research + "can_generate_images": False, # no metered image spend + "can_manage_memory": False, # no memory/skills writes + # Per-session cap is enforced in-memory (demo history isn't persisted, so a + # DB-count daily cap would always read 0). Keep this at 0 here. + "max_messages_per_day": 0, + "allowed_models": [DEMO_MODEL], + "allowed_models_restricted": True, + "block_all_models": False, +} + +# --- Route whitelist (the middleware boundary) ------------------------------ +# The ONLY surface a demo visitor may reach. Auth-exempt routes (login, status, +# features, settings, version, /static) are handled by AuthMiddleware BEFORE the +# demo path runs, so they need not be repeated here. +_DEMO_ALLOWED_EXACT = { + ("GET", "/"), # SPA shell + ("GET", "/api/default-chat"), # supplies endpoint+model so first send can create a session + ("POST", "/api/session"), # create the chat session (endpoint/model forced server-side) + ("POST", "/api/chat_stream"), # send a message + streamed reply (capabilities locked below) +} +_DEMO_ALLOWED_PREFIXES: Tuple[Tuple[str, str], ...] = (("GET", "/static"),) + + +def is_demo_owner(username: Optional[str]) -> bool: + """True for a per-visitor demo owner id (demo-) WHEN demo mode is on. + + Gated on DEMO_MODE so a normal fork stays inert: a user who registers a + ``demo-`` username on a non-demo deploy is an ordinary user, NOT silently + locked into the demo least-privilege profile with their chat history dropped. + This is the single choke point every caller (get_privileges, session_manager, + task_scheduler, chat/auth routes) shares, so the gate can't drift between + them. Prefix check — does NOT match the literal reserved username "demo". + + This module is a leaf (stdlib + ``src.rate_limiter`` only), so every caller + imports it at module top rather than lazily — there is no cycle to dodge and + no import-failure path to fall back to: a broken ``src.demo`` fails the boot + loudly instead of silently degrading a live deploy's demo gate.""" + return DEMO_MODE and bool(username) and str(username).startswith(DEMO_OWNER_PREFIX) + + +def is_demo_request(request, owner: Optional[str]) -> bool: + """True when this request should be served as a demo request: DEMO_MODE is on + AND either the middleware flagged it (``request.state.is_demo``) or ``owner`` + is a demo owner. The single predicate the routes share so the demo gate can't + drift between call sites.""" + return DEMO_MODE and ( + getattr(request.state, "is_demo", False) or is_demo_owner(owner) + ) + + +def is_demo_allowed(method: str, path: str) -> bool: + """True if (method, path) is on the demo route whitelist.""" + if (method, path) in _DEMO_ALLOWED_EXACT: + return True + return any(method == m and path.startswith(p) for m, p in _DEMO_ALLOWED_PREFIXES) + + +# --- Per-visitor cookie / owner --------------------------------------------- +def resolve_demo_owner(request) -> Tuple[str, Optional[str]]: + """Return ``(owner, new_cookie_value)`` for a demo visitor. + + Reuses the visitor's existing demo cookie when present and well-formed; + otherwise mints a fresh unguessable id. ``new_cookie_value`` is the raw + token to set on the response (or ``None`` when the cookie already existed). + """ + tok = request.cookies.get(DEMO_COOKIE, "") + if tok and _TOKEN_RE.match(tok): + return DEMO_OWNER_PREFIX + tok, None + new = uuid.uuid4().hex + return DEMO_OWNER_PREFIX + new, new + + +def set_demo_cookie(response, token: str) -> None: + """Set the per-visitor demo cookie: httponly, samesite=lax, secure per + SECURE_COOKIES (true on Render), short-lived (history is ephemeral).""" + response.set_cookie( + key=DEMO_COOKIE, + value=token, + httponly=True, + samesite="lax", + secure=os.getenv("SECURE_COOKIES", "false").lower() == "true", + max_age=60 * 60 * 24, # 1 day; a returning visitor keeps their session cap within it + path="/", + ) + + +# --- Session config (pinned model + env key, never persisted) --------------- +def apply_demo_session_config(session) -> None: + """Force a demo session to talk to OpenAI with the pinned model and the + server's env OPENAI_API_KEY. Called from sync_session_metadata so this is + authoritative on every read — the key is never read from, or written to, the + DB. No-op-safe when the env key is missing (the LLM call then fails cleanly + as "server missing key" rather than leaking a partial config).""" + key = os.getenv("OPENAI_API_KEY") + session.endpoint_url = OPENAI_CHAT_URL + session.model = DEMO_MODEL + session.headers = {"Authorization": f"Bearer {key}"} if key else {} + + +# --- Rate + per-session message limits -------------------------------------- +# Gate on DEMO_MODE too: a normal fork imports this module (via app.py) but must +# stay inert, so don't build a limiter it will never consult. +_rate_limiter: Optional[RateLimiter] = ( + RateLimiter(max_requests=DEMO_RATE_LIMIT_PER_MINUTE, window_seconds=60) + if DEMO_MODE and DEMO_RATE_LIMIT_PER_MINUTE > 0 + else None +) + +_PURGE_AFTER = 60 * 60 * 24 # forget a counter a day after its last activity + +# owner -> [message_count, last_touch_monotonic] +_session_counts: Dict[str, List[float]] = {} +_counts_lock = threading.Lock() +_last_purge = time.monotonic() + +# trusted_client_ip -> [message_count, window_start_monotonic]. Keyed on the IP +# (not the cookie/owner) so it survives cookie clearing and owner churn — this is +# the real backstop. The count resets once a full day elapses from window start. +_ip_counts: Dict[str, List[float]] = {} +_ip_lock = threading.Lock() +_ip_last_purge = time.monotonic() + + +def _purge_stale(store: Dict[str, List[float]], last_purge: float, now: float) -> float: + """Drop counters idle longer than _PURGE_AFTER so ``store`` can't grow without + bound. Returns the new last-purge timestamp (unchanged until it's time to + purge again). Call under the store's lock.""" + if now - last_purge < _PURGE_AFTER: + return last_purge + stale = [k for k, v in store.items() if now - v[1] > _PURGE_AFTER] + for k in stale: + del store[k] + return now + + +def check_demo_limits(owner: str, client_ip: str) -> Optional[str]: + """Return a friendly limit message if the visitor is over a cap, else None. + + Call once per chat send, BEFORE spending the key. Enforces, in order: + (a) a sliding per-minute rate limit keyed on the trusted client IP, + (b) a per-session (cookie-scoped) message cap — UX friction, not a guard, + (c) an IP-scoped daily message ceiling (the real volume backstop). + A tripped cap returns text, never an exception, so the caller can render it + as a normal assistant turn instead of a 500/hang. + + The per-session cap is checked (and consumed) BEFORE the IP counter is + touched, so a visitor already over their session cap returns without + spending a unit of the IP-scoped daily budget — the real cost backstop. + (The reverse over-count — an IP-capped visitor advancing the session + counter — is harmless: that counter is cookie-scoped UX friction, and the + visitor is blocked by the IP ceiling regardless.) + + The rate limit and daily ceiling key on ``client_ip`` alone — the only + visitor-stable signal for an unauthenticated demo request. ``owner`` is + minted fresh for any client that ignores the demo cookie, so keying either + on it would let a cookieless client reset the window on every request. + Sharing a bucket across visitors behind one NAT errs toward more limiting — + correct for a cost guard. + """ + global _last_purge, _ip_last_purge + if _rate_limiter is not None: + if not _rate_limiter.check(client_ip): + return LIMIT_MESSAGE + now = time.monotonic() + if DEMO_MAX_MESSAGES_PER_SESSION > 0: + with _counts_lock: + _last_purge = _purge_stale(_session_counts, _last_purge, now) + entry = _session_counts.get(owner) + used = entry[0] if entry else 0 + if used >= DEMO_MAX_MESSAGES_PER_SESSION: + return LIMIT_MESSAGE + _session_counts[owner] = [used + 1, now] + if DEMO_MAX_MESSAGES_PER_IP_PER_DAY > 0 and client_ip: + with _ip_lock: + _ip_last_purge = _purge_stale(_ip_counts, _ip_last_purge, now) + entry = _ip_counts.get(client_ip) + if entry and now - entry[1] < _PURGE_AFTER: + used, start = int(entry[0]), entry[1] + else: + used, start = 0, now # first hit, or the day-long window expired + if used >= DEMO_MAX_MESSAGES_PER_IP_PER_DAY: + return LIMIT_MESSAGE + _ip_counts[client_ip] = [used + 1, start] + return None + + +def demo_client_ip(request) -> str: + """Trusted client IP for the demo rate/volume caps. + + Delegates to the shared ``trusted_client_ip`` so the demo caps and the + auth-route limiters agree on which ``X-Forwarded-For`` entry to trust + (governed by ``TRUSTED_PROXY_HOPS``) — see src/rate_limiter.py. + """ + return trusted_client_ip(request) + + +async def demo_limit_sse(message: str): + """SSE generator that renders `message` as a single assistant turn and ends. + Matches the chat_stream framing the frontend consumes (data: {delta} … + data: [DONE]) so a tripped limit shows as a normal reply, not a broken + stream.""" + yield f'data: {json.dumps({"delta": message})}\n\n' + yield "data: [DONE]\n\n" + + +def clamp_demo_output_tokens(current: Optional[int]) -> int: + """Return the max_tokens to use for a demo turn: the tighter of the + request's value and DEMO_MAX_OUTPUT_TOKENS. Treats 0/None (which mean + "no cap" downstream) as needing the demo cap applied.""" + if not current or current > DEMO_MAX_OUTPUT_TOKENS: + return DEMO_MAX_OUTPUT_TOKENS + return current + + +def log_startup_mode(logger) -> None: + """Log which mode booted so a misconfigured deploy is obvious in the logs.""" + if DEMO_MODE: + logger.warning( + "[startup] DEMO mode ENABLED — public, no-signup, locked-down chat demo is live " + "and spends OPENAI_API_KEY. model=%s rate=%s/min msgs/session=%s " + "msgs/ip/day=%s max_output_tokens=%s", + DEMO_MODEL, + DEMO_RATE_LIMIT_PER_MINUTE or "unlimited", + DEMO_MAX_MESSAGES_PER_SESSION or "unlimited", + DEMO_MAX_MESSAGES_PER_IP_PER_DAY or "unlimited", + DEMO_MAX_OUTPUT_TOKENS, + ) + else: + logger.info("[startup] normal (authenticated) mode — DEMO is off") diff --git a/src/document_processor.py b/src/document_processor.py index 8025e22e0..dc2d871ab 100644 --- a/src/document_processor.py +++ b/src/document_processor.py @@ -316,7 +316,8 @@ def _resolve_vl_model(configured: str, owner: str | None = None) -> tuple: # Auto-detect: try known vision-capable models in priority order candidates = [ - "gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", + "gpt-5.6-sol", "gpt-5.6-luna", "gpt-4o", "gpt-4o-mini", + "gpt-4.1", "gpt-4.1-mini", "claude-sonnet-4-5-20250929", "claude-opus-4-20250514", "gemini-2.0-flash", "gemini-2.5-pro", "llava", "pixtral", "qwen2-vl", diff --git a/src/model_context.py b/src/model_context.py index b6afa801e..684309d4b 100644 --- a/src/model_context.py +++ b/src/model_context.py @@ -128,6 +128,8 @@ def is_local_endpoint(url: str) -> bool: 'gpt-4.1': 1047576, 'gpt-4.1-mini': 1047576, 'gpt-4.1-nano': 1047576, + 'gpt-5.6-sol': 1047576, + 'gpt-5.6-luna': 1047576, 'gpt-4o': 128000, 'gpt-4o-mini': 128000, 'gpt-4-turbo': 128000, diff --git a/src/model_discovery.py b/src/model_discovery.py index 4d67502c5..c2581ede0 100644 --- a/src/model_discovery.py +++ b/src/model_discovery.py @@ -268,12 +268,14 @@ def get_providers(self) -> Dict[str, Any]: if self.openai_api_key: openai_models = [ + "gpt-5.6-sol", + "gpt-5.6-luna", + "gpt-5.2", + "gpt-5.2-pro", "gpt-5.2-codex", + "gpt-4o", "gpt-4o-mini", "gpt-image-1.5", - "gpt-4o", - "gpt-5.2", - "gpt-5.2-pro", ] providers.append( { diff --git a/src/rate_limiter.py b/src/rate_limiter.py index 7ffd09259..4e0fe53ec 100644 --- a/src/rate_limiter.py +++ b/src/rate_limiter.py @@ -1,10 +1,98 @@ # src/rate_limiter.py -"""Generic in-memory rate limiter — sliding window, keyed by IP.""" +"""Generic in-memory rate limiter — sliding window, keyed by IP. +Also owns ``trusted_client_ip``: the single, spoof-resistant way to derive the +client IP that every IP-keyed rate limiter in the app should share (demo caps and +the auth-route limiters alike). Keeping one helper + one env var here avoids two +limiters disagreeing about which ``X-Forwarded-For`` entry to trust. +""" + +import logging +import os import threading import time from typing import Dict, List +logger = logging.getLogger(__name__) + + +def _trusted_proxy_hops() -> int: + """Number of trusted proxy hops in front of the app (see trusted_client_ip). + + Read per-call from ``TRUSTED_PROXY_HOPS`` (default 1, matching Render's single + edge proxy) so tests and redeploys can retune it without a module reload. + This mirrors the conventional trusted-hop count (Werkzeug ``ProxyFix``, + uvicorn ``--forwarded-allow-ips``): ``n`` = ``n`` trusted proxies, and an + explicit ``0`` = "no trusted proxy — the deploy is directly exposed, so + ``X-Forwarded-For`` is entirely attacker-supplied and must be ignored in + favour of the real TCP peer". Unset/invalid/negative falls back to 1 (the + Render default). Only a deliberate ``0`` disables XFF parsing. + """ + raw = os.getenv("TRUSTED_PROXY_HOPS", "").strip() + if not raw: + return 1 + try: + val = int(raw) + except ValueError: + return 1 + return val if val >= 0 else 1 + + +_logged_xff_sample = False +_xff_log_lock = threading.Lock() + + +def trusted_client_ip(request) -> str: + """Return the spoof-resistant client IP for rate limiting behind Render. + + ``X-Forwarded-For`` is an ordered list; Render's edge proxy appends the real + peer IP to the RIGHT, so the trustworthy client IP is ``TRUSTED_PROXY_HOPS`` + entries from the right. The leftmost entry is client-supplied and spoofable, + so we must NOT read it. Falls back to the immediate peer (``request.client``) + when the header is absent or shorter than the configured hop count. + + When ``TRUSTED_PROXY_HOPS`` is ``0`` (directly-exposed deploy with no trusted + proxy), ``X-Forwarded-For`` is wholly attacker-supplied and is ignored: the + key is always the real TCP peer. Forks deploying this template off Render must + set ``0`` if the service is internet-facing with no proxy — leaving the + default ``1`` there makes the rate limiter spoofable. + + NOTE: this assumes uvicorn runs WITHOUT ``--proxy-headers`` (see + docker/entrypoint.render.sh), so ``request.client.host`` is the Render proxy + and only the XFF right-side entry identifies the client. To confirm the hop + count on a real deploy, this logs the raw header + resolved IP exactly ONCE at + startup (grep the logs for ``[trusted-ip] X-Forwarded-For sample``); adjust + ``TRUSTED_PROXY_HOPS`` if the resolved IP isn't the true client. + """ + headers = getattr(request, "headers", None) + xff = headers.get("x-forwarded-for", "") if headers else "" + hops = _trusted_proxy_hops() + resolved = "" + if xff and hops > 0: + parts = [p.strip() for p in xff.split(",") if p.strip()] + if len(parts) >= hops: + resolved = parts[-hops] + if not resolved: + resolved = request.client.host if getattr(request, "client", None) else "" + + # One-shot observability so the hop-count assumption can be verified against + # real Render traffic without a redeploy. Only fires when an XFF is present. + global _logged_xff_sample + if xff and not _logged_xff_sample: + with _xff_log_lock: + if not _logged_xff_sample: + _logged_xff_sample = True + logger.info( + "[trusted-ip] X-Forwarded-For sample=%r hops=%s -> resolved=%r " + "(peer=%s). If resolved is not the true client IP, retune " + "TRUSTED_PROXY_HOPS.", + xff, + hops, + resolved, + request.client.host if getattr(request, "client", None) else "", + ) + return resolved + class RateLimiter: """Sliding-window rate limiter. diff --git a/src/task_scheduler.py b/src/task_scheduler.py index d5b1dad62..9ff3fe449 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -10,6 +10,7 @@ from typing import Any, Awaitable, Callable, Dict, Tuple from core.auth import RESERVED_USERNAMES +from src import demo as _demo from src.task_action_policy import ( is_admin_only_task_action, owner_has_admin_task_privileges, @@ -2484,7 +2485,7 @@ async def ensure_assistant_defaults(self, owner: str): # check-ins seeded, which then double-fire alongside the human user's # check-ins. This was the root cause of the duplicate 'Morning check-in' # rows we had to manually clean up. - if not owner or owner in RESERVED_USERNAMES: + if not owner or owner in RESERVED_USERNAMES or _demo.is_demo_owner(owner): logger.info(f"ensure_assistant_defaults: skip synthetic owner {owner!r}") return from core.database import SessionLocal, CrewMember, ScheduledTask diff --git a/static/app.js b/static/app.js index 2f1e8d4bf..76ce5a5df 100644 --- a/static/app.js +++ b/static/app.js @@ -185,11 +185,16 @@ function initRailHoverLabels() { }); } -// Redirect to login on 401 from any fetch +// Redirect to login on 401 from any fetch — EXCEPT in demo mode. A demo +// visitor is intentionally unauthenticated and only the chat endpoints are +// whitelisted server-side; every other endpoint 401s by design. Bouncing them +// to /login would make the public demo unusable, so in demo mode we let those +// 401s fall through and the corresponding panels simply stay empty. The flag is +// injected synchronously into before this runs (see serve_html_with_nonce). const _origFetch = window.fetch; window.fetch = async function(...args) { const res = await _origFetch.apply(this, args); - if (res.status === 401 && !String(args[0]).includes('/api/auth/')) { + if (res.status === 401 && !String(args[0]).includes('/api/auth/') && !window.__ODYSSEUS_DEMO) { window.location.href = '/login'; } return res; diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 000000000..b7abd96e9 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/index.html b/static/index.html index fea4e20ac..20ca892ae 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ Odysseus Chat - + diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 1d6e2e4a9..f79ba551a 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -507,6 +507,8 @@ const MODEL_INFO = { 'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 }, 'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 }, 'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 }, + 'gpt-5.6-sol': { input: 5.00, output: 30.00, ctx: 1047576 }, + 'gpt-5.6-luna': { input: 1.00, output: 6.00, ctx: 1047576 }, 'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 }, 'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 }, 'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 }, diff --git a/static/js/init.js b/static/js/init.js index 54239c52f..06e1a11e5 100644 --- a/static/js/init.js +++ b/static/js/init.js @@ -86,6 +86,19 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr if (_agent) _agent.style.display = 'none'; if (_chat) { _chat.classList.add('active'); _chat.click?.(); } } + // Demo mode: only the chat surface is reachable server-side (every other + // feature 401s by design). Hide every icon-rail control EXCEPT the + // chat-only keep-set so the public demo shows a clean chat UI instead of + // buttons that silently fail. Allowlist (not denylist) so a feature rail + // added later is hidden by default rather than dangling a 401ing button. + if (data && data.demo) { + const _demoKeep = new Set([ + 'rail-chats', 'rail-new-session', 'rail-theme', 'rail-resize-handle', + ]); + document.querySelectorAll('#icon-rail [id^="rail-"]').forEach(el => { + if (!_demoKeep.has(el.id)) el.style.display = 'none'; + }); + } } catch (_) { /* DOM not ready or unexpected shape — UI gates are non-fatal */ } } catch (_) { /* anonymous / loopback mode — nothing to do */ } })(); diff --git a/static/login.html b/static/login.html index eeece7cc3..de91af8c4 100644 --- a/static/login.html +++ b/static/login.html @@ -4,7 +4,7 @@ Odysseus — Login - +