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 + +**![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) 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:** `![P0 Badge](https://img.shields.io/badge/P0-red?style=flat)` - + release-blocking or actively dangerous. + - **P1:** `![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat)` - + serious bug, security risk, data-loss risk, or broken primary flow. + - **P2:** `![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)` - + meaningful correctness, test, maintainability, or edge-case issue. + - **P3:** `![P3 Badge](https://img.shields.io/badge/P3-lightgrey?style=flat)` - + 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 +

+ 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 +

-![Odysseus](docs/odysseus.jpg) +

+ Packaging status +

-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. +

+ Odysseus interface +

-[![Packaging status](https://repology.org/badge/vertical-allrepos/odysseus-ai.svg)](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 -![Chat & Agents](docs/chat.gif) -### Deep Research -![Deep Research](docs/research.gif) -### Compare -![Compare](docs/compare.gif) -### Documents -![Documents](docs/document.gif) -### Notes & Tasks -![Notes & Tasks](docs/notes.gif) - -
+--- ## 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