diff --git a/.dockerignore b/.dockerignore
index 271d27a7a..eca6c8fe8 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -15,6 +15,10 @@ build/
# at runtime — never baked into the image. Mirrored in .gitignore.
secrets.env
secrets.env.*
+secrets.env~
+.secrets.env.swp
+.secrets.env.swo
+**/#secrets.env#
!secrets.env.example
/data/
/logs/
diff --git a/.env.example b/.env.example
index 5382c23c7..0f4dcd449 100644
--- a/.env.example
+++ b/.env.example
@@ -190,3 +190,10 @@ SEARXNG_INSTANCE=http://localhost:8080
# These overlays only expose the GPU devices. The slim Odysseus image
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
# llama-cpp-python, etc.) before models can actually serve on GPU.
+
+# ============================================================
+# Storage Paths (Docker Compose)
+# ============================================================
+
+# APP_DATA_DIR=./data
+# APP_LOGS_DIR=./logs
diff --git a/.github/pull_request_review_template.md b/.github/pull_request_review_template.md
new file mode 100644
index 000000000..725138545
--- /dev/null
+++ b/.github/pull_request_review_template.md
@@ -0,0 +1,123 @@
+# Pull Request Review Template
+
+Use this shape as a copyable reference for substantive PR reviews; GitHub does
+not auto-apply this file to review comments. Omit sections that do not add
+useful signal. Lead with confirmed findings; keep speculative notes out of the
+public review unless they are framed as a concrete open question.
+
+## Small PR Path
+
+For narrow docs, typo, test-only, or obvious local fixes, a short review is
+enough:
+
+```md
+LGTM after checking:
+- scope:
+- validation:
+- residual risk:
+```
+
+Use the fuller structure below for larger, risky, multi-finding, or
+security-sensitive reviews.
+
+## Findings
+
+** issue (test): Short issue title**
+
+- **Problem:** Concrete broken flow, contract, input, or risk.
+
+- **Impact:** Why this matters to users, CI, maintainers, data, security, or scale.
+
+- **Ask:** Smallest practical correction or decision the author should make.
+
+- **Location:** `path:line`
+
+## Open Questions
+
+- **question (scope, non-blocking): Short author question** Ask the concrete
+ intent, scope, or tradeoff question.
+
+## Validation
+
+- Ran:
+- Not run:
+- Residual risk:
+
+## PR Hygiene
+
+- Target/template/checks:
+- Related, duplicate, or superseding context:
+
+## No Findings Variant
+
+```md
+## Findings
+
+none confirmed
+
+## Validation
+
+- Ran:
+- Not run:
+- Residual risk:
+```
+
+## Legend
+
+- **Findings:** Verified, author-actionable issues that should be fixed or
+ consciously accepted before merge.
+- **Priority badges:** The shields.io badges below are optional formatting for
+ priority labels. Plain `P0`, `P1`, `P2`, or `P3` text is also acceptable when
+ an external image dependency is undesirable or may not render.
+ - **P0:** `` -
+ release-blocking or actively dangerous.
+ - **P1:** `` -
+ serious bug, security risk, data-loss risk, or broken primary flow.
+ - **P2:** `` -
+ meaningful correctness, test, maintainability, or edge-case issue.
+ - **P3:** `` -
+ minor polish or low-risk cleanup.
+- **Intent labels:**
+ - **`issue`:** A confirmed defect, regression, broken contract, or concrete
+ risk.
+ - **`suggestion`:** A non-blocking improvement that would make the PR clearer,
+ safer, or easier to maintain.
+ - **`nit`:** A tiny, non-blocking cleanup or style note. Use it only when the
+ author can safely ignore it without changing the review outcome.
+ - **`question`:** A real author-facing clarification about intent, scope, or
+ tradeoffs. Do not use questions to hide an issue that should be stated
+ directly.
+ - **`LGTM`:** "Looks good to me." Use only when the review found no blocking
+ issues, or when any remaining notes are clearly optional.
+- **Decorations:** Optional labels in parentheses that clarify the finding type,
+ scope, or merge impact.
+ - **`security`:** Auth, authorization, ownership, secrets, SSRF, injection,
+ unsafe external input, or other trust-boundary concerns.
+ - **`test`:** Missing, failing, misleading, brittle, or insufficient tests.
+ - **`scope`:** PR scope, feature boundaries, unrelated churn, or work that
+ should be split into a separate issue or PR.
+ - **`ci`:** CI configuration, workflow failures, flaky checks, or validation
+ signal quality.
+ - **`api`:** Route, request/response, public function, schema, persistence, or
+ integration contract changes.
+ - **`docs`:** User-facing docs, contributor docs, examples, or comments that
+ need to change with the code.
+ - **`non-blocking`:** Useful feedback that should not prevent merge by
+ itself.
+- **Finding fields:**
+ - **Problem:** What is wrong, what contract is ambiguous, or what risk the PR
+ introduces.
+ - **Impact:** Why the problem matters in practical terms.
+ - **Ask:** The smallest concrete fix, test, or decision requested from the PR
+ author.
+ - **Location:** The most useful repo-relative file and line reference for the
+ finding, using `path:line`.
+- **Optional sections:**
+ - **Open Questions:** Genuine scope or intent questions; omit when there are
+ no real questions.
+ - **Validation:** What the reviewer ran, what was intentionally not run, and
+ what risk remains after review.
+ - **PR Hygiene:** Target-branch, template, CI/check, duplicate, related-work,
+ or superseding-PR notes.
+- **`none confirmed`:** Use only when no review-worthy findings were confirmed;
+ still list validation gaps or residual risk when relevant.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 818495d14..787bd9dea 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,10 +19,10 @@ jobs:
name: Python syntax (compileall)
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps.
@@ -32,10 +32,10 @@ jobs:
name: JS syntax (node --check)
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
# Syntax-check our own JS (skip vendored libs in static/lib).
@@ -54,7 +54,7 @@ jobs:
# ROADMAP "fresh install smoke tests" item; make this required once green.
continue-on-error: true
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -81,7 +81,7 @@ jobs:
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi
- - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
python-version: "3.11"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index a53835a05..000000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-# CodeQL code scanning
-#
-# Purpose: GitHub's own static analysis engine reads the application source
-# (Python backend + the JavaScript frontend) and looks for real
-# vulnerabilities -- SQL/command injection, path traversal, auth mistakes,
-# unsafe deserialization. Findings appear in the repo's Security tab. This is
-# the deepest check in the suite and the most valuable for a high-profile
-# target.
-#
-# It runs on every push to main and on a weekly schedule (to catch newly
-# disclosed query patterns against unchanged code). It deliberately does NOT
-# run on pull requests: most PRs here come from forks, whose read-only token
-# cannot publish results, which would produce confusing failures. To scan pull
-# requests too, a maintainer can instead enable CodeQL "default setup" in
-# Settings -> Security -> Code scanning (one toggle, no file needed) -- see
-# docs/security-ci.md.
-
-name: CodeQL
-
-on:
- push:
- branches: [main]
- schedule:
- # Weekly, Monday 06:00 UTC.
- - cron: '0 6 * * 1'
- workflow_dispatch:
-
-permissions: {}
-
-concurrency:
- group: codeql-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- analyze:
- name: Analyze (${{ matrix.language }})
- runs-on: ubuntu-latest
- permissions:
- contents: read
- security-events: write # publish results to the Security tab
- strategy:
- fail-fast: false
- matrix:
- # Both are interpreted, so CodeQL needs no build step (build-mode none).
- language: [python, javascript-typescript]
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- persist-credentials: false
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
- with:
- languages: ${{ matrix.language }}
- build-mode: none
-
- - name: Perform CodeQL analysis
- uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
- with:
- category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml
index 71c4121a4..f1c4b5bfd 100644
--- a/.github/workflows/container-scan.yml
+++ b/.github/workflows/container-scan.yml
@@ -37,7 +37,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
diff --git a/.github/workflows/container-trivy.yml b/.github/workflows/container-trivy.yml
index 025fefc16..2a482f067 100644
--- a/.github/workflows/container-trivy.yml
+++ b/.github/workflows/container-trivy.yml
@@ -52,7 +52,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
security-events: write # upload SARIF to the Security tab
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -119,7 +119,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results
- uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
+ uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-image
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 85dc26ec6..0a587de19 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -36,7 +36,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -55,7 +55,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 5e822ab07..d52c0c4e8 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -45,7 +45,7 @@ jobs:
arch: arm64
runner: ubuntu-24.04-arm
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Buildx
@@ -86,7 +86,7 @@ jobs:
contents: read
packages: write
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Read APP_VERSION + short sha
diff --git a/.github/workflows/issue-description-check.yml b/.github/workflows/issue-description-check.yml
index 3d0cf094e..52e9dddae 100644
--- a/.github/workflows/issue-description-check.yml
+++ b/.github/workflows/issue-description-check.yml
@@ -14,7 +14,7 @@ jobs:
# Skip bots (Dependabot, release-drafter, etc.)
if: ${{ github.event.issue.user.type != 'Bot' }}
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
sparse-checkout: .github/scripts
persist-credentials: false
diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml
index c8fbe4b0f..53f0b5f50 100644
--- a/.github/workflows/pr-description-check.yml
+++ b/.github/workflows/pr-description-check.yml
@@ -23,7 +23,7 @@ jobs:
# Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot'
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.base_ref }}
sparse-checkout: .github/scripts
diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml
index 55825bedf..02512204a 100644
--- a/.github/workflows/secret-scan.yml
+++ b/.github/workflows/secret-scan.yml
@@ -35,7 +35,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Full history so a secret committed in an earlier commit (and later
# deleted) is still caught -- deletion does not remove it from Git.
diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml
index efe487319..ee345333b 100644
--- a/.github/workflows/workflow-security.yml
+++ b/.github/workflows/workflow-security.yml
@@ -36,7 +36,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md
index fdf55c48a..94092c6ca 100644
--- a/ACKNOWLEDGMENTS.md
+++ b/ACKNOWLEDGMENTS.md
@@ -86,6 +86,7 @@ Bundled in `static/fonts/`:
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
+| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
## Python dependencies
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 174a4f2f6..efb38ed24 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -37,7 +37,7 @@ Manual development uses Python 3.11+:
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
-python -m uvicorn app:app --host 0.0.0.0 --port 7000
+python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
diff --git a/Dockerfile b/Dockerfile
index ad273cec4..221d462d1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,15 @@
-FROM python:3.12-slim
+# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
+# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
+# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
+# the final image / Cookbook never has to compile the broken sdists. See
+# docker/build-realesrgan-wheels.sh for the full rationale.
+FROM python:3.14-slim AS realesrgan-wheels
+RUN apt-get update && apt-get install -y --no-install-recommends curl \
+ && rm -rf /var/lib/apt/lists/*
+COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
+RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
+
+FROM python:3.14-slim
# System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes,
@@ -18,8 +29,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
tmux \
openssh-client \
gosu \
+ libgl1 \
+ libglib2.0-0t64 \
+ libxcb1 \
&& rm -rf /var/lib/apt/lists/*
+# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
+# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
+# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
+# and dies with `libxcb.so.1: cannot open shared object file` despite a clean
+# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/
+# facexlib/realesrgan all depend on the `opencv-python` distribution by name.
+
+# Docker CLI (client only — daemon stays on the host via the
+# /var/run/docker.sock mount). The Debian `docker.io` package ships
+# dockerd but not the client binary on slim, so grab the static client
+# tarball from download.docker.com instead.
+ARG DOCKER_CLI_VERSION=27.5.1
+RUN ARCH="$(dpkg --print-architecture)" \
+ && case "$ARCH" in \
+ amd64) DARCH=x86_64 ;; \
+ arm64) DARCH=aarch64 ;; \
+ *) echo "unsupported arch $ARCH"; exit 1 ;; \
+ esac \
+ && curl -fsSL "https://download.docker.com/linux/static/stable/${DARCH}/docker-${DOCKER_CLI_VERSION}.tgz" \
+ -o /tmp/docker.tgz \
+ && tar -xzf /tmp/docker.tgz -C /tmp \
+ && install -m 0755 /tmp/docker/docker /usr/local/bin/docker \
+ && rm -rf /tmp/docker /tmp/docker.tgz
+
WORKDIR /app
# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
@@ -29,6 +67,15 @@ COPY requirements.txt requirements-optional.txt ./
RUN pip install --no-cache-dir -r requirements.txt \
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
+# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
+# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
+# pulled only when realesrgan is actually installed). With these dists already
+# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
+# wheels instead of rebuilding the sdists that fail on Python 3.14.
+COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
+RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
+ && rm -rf /tmp/odysseus-wheels
+
# Copy app code
COPY . .
diff --git a/Odysseus.spec b/Odysseus.spec
new file mode 100644
index 000000000..547460c69
--- /dev/null
+++ b/Odysseus.spec
@@ -0,0 +1,45 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+
+a = Analysis(
+ ['launcher.py'],
+ pathex=[],
+ binaries=[],
+ datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')],
+ hiddenimports=[],
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+ optimize=0,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name='Odysseus',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ console=False,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ icon=['static\\icon.ico'],
+)
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ name='Odysseus',
+)
diff --git a/README.md b/README.md
index 366e92c89..063efed3c 100644
--- a/README.md
+++ b/README.md
@@ -1,471 +1,65 @@
-# Odysseus
+
+
+
-> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main).
+
+ A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
+
-```
-───────────────────────────────────────────────
- ⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0
-───────────────────────────────────────────────
-```
+
+ Quick Start ·
+ Setup Guide ·
+ Contributing ·
+ Roadmap
+
-
+
+
+
-A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
+
+
+
-[](https://repology.org/project/odysseus-ai/versions)
-
-## Features
- - **Chat** -- chat with any local model or API; adding them is super simple.
vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot
- - **Agent** -- hand it tools and let it run the whole task itself.
built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory
- - **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!
built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving
- - **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.
adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)
- - **Compare** -- a fun tool to compare models side by side. Test completely blind, no bias!
multi-model · blind test · synthesis
- - **Documents** -- YOU write the text, AI is there to assist, not the opposite.
multi-tab editor · markdown · HTML · CSV · syntax highlighting · AI edits · suggestions
- - **Memory / Skills** -- Persistent memory and skills, your agent evolves over time as it better understands you and your tasks!
ChromaDB · fastembed (ONNX) · vector + keyword retrieval · import/export
- - **Email** -- IMAP/SMTP inbox with AI triage built in: urgency reminders, auto-tag, auto-summary, auto-reply drafts, auto-spam.
IMAP · SMTP · per-account routing · CalDAV-aware
- - **Notes & Tasks** -- Quick notes with reminders, a todo list, and scheduled tasks the agent can act on.
note pings · checklist · cron-style tasks · ntfy / browser / email channels
- - **Calendar** -- Local-first calendar with CalDAV sync to Radicale / Nextcloud / Apple / Fastmail.
CalDAV pull · .ics import/export · per-calendar colors · agent-aware
- - **Works on mobile** -- looks and runs great on your phone, not just desktop.
responsive · installable (PWA) · touch gestures
- - **Extras** -- more to explore, happy if you give it a go!
image editor · theme editor · file uploads (vision + PDF) · web search · presets · sessions · 2FA
-
-## Demo
-A full, hover-to-play tour lives on the landing page (`docs/index.html`).
-
-
-Screenshots / clips
-
-### Chat & Agents
-
-### Deep Research
-
-### Compare
-
-### Documents
-
-### Notes & Tasks
-
-
-
+---
## Quick Start
-Defaults work out of the box: clone, run, then configure models/search/email
-inside **Settings**. Only edit `.env` for deployment-level overrides like
-`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
+> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main) if you want the more curated branch.
-On first setup, Odysseus creates an admin account (`admin` unless
-`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
-For Docker installs, the same line is in `docker compose logs odysseus`.
-Use that for the first login, then change it in **Settings**.
-
-Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
-pull request guidelines.
-
-### Docker (recommended)
```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
cd odysseus
-cp .env.example .env # optional, but recommended for explicit defaults
+cp .env.example .env
docker compose up -d --build
```
-To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
-Open `http://localhost:7000` when the containers are healthy. Docker Compose
-binds the web UI to `127.0.0.1` by default. If the port is taken, set
-`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
-only when you intentionally want LAN/reverse-proxy access.
+Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
-> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
-> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
-> run natively instead — see [Apple Silicon](#apple-silicon) below.
+Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
-### Native Linux / macOS
-```bash
-git clone https://github.com/pewdiepie-archdaemon/odysseus.git
-cd odysseus
-python3 -m venv venv
-source venv/bin/activate
-pip install -r requirements.txt
-python setup.py
-python -m uvicorn app:app --host 127.0.0.1 --port 7000
-```
-Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
-downloads and serves. The app itself is lightweight; local model serving is the
-heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
-connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
+## Features
-### Apple Silicon
-Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
-M-series Mac, run Odysseus natively:
+- **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.
-```bash
-git clone https://github.com/pewdiepie-archdaemon/odysseus.git
-cd odysseus
-./start-macos.sh
-```
+## Demo
-It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
-
-```bash
-ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
-# then open http://:7860
-```
-
-The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
-set there are picked up automatically without a command-line override each run.
-
-Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
-expose this port directly to the public internet. To build a clickable app wrapper:
-
-```bash
-./build-macos-app.sh
-```
-
-
-Cookbook, GPU, Ollama, and troubleshooting notes
-
-**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
-ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
-they are reachable from the host but not exposed to your LAN/public internet
-unless you opt in.
-
-**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
-(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
-serve engines live in `./data/local` (`~/.local` in the container), so they
-survive container recreation.
-
-**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
-Odysseus SSH key and add the public key to the remote server's
-`~/.ssh/authorized_keys`. From the host you can also run:
-
-```bash
-ssh-copy-id -i data/ssh/id_ed25519.pub user@server
-```
-
-**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
-only detect GPUs that Docker exposes to the container — if the host runtime or
-device passthrough is not configured, Cookbook sees the iGPU, another card, or
-CPU instead of your intended GPU.
-
-For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
-optionally install the host runtime or update `.env`.
-
-```bash
-# Read-only diagnostic (default — installs nothing, never edits .env):
-scripts/check-docker-gpu.sh
-
-# Print OS-specific install commands without running them:
-scripts/check-docker-gpu.sh --print-install-commands
-
-# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
-scripts/check-docker-gpu.sh --install-nvidia-toolkit
-
-# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
-scripts/check-docker-gpu.sh --enable-nvidia-overlay
-
-# Full assisted setup — install toolkit, then enable overlay if passthrough works:
-scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
-```
-
-Safety notes:
-- The app never installs host GPU runtime automatically.
-- The app never edits `.env` automatically.
-- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
- and only after GPU passthrough succeeds. `--yes` skips prompts but does not
- bypass the passthrough gate.
-- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
- Git and the Docker build context.
-
-To enable manually without the script, add this to `.env`:
-
-```bash
-COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
-```
-
-**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
-
-```bash
-scripts/check-docker-amd-gpu.sh
-```
-
-Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
-numeric render group id:
-
-```bash
-COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
-RENDER_GID=989
-```
-
-For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
-
-**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
-often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
-or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
-overlay workflow above. For stack UIs, point the stack at one of the standalone
-files instead, which bundle the base stack plus the GPU settings:
-
-- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
- on the host.
-- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
- `video`/`render` group membership, and `RENDER_GID` when needed.
-
-The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
-source of truth; the standalone files mirror them for single-file deployments.
-
-Verify after enabling either overlay:
-
-```bash
-docker compose exec odysseus nvidia-smi -L # NVIDIA
-docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
-```
-
-> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
-> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
-> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
-> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
-> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
-> not a Docker passthrough failure. Reinstall the serve engine via
-> **Cookbook → Dependencies** to get a CUDA-enabled build.
->
-> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
-> the container confirms device passthrough, not ROCm userspace or a
-> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
-> inside the slim Odysseus image.
-
-**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
-Settings:
-
-```text
-http://host.docker.internal:11434/v1
-```
-
-Ollama must listen outside its own loopback interface:
-
-```bash
-OLLAMA_HOST=0.0.0.0:11434 ollama serve
-```
-
-This connects Odysseus in Docker to an Ollama server that is already running on
-your host machine; it does not start Ollama inside the container.
-`host.docker.internal` is Docker's hostname for the host machine from inside the
-container. Cookbook **Serve** is a separate workflow for serving downloaded
-models through Odysseus/llama.cpp, so Windows users with an existing Ollama
-install usually only need to add the endpoint in Settings.
-
-**Useful checks.**
-
-```bash
-docker compose ps
-docker compose logs --tail=120 odysseus
-docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
-```
-
-**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
-runs setup, and starts uvicorn on port `7860` because AirPlay often holds
-`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
-do not run on macOS. MLX-only models are not served by Odysseus.
-
-
-
-### Native Windows
-
-**One-command launcher** (creates the venv, installs deps, runs setup, starts the
-server; safe to re-run):
-
-```powershell
-git clone https://github.com/pewdiepie-archdaemon/odysseus.git
-cd odysseus
-powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
-```
-
-Or do it by hand:
-
-```powershell
-git clone https://github.com/pewdiepie-archdaemon/odysseus.git
-cd odysseus
-py -3.11 -m venv venv
-venv\Scripts\Activate.ps1
-pip install -r requirements.txt
-python setup.py
-python -m uvicorn app:app --host 127.0.0.1 --port 7000
-```
-
-If `python` points at an older interpreter, use `py -3.12` (or another installed
-3.11+ version) for the venv step.
-
-**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
-email, calendar, deep research) runs fully native. For full **Cookbook** background
-model downloads and the agent shell tool, also install
-[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
-Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
-[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
-`http://localhost:11434/v1` in Settings.
-
-Open `http://localhost:7000`, log in with the generated admin password,
-and configure everything else inside **Settings**.
-
-## Troubleshooting & Advanced Setup
-
-### `chromadb-client` conflicts with embedded ChromaDB
-If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
-
-**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
-```bash
-./venv/bin/pip uninstall chromadb-client -y
-./venv/bin/pip install --force-reinstall chromadb
-```
-
-### HTTPS + LAN/Tailscale exposure
-To expose Odysseus on a local network or Tailscale with HTTPS:
-1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
-2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
- ```bash
- mkcert -install
- mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
- ```
-3. Run `uvicorn` with the generated certs:
- ```bash
- python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
- ```
-4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
-
-### Optional Dependencies
-`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
-
-| Package | Feature unlocked |
-|---------|-----------------|
-| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
-| `ddgs` | DuckDuckGo as a search provider option. |
-| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
-| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
-
-### Faster, reproducible installs with uv (optional)
-[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
-venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
-
-```bash
-uv venv venv --python 3.13
-uv pip install -r requirements.txt
-# then continue as usual: python setup.py, uvicorn, ...
-```
-
-`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
-
-```bash
-uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
-uv pip sync requirements.lock # reproduce it exactly later
-```
-
-`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
-
-### Outlook / Office 365 email
-Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
-and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
-passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
-current limitation and the planned integration direction.
-
-## Security Notes
-Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
-
-- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
-- Keep `LOCALHOST_BYPASS=false` outside local development.
-- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
-- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
-- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
-- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
-- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
-- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
-- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
-- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
-- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
-- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
-
-### Private or proxied deployments
-Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
-
-1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
-2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
-3. Put the authenticated Odysseus web/API entrypoint behind that layer.
-4. Keep raw service and model ports internal-only.
-
-Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
-`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
-
-Common internal-only ports from the default docs/compose setup:
-
-| Port | Service |
-|---|---|
-| `7000` | Odysseus raw app port |
-| `8080` | SearXNG |
-| `8091` | ntfy |
-| `8100` | ChromaDB host port for manual/compose access |
-| `11434` | Ollama |
-| `8000-8020` | Common local model/provider APIs |
+A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
## Contributing
-Help is welcome. The best entry points are fresh-install testing, provider setup
-bugs, mobile/editor polish, docs, and small focused refactors. See
-[ROADMAP.md](ROADMAP.md) for the current help-wanted list.
-## Configuration
-Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
-for deployment-level defaults and secrets you want present before first boot.
-Key settings:
+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).
-| Variable | Default | Description |
-|---|---|---|
-| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
-| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
-| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
-| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
-| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
-| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
-| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
-| `AUTH_ENABLED` | `true` | Enable/disable login |
-| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
-| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
-| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
-| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
-| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
-| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
-| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
-| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
-| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
-| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
-| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
-| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
-| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
-| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
-| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
+## Security
-All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
-
-### Built-in MCP servers (optional setup)
-
-Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
-
-To enable the browser MCP (page navigation, screenshots, vision), run once:
-
-```bash
-npx -y @playwright/mcp@latest --version
-```
-
-That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
-
-## Architecture
-```
-app.py # FastAPI entry point
-core/ auth, database, middleware, constants
-src/ llm_core, agent_loop, agent_tools, chat_processor, search/
-routes/ chat, session, document, memory, model … endpoints
-services/ docs, memory, search, hwfit (Cookbook) …
-static/ index.html + app.js + style.css + js/ (modular front-end)
-docs/ landing page (index.html) + preview clips
-```
-
-## Data
-All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
-`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
+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).
## Star History
@@ -478,19 +72,5 @@ All user data lives in `data/` (gitignored): `app.db` (sessions, messages, docum
## License
-AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
-```
- |
- |||
- |||||
- | | | |||||||
- )_) )_) )_) ~|~
- )___))___))___)\ |
- )____)____)_____)\\|
- _____|____|____|_____\\\__
- \ /
- ~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
- ~^~ all aboard! ~^~
- ~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
-```
+AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
diff --git a/app.py b/app.py
index 9e48bb511..b6987acab 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,17 @@
# app.py — slim orchestrator
import mimetypes
import os
+import sys
+import asyncio
+
+# 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())
def register_static_mime_types() -> None:
@@ -38,12 +49,12 @@ load_dotenv(encoding="utf-8-sig")
import asyncio
import logging
import secrets
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Dict
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException
-from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
+from fastapi.responses import JSONResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
@@ -64,7 +75,7 @@ from core.exceptions import (
import bcrypt as _bcrypt
-from src.app_helpers import abs_join
+from src.app_helpers import abs_join, serve_html_with_nonce
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from starlette.responses import RedirectResponse
@@ -113,12 +124,13 @@ app = FastAPI(
)
# ========= CORS =========
+CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]
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=["GET", "POST", "PUT", "DELETE"],
+ allow_methods=CORS_ALLOW_METHODS,
allow_headers=[
"Accept",
"Authorization",
@@ -167,6 +179,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
"/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
)
@@ -315,7 +328,7 @@ if AUTH_ENABLED:
# (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
+ from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN as _ITT, INTERNAL_TOOL_USER
_hdr = request.headers.get(INTERNAL_TOOL_HEADER)
if _hdr and secrets.compare_digest(_hdr, _ITT) and _is_trusted_loopback(request):
# Impersonation: when the agent's loopback call sets
@@ -327,11 +340,11 @@ if AUTH_ENABLED:
if _impersonate and _impersonate in getattr(_auth_mgr, "users", {}):
request.state.current_user = _impersonate
else:
- request.state.current_user = "internal-tool"
+ request.state.current_user = INTERNAL_TOOL_USER
request.state.api_token = False
return await call_next(request)
- except Exception:
- pass
+ except Exception as _e:
+ logger.warning("Internal tool auth header check failed", exc_info=_e)
# 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
@@ -384,11 +397,10 @@ if AUTH_ENABLED:
_db.close()
try:
await _asyncio.to_thread(_do)
- except Exception:
- pass
+ except Exception as _e:
+ logger.debug("Failed to update token last_used_at", exc_info=_e)
_asyncio.create_task(_touch_last_used(matched_id))
# Keep bearer-token callers out of normal cookie/user
- # routes. API-aware routes can read api_token_owner.
request.state.current_user = "api"
request.state.api_token = True
request.state.api_token_id = matched_id
@@ -437,7 +449,7 @@ class _RevalidatingStatic(StaticFiles):
return resp
-app.mount("/static", _RevalidatingStatic(directory="static"), name="static")
+app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static")
# ========= GENERATED IMAGES =========
@app.get("/api/generated-image/{filename}")
@@ -463,8 +475,8 @@ async def serve_generated_image(filename: str, request: Request):
_db.close()
except HTTPException:
raise
- except Exception:
- pass
+ except Exception as _e:
+ logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
ext = filename.rsplit('.', 1)[-1].lower()
mime = {
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
@@ -527,6 +539,7 @@ memory_vector = components.get("memory_vector")
upload_handler = components["upload_handler"]
app.state.upload_handler = upload_handler
personal_docs_mgr = components["personal_docs_manager"]
+app.state.personal_docs_manager = personal_docs_mgr
api_key_manager = components["api_key_manager"]
preset_manager = components["preset_manager"]
chat_processor = components["chat_processor"]
@@ -788,23 +801,17 @@ app.include_router(setup_companion_routes())
# ========= ROUTES (kept in app.py) =========
-def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
- """Read an HTML file and inject the CSP nonce into inline ', encoding="utf-8")
+ resp = serve_html_with_nonce(_request_with_nonce("nonce-abc"), str(page))
+ assert resp.status_code == 200
+ body = resp.body.decode("utf-8")
+ assert "nonce-abc" in body
+ assert "{{CSP_NONCE}}" not in body
diff --git a/tests/test_session_endpoint_owner_scope.py b/tests/test_session_endpoint_owner_scope.py
index 6fe39e2c8..e1ea50588 100644
--- a/tests/test_session_endpoint_owner_scope.py
+++ b/tests/test_session_endpoint_owner_scope.py
@@ -52,6 +52,6 @@ def test_chat_endpoint_recovery_paths_are_owner_scoped():
assert "def _clear_orphaned_session_endpoint(sess, owner:" in chat_routes
assert "def _recover_empty_session_model(sess, session_id: str, owner:" in chat_routes
assert "q = owner_filter(q, ModelEndpoint, owner)" in chat_routes
- assert "resolve_session_auth(sess, session, owner=get_current_user(request))" in chat_routes
+ assert "resolve_session_auth(sess, session, owner=effective_user(request))" in chat_routes
assert "def resolve_session_auth(sess, session_id: str, owner:" in chat_helpers
assert "update_q = update_q.filter(DBSession.owner == owner)" in chat_helpers
diff --git a/tests/test_session_list_owner_scope.py b/tests/test_session_list_owner_scope.py
index 8bd9f3123..82e41e0d5 100644
--- a/tests/test_session_list_owner_scope.py
+++ b/tests/test_session_list_owner_scope.py
@@ -7,6 +7,7 @@ import sys
import tempfile
import types
import uuid
+from datetime import timedelta
import pytest
from sqlalchemy import create_engine
@@ -14,6 +15,7 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
import core.database as cdb
+from core.database import ChatMessage as DbMessage
from core.database import Session as DbSession
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
@@ -72,3 +74,60 @@ def test_list_sessions_excludes_other_users_sessions(monkeypatch):
returned_ids = {s["id"] for s in result}
assert alice_id in returned_ids
assert bob_id not in returned_ids
+
+
+def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(monkeypatch):
+ import routes.session_routes as sr
+ from unittest.mock import MagicMock
+
+ _stub_multipart_if_missing(monkeypatch)
+ monkeypatch.setenv("AUTH_ENABLED", "false")
+ monkeypatch.setattr(sr, "SessionLocal", _TS)
+ monkeypatch.setattr(sr, "effective_user", lambda request: None)
+
+ sid = str(uuid.uuid4())
+ old_time = cdb.utcnow_naive() - timedelta(hours=2)
+ db = _TS()
+ try:
+ db.query(DbMessage).delete()
+ db.query(DbSession).delete()
+ db.add(DbSession(
+ id=sid,
+ owner="alice",
+ name="New chat",
+ endpoint_url="http://localhost",
+ model="gpt-4",
+ archived=False,
+ message_count=1,
+ created_at=old_time,
+ updated_at=old_time,
+ last_message_at=old_time,
+ last_accessed=old_time,
+ ))
+ db.add(DbMessage(
+ id="m-" + uuid.uuid4().hex,
+ session_id=sid,
+ role="user",
+ content="hi",
+ timestamp=old_time,
+ ))
+ db.commit()
+ finally:
+ db.close()
+
+ session = MagicMock(id=sid, name="New chat", model="gpt-4", endpoint_url="http://localhost", rag=False, archived=False)
+ sm = MagicMock()
+ sm.get_sessions_for_user.return_value = {sid: session}
+ router = sr.setup_session_routes(sm, {})
+ endpoint = next(r.endpoint for r in router.routes
+ if getattr(r, "path", "") == "/api/sessions/auto-sort"
+ and "POST" in getattr(r, "methods", set()))
+
+ result = endpoint(request=MagicMock(), skip_llm=True)
+
+ assert result["deleted_throwaway"] == 1
+ db = _TS()
+ try:
+ assert db.query(DbSession).filter(DbSession.id == sid).first() is None
+ finally:
+ db.close()
diff --git a/tests/test_session_tools_registry.py b/tests/test_session_tools_registry.py
new file mode 100644
index 000000000..804cfdbdc
--- /dev/null
+++ b/tests/test_session_tools_registry.py
@@ -0,0 +1,149 @@
+"""Tests for the session tools' move to the agent_tools registry (#3629):
+create_session, list_sessions, send_to_session, manage_session.
+
+The implementations now live in src/agent_tools/session_tools.py (moved out of
+src/ai_interaction.py). These assert (1) the handlers are registered in
+TOOL_HANDLERS, (2) the moved logic runs and threads owner/session from ctx
+(the session manager is fetched via ai_interaction.get_session_manager), and
+(3) tool_execution.py dispatches them through the registry rather than the
+legacy dispatch_ai_tool elif.
+"""
+import asyncio
+from pathlib import Path
+
+import src.ai_interaction as ai_interaction
+import src.database as database
+from src.agent_tools import TOOL_HANDLERS
+from src.agent_tools import session_tools as st
+
+_SESSION_TOOLS = ("create_session", "list_sessions", "send_to_session", "manage_session")
+
+
+def test_session_tools_registered():
+ for name in _SESSION_TOOLS:
+ assert name in TOOL_HANDLERS, f"{name} missing from TOOL_HANDLERS"
+
+
+def test_list_sessions_handler_threads_ctx(monkeypatch):
+ # The handler must thread content + session_id + owner from ctx into the
+ # moved list_sessions implementation. Spy at the function boundary so the
+ # test does not depend on list_sessions' DB internals.
+ seen = {}
+
+ async def spy(content, session_id=None, owner=None):
+ seen.update(content=content, session_id=session_id, owner=owner)
+ return {"results": "ok"}
+
+ monkeypatch.setattr(st, "list_sessions", spy)
+ res = asyncio.run(st.ListSessionsTool().execute("q", {"owner": "alice", "session_id": "s1"}))
+ assert res == {"results": "ok"}
+ assert seen == {"content": "q", "session_id": "s1", "owner": "alice"}
+
+
+def test_manage_session_list_delegates_to_list_sessions(monkeypatch):
+ # manage_session("list") must delegate to list_sessions; guards against a
+ # stale do_list_sessions reference surviving the move (caught live in e2e).
+ called = {}
+
+ async def spy(content, session_id=None, owner=None):
+ called["owner"] = owner
+ return {"results": "ok"}
+
+ monkeypatch.setattr(st, "list_sessions", spy)
+ # manage_session imports `Session` from src.database before the list branch;
+ # the src.database test double may not expose it, so provide a stand-in.
+ monkeypatch.setattr(database, "Session", object, raising=False)
+ monkeypatch.setattr(ai_interaction, "_session_manager", object()) # truthy: pass the guard
+ res = asyncio.run(st.ManageSessionTool().execute("list", {"owner": "carol"}))
+ assert called.get("owner") == "carol"
+ assert res == {"results": "ok"}
+
+
+def test_create_session_reaches_uuid_and_creates(monkeypatch):
+ # Regression for the missing `import uuid` (PR review): create_session must
+ # get past _resolve_model and mint a session id without NameError.
+ monkeypatch.setattr(st, "_resolve_model", lambda spec, owner=None: ("http://x", "model-x", {}))
+ created = {}
+
+ class FakeMgr:
+ def create_session(self, **kw):
+ created.update(kw)
+
+ def get_session(self, sid):
+ return None
+
+ monkeypatch.setattr(ai_interaction, "_session_manager", FakeMgr())
+ res = asyncio.run(st.CreateSessionTool().execute("My Chat\nmodel-x", {"owner": "alice"}))
+ assert res.get("name") == "My Chat" and res.get("model") == "model-x"
+ assert isinstance(res.get("session_id"), str) and res["session_id"]
+ assert created.get("name") == "My Chat" # the uuid-minted id reached the manager
+
+
+def test_manage_session_fork_reaches_uuid(monkeypatch):
+ # Regression for the missing `import uuid`: the fork action also mints a new
+ # session id and must not NameError. Mocks the DB query layer so the fork
+ # branch reaches the uuid call without a real sessions table.
+ class FakeDbSession:
+ id = "id"
+ owner = "owner"
+
+ class FakeQ:
+ def filter(self, *a, **k):
+ return self
+
+ def first(self):
+ return object()
+
+ class FakeDB:
+ def query(self, *a, **k):
+ return FakeQ()
+
+ def close(self):
+ pass
+
+ monkeypatch.setattr(database, "Session", FakeDbSession, raising=False)
+ monkeypatch.setattr(database, "SessionLocal", lambda: FakeDB(), raising=False)
+
+ class Src:
+ name = "Orig"
+ endpoint_url = "http://x"
+ model = "m"
+
+ def get_context_messages(self):
+ return []
+
+ created = {}
+
+ class FakeMgr:
+ def get_session(self, sid):
+ return Src() if sid == "abc" else type("S", (), {"add_message": lambda self, m: None})()
+
+ def create_session(self, **kw):
+ created.update(kw)
+
+ monkeypatch.setattr(ai_interaction, "_session_manager", FakeMgr())
+ res = asyncio.run(st.ManageSessionTool().execute('{"action":"fork","session_id":"abc"}', {"owner": "owner"}))
+ assert res.get("action") == "fork"
+ assert isinstance(res.get("session_id"), str) and res["session_id"]
+ assert created.get("name") == "Fork: Orig" # uuid-minted new session was created
+
+
+def test_no_session_manager_is_handled(monkeypatch):
+ # With no session manager set, the moved function must fail gracefully
+ # (proves the handler reached the impl, not an "unknown tool").
+ monkeypatch.setattr(ai_interaction, "_session_manager", None)
+ res = asyncio.run(st.ListSessionsTool().execute("", {"owner": "bob"}))
+ assert isinstance(res, dict)
+ assert "error" in res or "results" in res
+
+
+def test_dispatched_via_registry_not_dispatch_ai_tool():
+ source = (Path(__file__).resolve().parent.parent / "src" / "tool_execution.py").read_text(encoding="utf-8")
+ assert 'elif tool in ("create_session", "list_sessions", "send_to_session", "manage_session"):' in source
+
+ marker = "from src.ai_interaction import dispatch_ai_tool"
+ idx = source.index(marker)
+ branch_head = source.rfind("elif tool in (", 0, idx)
+ legacy_tuple = source[branch_head:idx]
+ for name in _SESSION_TOOLS:
+ assert f'"{name}"' not in legacy_tuple, f"{name} still routed via dispatch_ai_tool"
diff --git a/tests/test_set_admin.py b/tests/test_set_admin.py
new file mode 100644
index 000000000..0d3b97172
--- /dev/null
+++ b/tests/test_set_admin.py
@@ -0,0 +1,317 @@
+"""Promote/demote users to/from admin (issue #2958).
+
+Covers AuthManager.set_admin (the core logic + last-admin lockout guard +
+privilege stash/restore on a real role change + no-op preservation) and the
+PUT /api/auth/users/{username}/admin route's status/envelope mapping.
+"""
+
+import asyncio
+import importlib
+import sys
+import types
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from fastapi import HTTPException
+
+from tests.helpers.import_state import clear_module
+
+
+# ---------------------------------------------------------------------------
+# Manager-level: real AuthManager on a temp auth.json (mirrors
+# tests/test_rename_user_case_insensitive.py).
+# ---------------------------------------------------------------------------
+
+def _real_core_package():
+ root = Path(__file__).resolve().parent.parent
+ core_path = str(root / "core")
+ core = sys.modules.get("core")
+ if core is None:
+ core = types.ModuleType("core")
+ sys.modules["core"] = core
+ core.__path__ = [core_path]
+ clear_module("core.auth")
+ return core
+
+
+def _fresh_auth_manager(tmp_path):
+ """Return (auth_module, AuthManager) with hashing stubbed for speed."""
+ auth_mod = importlib.import_module("core.auth", package=_real_core_package())
+ auth_mod._hash_password = lambda password: f"hash:{password}"
+ auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
+ mgr = auth_mod.AuthManager(str(tmp_path / "auth.json"))
+ return auth_mod, mgr
+
+
+def test_promote_sets_admin_flag_and_admin_privileges(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ assert mgr.create_user("admin", "pw-123456", is_admin=True) is True
+ assert mgr.create_user("bob", "pw-123456") is True
+
+ result = mgr.set_admin("bob", True, "admin")
+
+ assert result is auth_mod.SetAdminResult.OK
+ assert mgr.is_admin("bob") is True
+ assert mgr.users["bob"]["privileges"] == auth_mod.ADMIN_PRIVILEGES
+
+
+def test_demote_with_two_admins_resets_to_default_privileges(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456", is_admin=True)
+
+ result = mgr.set_admin("bob", False, "admin")
+
+ assert result is auth_mod.SetAdminResult.OK
+ assert mgr.is_admin("bob") is False
+ assert mgr.users["bob"]["privileges"] == auth_mod.DEFAULT_PRIVILEGES
+
+
+def test_demote_last_admin_is_blocked(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+
+ result = mgr.set_admin("admin", False, "admin")
+
+ assert result is auth_mod.SetAdminResult.LAST_ADMIN
+ assert mgr.is_admin("admin") is True # unchanged
+
+
+def test_self_demote_allowed_when_another_admin_exists(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456", is_admin=True)
+
+ result = mgr.set_admin("admin", False, "admin") # admin demotes self
+
+ assert result is auth_mod.SetAdminResult.OK
+ assert mgr.is_admin("admin") is False
+ assert mgr.is_admin("bob") is True
+
+
+def test_cannot_demote_past_the_last_admin_sequentially(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456", is_admin=True)
+
+ assert mgr.set_admin("bob", False, "admin") is auth_mod.SetAdminResult.OK
+ # Now "admin" is the only admin left — demoting them must be refused.
+ assert mgr.set_admin("admin", False, "admin") is auth_mod.SetAdminResult.LAST_ADMIN
+ assert mgr.is_admin("admin") is True
+
+
+def test_non_admin_requester_is_rejected(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456")
+ mgr.create_user("carol", "pw-123456")
+
+ result = mgr.set_admin("carol", True, "bob") # bob is not an admin
+
+ assert result is auth_mod.SetAdminResult.NOT_AUTHORIZED
+ assert mgr.is_admin("carol") is False
+
+
+def test_unknown_target_user_returns_not_found(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+
+ result = mgr.set_admin("ghost", True, "admin")
+
+ assert result is auth_mod.SetAdminResult.USER_NOT_FOUND
+
+
+def test_noop_demote_of_regular_user_preserves_custom_privileges(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456")
+ # Give bob a non-default privilege; DEFAULT_PRIVILEGES has can_use_bash=False.
+ assert mgr.set_privileges("bob", {"can_use_bash": True}) is True
+
+ result = mgr.set_admin("bob", False, "admin") # already a regular user
+
+ assert result is auth_mod.SetAdminResult.OK
+ # Privileges must NOT have been reset to defaults by the no-op.
+ assert mgr.users["bob"]["privileges"]["can_use_bash"] is True
+
+
+def test_demote_restores_pre_admin_privilege_restrictions(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456")
+ # Tighten bob below the defaults before promoting him.
+ assert mgr.set_privileges("bob", {
+ "can_use_agent": False,
+ "can_generate_images": False,
+ "max_messages_per_day": 50,
+ }) is True
+ restricted = mgr.get_privileges("bob")
+
+ assert mgr.set_admin("bob", True, "admin") is auth_mod.SetAdminResult.OK
+ assert mgr.set_admin("bob", False, "admin") is auth_mod.SetAdminResult.OK
+
+ # Demotion must restore the pre-admin policy, not reset to defaults.
+ assert mgr.get_privileges("bob") == restricted
+ assert mgr.get_privileges("bob")["can_use_agent"] is False
+ assert mgr.get_privileges("bob")["max_messages_per_day"] == 50
+
+
+def test_promote_demote_round_trip_is_stable_and_cleans_up_stash(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456")
+ assert mgr.set_privileges("bob", {"can_use_browser": False}) is True
+ restricted = mgr.get_privileges("bob")
+
+ for _ in range(2): # two full promote/demote cycles
+ assert mgr.set_admin("bob", True, "admin") is auth_mod.SetAdminResult.OK
+ assert mgr.set_admin("bob", False, "admin") is auth_mod.SetAdminResult.OK
+
+ assert mgr.get_privileges("bob") == restricted
+ # The stash is promotion-time bookkeeping; it must not linger on the row.
+ assert "privileges_before_admin" not in mgr.users["bob"]
+
+
+def test_redundant_promote_does_not_clobber_stash(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456")
+ assert mgr.set_privileges("bob", {"can_use_agent": False}) is True
+ restricted = mgr.get_privileges("bob")
+
+ assert mgr.set_admin("bob", True, "admin") is auth_mod.SetAdminResult.OK
+ # A second promote is a no-op and must not re-stash ADMIN_PRIVILEGES.
+ assert mgr.set_admin("bob", True, "admin") is auth_mod.SetAdminResult.OK
+ assert mgr.set_admin("bob", False, "admin") is auth_mod.SetAdminResult.OK
+
+ # Demotion must still restore the original pre-admin restrictions.
+ assert mgr.get_privileges("bob") == restricted
+ assert mgr.get_privileges("bob")["can_use_agent"] is False
+
+
+def test_pre_admin_privileges_survive_manager_reload(tmp_path):
+ auth_mod, mgr = _fresh_auth_manager(tmp_path)
+ mgr.create_user("admin", "pw-123456", is_admin=True)
+ mgr.create_user("bob", "pw-123456")
+ assert mgr.set_privileges("bob", {"can_use_research": False}) is True
+ assert mgr.set_admin("bob", True, "admin") is auth_mod.SetAdminResult.OK
+
+ # Fresh manager on the same auth.json — the stash must round-trip disk.
+ mgr2 = auth_mod.AuthManager(str(tmp_path / "auth.json"))
+ assert mgr2.set_admin("bob", False, "admin") is auth_mod.SetAdminResult.OK
+ assert mgr2.get_privileges("bob")["can_use_research"] is False
+
+
+# ---------------------------------------------------------------------------
+# Route-level: PUT /api/auth/users/{username}/admin (mirrors
+# tests/test_auth_regressions.py). SetAdminResult is read from the route
+# module's own namespace so the route and the test share one enum object.
+# ---------------------------------------------------------------------------
+
+_ADMIN_ROUTE = "/api/auth/users/{username}/admin"
+
+
+def _auth_route_endpoint(path, method):
+ from routes.auth_routes import setup_auth_routes
+
+ auth_manager = MagicMock()
+ router = setup_auth_routes(auth_manager)
+ for route in router.routes:
+ if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
+ return auth_manager, route.endpoint
+ raise AssertionError(f"{method} {path} route not registered")
+
+
+def _fake_auth_request(token="session-token"):
+ from routes.auth_routes import SESSION_COOKIE
+
+ req = SimpleNamespace()
+ req.cookies = {SESSION_COOKIE: token}
+ req.client = SimpleNamespace(host="127.0.0.1")
+ return req
+
+
+def _result_enum():
+ import routes.auth_routes as ar
+
+ return ar.SetAdminResult
+
+
+def test_route_requires_admin():
+ from routes.auth_routes import SetAdminRequest
+
+ auth, target = _auth_route_endpoint(_ADMIN_ROUTE, "PUT")
+ auth.get_username_for_token.return_value = "bob"
+ auth.is_admin.return_value = False
+
+ with pytest.raises(HTTPException) as exc:
+ asyncio.run(target(username="carol", body=SetAdminRequest(is_admin=True),
+ request=_fake_auth_request()))
+
+ assert exc.value.status_code == 403
+ auth.set_admin.assert_not_called()
+
+
+def test_route_last_admin_returns_400():
+ from routes.auth_routes import SetAdminRequest
+
+ R = _result_enum()
+ auth, target = _auth_route_endpoint(_ADMIN_ROUTE, "PUT")
+ auth.get_username_for_token.return_value = "admin"
+ auth.is_admin.return_value = True
+ auth.set_admin.return_value = R.LAST_ADMIN
+
+ with pytest.raises(HTTPException) as exc:
+ asyncio.run(target(username="admin", body=SetAdminRequest(is_admin=False),
+ request=_fake_auth_request()))
+
+ assert exc.value.status_code == 400
+
+
+def test_route_user_not_found_returns_404():
+ from routes.auth_routes import SetAdminRequest
+
+ R = _result_enum()
+ auth, target = _auth_route_endpoint(_ADMIN_ROUTE, "PUT")
+ auth.get_username_for_token.return_value = "admin"
+ auth.is_admin.return_value = True
+ auth.set_admin.return_value = R.USER_NOT_FOUND
+
+ with pytest.raises(HTTPException) as exc:
+ asyncio.run(target(username="ghost", body=SetAdminRequest(is_admin=True),
+ request=_fake_auth_request()))
+
+ assert exc.value.status_code == 404
+
+
+def test_route_success_returns_envelope():
+ from routes.auth_routes import SetAdminRequest
+
+ R = _result_enum()
+ auth, target = _auth_route_endpoint(_ADMIN_ROUTE, "PUT")
+ auth.get_username_for_token.return_value = "admin"
+ auth.is_admin.return_value = True
+ auth.set_admin.return_value = R.OK
+
+ out = asyncio.run(target(username="bob", body=SetAdminRequest(is_admin=True),
+ request=_fake_auth_request()))
+
+ assert out == {"ok": True, "is_admin": True, "self": False}
+
+
+def test_route_self_flag_true_when_targeting_own_account():
+ from routes.auth_routes import SetAdminRequest
+
+ R = _result_enum()
+ auth, target = _auth_route_endpoint(_ADMIN_ROUTE, "PUT")
+ auth.get_username_for_token.return_value = "admin"
+ auth.is_admin.return_value = True
+ auth.set_admin.return_value = R.OK
+
+ out = asyncio.run(target(username="Admin", body=SetAdminRequest(is_admin=False),
+ request=_fake_auth_request()))
+
+ assert out == {"ok": True, "is_admin": False, "self": True}
diff --git a/tests/test_setup_admin_user.py b/tests/test_setup_admin_user.py
index 9ecfb416b..b0fde4d75 100644
--- a/tests/test_setup_admin_user.py
+++ b/tests/test_setup_admin_user.py
@@ -1,5 +1,6 @@
import importlib.util
import json
+import os
from pathlib import Path
@@ -23,3 +24,49 @@ def test_create_default_admin_normalizes_env_username(tmp_path, monkeypatch):
data = json.loads(auth_path.read_text(encoding="utf-8"))
assert "adminuser" in data["users"]
assert "AdminUser" not in data["users"]
+
+
+def test_main_loads_admin_password_from_env_file(tmp_path, monkeypatch):
+ """Regression: setup.py must honor an admin password pre-seeded in .env on
+ native installs, even when the var is not exported into the shell
+ (docs/setup.md documents this). Previously setup.py never called
+ load_dotenv(), so os.getenv() saw nothing and a random password was
+ generated instead."""
+ import bcrypt
+
+ setup_module = _load_setup_module()
+
+ # Credentials live ONLY in a .env beside setup.py (written with a UTF-8 BOM,
+ # the Notepad-on-Windows case that utf-8-sig must tolerate) — not exported.
+ monkeypatch.delenv("ODYSSEUS_ADMIN_USER", raising=False)
+ monkeypatch.delenv("ODYSSEUS_ADMIN_PASSWORD", raising=False)
+ (tmp_path / ".env").write_text(
+ "ODYSSEUS_ADMIN_USER=presetuser\nODYSSEUS_ADMIN_PASSWORD=fromenvfile12345\n",
+ encoding="utf-8-sig",
+ )
+
+ # Point setup at the temp dir and neutralize main()'s heavy steps.
+ monkeypatch.setattr(setup_module, "BASE_DIR", str(tmp_path))
+ auth_path = tmp_path / "auth.json"
+ monkeypatch.setattr(setup_module, "AUTH_FILE", str(auth_path))
+ monkeypatch.setattr(setup_module, "check_arch", lambda: None)
+ monkeypatch.setattr(setup_module, "create_dirs", lambda: None)
+ monkeypatch.setattr(setup_module, "create_env", lambda: None)
+ monkeypatch.setattr(setup_module, "check_deps", lambda: None)
+ monkeypatch.setattr(setup_module, "init_database", lambda: None)
+ # Force the non-interactive branch so the test never blocks on a prompt.
+ monkeypatch.setenv("ODYSSEUS_SKIP_ADMIN_PROMPT", "1")
+
+ try:
+ setup_module.main()
+ finally:
+ # load_dotenv writes real os.environ entries; undo so sibling tests
+ # don't inherit them.
+ os.environ.pop("ODYSSEUS_ADMIN_USER", None)
+ os.environ.pop("ODYSSEUS_ADMIN_PASSWORD", None)
+
+ data = json.loads(auth_path.read_text(encoding="utf-8"))
+ assert "presetuser" in data["users"], data
+ assert bcrypt.checkpw(
+ b"fromenvfile12345", data["users"]["presetuser"]["password_hash"].encode()
+ ), "admin password from .env was ignored; a random one was generated"
diff --git a/tests/test_setup_llamacpp_hint_js.py b/tests/test_setup_llamacpp_hint_js.py
new file mode 100644
index 000000000..2eef9483c
--- /dev/null
+++ b/tests/test_setup_llamacpp_hint_js.py
@@ -0,0 +1,17 @@
+"""The /setup guide must offer a llama.cpp (llama-server) local example.
+
+Without it, the port-8080 "llama.cpp" provider label (src/llm_core.py
+_provider_label) is never reachable from first-run setup — a user pasting a
+local endpoint only saw the Ollama and generic examples. Both the static-HTML
+and the streamed-blocks renderings of the setup guide must carry the example.
+"""
+from pathlib import Path
+
+_SRC = Path(__file__).resolve().parent.parent / "static" / "js" / "slashCommands.js"
+
+
+def test_setup_guide_offers_llamacpp_local_example():
+ src = _SRC.read_text(encoding="utf-8")
+ # The example URL appears in both the HTML-string and streamed renderings.
+ assert src.count("http://localhost:8080/v1") >= 2
+ assert "llama.cpp (llama-server)" in src
diff --git a/tests/test_skill_edit_no_collapse_on_outside_click_js.py b/tests/test_skill_edit_no_collapse_on_outside_click_js.py
new file mode 100644
index 000000000..1a25c5325
--- /dev/null
+++ b/tests/test_skill_edit_no_collapse_on_outside_click_js.py
@@ -0,0 +1,56 @@
+"""Regression guard for issue #4002 — clicking the card body outside the
+edit textarea collapsed the skill card and silently discarded unsaved edits.
+
+In Brain > Skills, the card's click handler toggles expand/collapse. The
+edit