diff --git a/.env.example b/.env.example index 39c90b30d..5382c23c7 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,13 @@ SEARXNG_INSTANCE=http://localhost:8080 # SQLite database path (default: sqlite:///./data/app.db) # DATABASE_URL=sqlite:///./data/app.db +# ============================================================ +# Data directory +# ============================================================ +# Move everything that lives under data/ - settings, sessions, database, auth, +# cache, uploads, etc. - to another path: +# ODYSSEUS_DATA_DIR=C:\path\to\dir + # ============================================================ # Auth & Security # ============================================================ @@ -147,6 +154,21 @@ SEARXNG_INSTANCE=http://localhost:8080 # if you intentionally want scheduled scripts to run remotely. # ODYSSEUS_SCRIPT_HOST=localhost +# Chat / agent attachment size cap in bytes (default: 10 MB). +# Raise this for local installs that need larger PDFs or text documents. +# Example: 52428800 = 50 MB. +# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760 + +# Other per-feature upload size caps in bytes. All are validated and optional; +# defaults shown. An invalid value (non-integer or < 1) fails fast at startup. +# ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=104857600 # gallery image upload (100 MB) +# ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=26214400 # gallery transform input (25 MB) +# ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=10485760 # memory import file (10 MB) +# ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=26214400 # personal document upload (25 MB) +# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB) +# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) +# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) + # ============================================================ # GPU support (Docker Compose) # ============================================================ diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 67d84b1ff..64f2d7dcf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -23,7 +23,7 @@ body: required: true - label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md).) required: true - - label: I am running the latest code from `main`. + - label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing. required: true - type: dropdown diff --git a/.github/scripts/check-pr-description.js b/.github/scripts/check-pr-description.js index 2a06c2b36..f5dabea5d 100644 --- a/.github/scripts/check-pr-description.js +++ b/.github/scripts/check-pr-description.js @@ -103,14 +103,21 @@ module.exports = async ({ github, context, core }) => { async function swapLabel(num, add, remove) { if (await labelExists(add)) { - await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] }); + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] }); + } catch (e) { + // Fail soft on a token that can't write labels so a label permission + // problem never masks the actual description verdict. + if (e.status !== 403) throw e; + core.warning(`Could not add "${add}" — token lacks label write here; skipping.`); + } } else { core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`); } try { await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove }); } catch (e) { - if (e.status !== 404 && e.status !== 410) throw e; + if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e; } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b75f96b96..818495d14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" @@ -31,6 +33,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" @@ -53,6 +57,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 + persist-credentials: false # Detect whether this PR only touches documentation files. # If so, skip the expensive pytest run while still reporting a passing check. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 000000000..5e822ab07 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,140 @@ +name: ci / docker publish + +# Build the Odysseus image and publish to GHCR. +# push to main -> :latest, :X.Y.Z (curated release; main is fast-forwarded at releases) +# push to dev -> :dev, :X.Y.Z-dev. (rolling dev + an immutable, traceable pin) +# Multi-arch (linux/amd64 + linux/arm64): each arch builds on its own native +# runner and pushes by digest, then a merge job stitches the digests into one +# manifest list and applies the tags (faster + cleaner than QEMU emulation). +# Registry: ghcr.io//. + +on: + push: + branches: [dev, main] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: build (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digest-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: merge manifest + tag + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Read APP_VERSION + short sha + id: ver + run: | + v=$(grep -E '^APP_VERSION' src/constants.py | head -1 | sed -E 's/.*"([^"]+)".*/\1/') + [ -n "$v" ] || { echo "APP_VERSION not found"; exit 1; } + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute tags + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=${{ steps.ver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + type=raw,value=${{ steps.ver.outputs.version }}-dev.${{ steps.ver.outputs.short }},enable=${{ github.ref == 'refs/heads/dev' }} + - name: Create manifest list + push tags + working-directory: /tmp/digests + run: | + tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") + digests=$(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *) + # word-splitting is intended: $tags and $digests each expand to multiple args + # shellcheck disable=SC2086 + docker buildx imagetools create $tags $digests + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} + - name: Inspect + run: | + if [ "$GITHUB_REF" = "refs/heads/main" ]; then ref=latest; else ref=dev; fi + docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${ref}" + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} diff --git a/.github/workflows/issue-description-check.yml b/.github/workflows/issue-description-check.yml index 5dc3fdf82..3d0cf094e 100644 --- a/.github/workflows/issue-description-check.yml +++ b/.github/workflows/issue-description-check.yml @@ -14,10 +14,11 @@ jobs: # Skip bots (Dependabot, release-drafter, etc.) if: ${{ github.event.issue.user.type != 'Bot' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: sparse-checkout: .github/scripts + persist-credentials: false - - uses: actions/github-script@v7 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: return require('./.github/scripts/check-issue-description.js')({github, context, core}) diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index 9ac05b373..c8fbe4b0f 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -1,28 +1,109 @@ -name: ci / PR description check +name: ci / PR checks on: - pull_request_target: - types: [opened, edited, synchronize, reopened] + # pull_request_target runs in the base-repo context (has secrets) so the check + # works on fork PRs. Safe here: the checkout pins to the base branch (no fork + # code runs) and the scripts only read context.payload and call the GitHub API. + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, edited, synchronize, reopened, ready_for_review] -# pull_request_target runs in the base-repo context (has secrets). -# The checkout below pins to the base branch so no fork code is executed. -# The script only reads context.payload and calls the GitHub API. -permissions: - issues: write - pull-requests: write +# Default-deny at the workflow level; each job opts into only the scopes it needs. +# Note: modifying a PR's labels/comments needs pull-requests:write even though the +# REST path is under /issues/{n}/...; issues:write alone returns 403 on PRs. +permissions: {} jobs: check-description: name: Check PR description runs-on: ubuntu-latest - # Skip bots — they open PRs programmatically and have their own process. + permissions: + contents: read + pull-requests: write + issues: write + # Skip bots: they open PRs programmatically and have their own process. if: github.event.pull_request.user.type != 'Bot' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.base_ref }} sparse-checkout: .github/scripts + persist-credentials: false - - uses: actions/github-script@v7 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: return require('./.github/scripts/check-pr-description.js')({github, context, core}) + + check-title: + name: Check PR title (Conventional Commits) + runs-on: ubuntu-latest + permissions: {} + # Skip bots: they open PRs programmatically and have their own process. + if: github.event.pull_request.user.type != 'Bot' + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const title = context.payload.pull_request.title || ""; + // Conventional Commits: type(optional-scope)(optional !): summary + const re = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w .\/-]+\))?!?: .+/; + if (!re.test(title)) { + core.setFailed( + `PR title is not in Conventional Commits format:\n "${title}"\n\n` + + `Expected: type(scope): summary\n` + + `Example: fix(search): handle empty query\n` + + `Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.` + ); + } else { + core.info(`PR title OK: ${title}`); + } + + check-mergeable: + name: Flag unmergeable PRs + runs-on: ubuntu-latest + permissions: + pull-requests: write + issues: write + # Skip bots: they open PRs programmatically and have their own process. + if: github.event.pull_request.user.type != 'Bot' + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + const number = context.payload.pull_request.number; + const READY = "ready for review"; + const CONFLICT = "merge conflict"; + + // Ensure the conflict label exists (red). Ignore if already present. + try { + await github.rest.issues.getLabel({ ...repo, name: CONFLICT }); + } catch { + await github.rest.issues.createLabel({ + ...repo, name: CONFLICT, color: "B60205", + description: "Conflicts with the base branch; needs a rebase before review.", + }).catch(() => {}); + } + + // mergeable is computed asynchronously and is often null right after + // an event, so poll a few times until GitHub has resolved it. + let pr = null; + for (let i = 0; i < 5; i++) { + const { data } = await github.rest.pulls.get({ ...repo, pull_number: number }); + if (data.mergeable !== null) { pr = data; break; } + await new Promise(r => setTimeout(r, 3000)); + } + if (!pr || pr.draft) return; + const labels = pr.labels.map(l => l.name); + + if (pr.mergeable === false) { + if (labels.includes(READY)) { + await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: READY }).catch(() => {}); + } + if (!labels.includes(CONFLICT)) { + await github.rest.issues.addLabels({ ...repo, issue_number: number, labels: [CONFLICT] }); + } + } else if (pr.mergeable === true) { + if (labels.includes(CONFLICT)) { + await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: CONFLICT }).catch(() => {}); + } + } diff --git a/.gitignore b/.gitignore index c48f6cd61..846e6cf74 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ docs/windows-port/ compound.config.json *.error.log _scratch/ +/odysseus/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2302c4198..174a4f2f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,18 @@ Before submitting any change that affects what the app looks like — buttons, i If you are unsure whether a change is "visual," it is. Default to attaching a screenshot. +## Code conventions + +Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed. + +- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import. +- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`). +- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal. + +If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files. + +**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious. + ## Issue Reports For bugs, include: diff --git a/LICENSE b/LICENSE index 7087e2d59..0c97efd25 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,235 @@ -MIT License +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 -Copyright (c) 2025 Odysseus Contributors +Copyright (C) 2007 Free Software Foundation, Inc. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + Preamble -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/README.md b/README.md index 638089fd7..a0dde96a9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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). + ``` ─────────────────────────────────────────────── ⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0 @@ -327,10 +329,16 @@ To expose Odysseus on a local network or Tailscale with HTTPS: | Package | Feature unlocked | |---------|-----------------| | `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. | -| `duckduckgo-search` | DuckDuckGo as a search provider option. | +| `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). | +### 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. @@ -394,6 +402,16 @@ Key settings: | `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). | + +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) @@ -433,7 +451,7 @@ All user data lives in `data/` (gitignored): `app.db` (sessions, messages, docum ## License -MIT -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). +AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). ``` | diff --git a/app.py b/app.py index 87ef1ae45..365eee94a 100644 --- a/app.py +++ b/app.py @@ -47,15 +47,16 @@ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.gzip import GZipMiddleware # Core imports from core.constants import ( BASE_DIR, STATIC_DIR, SESSIONS_FILE, - REQUEST_TIMEOUT, OPENAI_API_KEY, + REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE, ) from core.database import SessionLocal, ApiToken -from core.middleware import SecurityHeadersMiddleware -from core.auth import AuthManager +from core.middleware import SecurityHeadersMiddleware, is_cors_preflight +from core.auth import AuthManager, normalize_known_username from core.exceptions import ( SessionNotFoundError, InvalidFileUploadError, LLMServiceError, WebSearchError, @@ -104,6 +105,16 @@ app.add_middleware( ], ) +# ========= RESPONSE COMPRESSION (gzip) ========= +# The frontend's text assets (style.css, index.html, the JS bundles) shipped +# uncompressed on every cold load. gzip cuts CSS/JS/HTML by ~75-85% on the wire +# with no behavioural change. Starlette's GZipMiddleware excludes +# `text/event-stream` by default, so the SSE streams (chat, shell, research, +# model-probe — all served with media_type="text/event-stream") are never +# compressed or buffered; only complete bodies over minimum_size are. The +# security-header middleware composes cleanly on top. +app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6) + # ========= SECURITY HEADERS MIDDLEWARE ========= app.add_middleware(SecurityHeadersMiddleware) @@ -217,8 +228,16 @@ if AUTH_ENABLED: try: rows = db.query(ApiToken).filter(ApiToken.is_active == True).all() for r in rows: + owner_key = normalize_known_username(auth_manager.users, getattr(r, "owner", None)) + if not owner_key: + logger.warning( + "Ignoring active API token '%s' for unknown auth user '%s'", + getattr(r, "id", ""), + getattr(r, "owner", None), + ) + continue scopes = [s.strip() for s in (getattr(r, "scopes", "") or "chat").split(",") if s.strip()] - new_map[r.token_prefix].append((r.id, r.token_hash, getattr(r, "owner", None), scopes)) + new_map[r.token_prefix].append((r.id, r.token_hash, owner_key, scopes)) finally: db.close() _token_cache.clear() @@ -253,6 +272,15 @@ if AUTH_ENABLED: class AuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): path = request.url.path + # A genuine CORS preflight (OPTIONS + Access-Control-Request-Method) + # carries no credentials by design and must reach CORSMiddleware to be + # answered. AuthMiddleware is the outermost middleware, so gating the + # preflight on auth 401s it before CORS can respond -- which blocks + # every cross-origin browser/WebView client before the real request + # is sent. Let real preflights through (only OPTIONS w/ the ACRM + # header; never a credentialed request). + if is_cors_preflight(request.method, request.headers): + return await call_next(request) if _is_auth_exempt(path): return await call_next(request) # In-process internal-tool token bypass. Used by the agent @@ -463,6 +491,10 @@ components = initialize_managers(BASE_DIR, rag_manager) session_manager = components["session_manager"] from src.assistant_log import set_session_manager as _set_asst_sm _set_asst_sm(session_manager) +# Set the global session manager singleton (used by core.models.Session.add_message) +from core.models import set_session_manager_instance +set_session_manager_instance(session_manager) +app.state.session_manager = session_manager memory_manager = components["memory_manager"] memory_vector = components.get("memory_vector") upload_handler = components["upload_handler"] @@ -471,6 +503,7 @@ api_key_manager = components["api_key_manager"] preset_manager = components["preset_manager"] chat_processor = components["chat_processor"] research_handler = components["research_handler"] +app.state.research_handler = research_handler chat_handler = components["chat_handler"] model_discovery = components["model_discovery"] skills_manager = components["skills_manager"] @@ -520,9 +553,6 @@ upload_cleanup_task = None from routes.emoji_routes import setup_emoji_routes app.include_router(setup_emoji_routes()) -from routes.workspace_routes import setup_workspace_routes -app.include_router(setup_workspace_routes()) - # Sessions from routes.session_routes import setup_session_routes session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE} @@ -567,7 +597,7 @@ app.include_router(setup_preset_routes(preset_manager)) # Diagnostics from routes.diagnostics_routes import setup_diagnostics_routes -app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler)) +app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector)) # Cleanup from routes.cleanup_routes import setup_cleanup_routes @@ -589,6 +619,10 @@ app.include_router(setup_model_routes(model_discovery)) from routes.copilot_routes import setup_copilot_routes app.include_router(setup_copilot_routes()) +# ChatGPT Subscription device-flow login +from routes.chatgpt_subscription_routes import setup_chatgpt_subscription_routes +app.include_router(setup_chatgpt_subscription_routes()) + # TTS from routes.tts_routes import setup_tts_routes app.include_router(setup_tts_routes(tts_service)) @@ -784,6 +818,8 @@ async def serve_backgrounds(request: Request): @app.get("/login") async def serve_login(request: Request): + if not AUTH_ENABLED: + return RedirectResponse(url="/", status_code=302) return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html")) @app.get("/api/version") @@ -911,16 +947,21 @@ async def _startup_event(): async def _warmup_endpoints(): try: import httpx - endpoints = model_discovery.get_endpoints() if model_discovery else [] - for ep in endpoints[:5]: - url = ep.get("url", "").replace("/chat/completions", "/models") - if url: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - await client.get(url) - logger.info(f"Warmup ping OK: {url}") - except Exception as e: - logger.debug(f"Warmup ping failed for endpoint: {e}") + # model_discovery has no get_endpoints(); that call raised + # AttributeError every run and silently disabled warmup/keepalive. + # Resolve the /models probe URLs via the real discovery API, off the + # event loop since discovery does a blocking port scan. + urls = ( + await asyncio.to_thread(model_discovery.warmup_ping_urls) + if model_discovery else [] + ) + for url in urls: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.get(url) + logger.info(f"Warmup ping OK: {url}") + except Exception as e: + logger.debug(f"Warmup ping failed for endpoint: {e}") except Exception as e: logger.debug(f"Warmup ping skipped: {e}") @@ -943,7 +984,7 @@ async def _startup_event(): owners = set() try: import json as _json - auth_path = "data/auth.json" + auth_path = AUTH_FILE with open(auth_path, encoding="utf-8") as f: users = _json.load(f).get("users", {}) owners.update(users.keys()) @@ -990,7 +1031,7 @@ async def _startup_event(): # does not make an existing library look empty after auth/account changes. try: import json as _json - auth_path = "data/auth.json" + auth_path = AUTH_FILE with open(auth_path, encoding="utf-8") as f: users = _json.load(f).get("users", {}) primary_owner = None diff --git a/companion/pairing.py b/companion/pairing.py index 48197302b..c4ea62345 100644 --- a/companion/pairing.py +++ b/companion/pairing.py @@ -14,6 +14,8 @@ import uuid import bcrypt +from src.constants import AUTH_FILE + PAIRING_VERSION = 1 COMPANION_SCOPE = "chat" @@ -61,7 +63,7 @@ def lan_ip_candidates() -> list[str]: def find_admin_user() -> str | None: """Resolve an admin username from data/auth.json (schema uses is_admin), falling back to the first user.""" - auth_path = os.path.join("data", "auth.json") + auth_path = AUTH_FILE try: with open(auth_path, "r", encoding="utf-8") as f: data = json.load(f) diff --git a/core/auth.py b/core/auth.py index ed083b008..2f9fd4e51 100644 --- a/core/auth.py +++ b/core/auth.py @@ -31,15 +31,23 @@ DEFAULT_PRIVILEGES = { "max_messages_per_day": 0, "allowed_models": [], "allowed_models_restricted": False, + # Explicit "block every model" sentinel. An empty `allowed_models` list is + # ambiguous — it's also what gets sent when the admin clicks "[All]" — so + # we need a dedicated flag to express "this user may use no models at all" + # distinctly from "this user has no restriction". + "block_all_models": False, } # Admins get everything ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()} ADMIN_PRIVILEGES["allowed_models_restricted"] = False +# Admins must never be blocked from using models — the generic dict +# comprehension above flips every boolean default to True, which would be +# backwards for this sentinel. +ADMIN_PRIVILEGES["block_all_models"] = False -DEFAULT_AUTH_PATH = os.path.join( - Path(__file__).parent.parent, "data", "auth.json" -) +from src.constants import AUTH_FILE +DEFAULT_AUTH_PATH = AUTH_FILE TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days # Usernames the auth + middleware layer reserve as internal "synthetic owner" @@ -59,6 +67,14 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"}) +def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]: + """Return a normalized username only when it exists in the auth user map.""" + key = str(username or "").strip().lower() + if not key or key not in users: + return None + return key + + def _hash_password(password: str) -> str: return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") @@ -88,6 +104,7 @@ class AuthManager: self._load() self._load_sessions() self._migrate_single_user() + self._drop_reserved_loaded_users() self._migrate_legacy_admin_role() def _load(self): @@ -140,7 +157,13 @@ class AuthManager: def _migrate_single_user(self): """Migrate old single-user format to multi-user format.""" if "password_hash" in self._config and "users" not in self._config: - old_user = self._config.get("username", "admin") + old_user = str(self._config.get("username", "admin") or "admin").strip().lower() + if old_user in RESERVED_USERNAMES: + logger.warning( + "Migrating legacy single-user reserved username '%s' to 'admin'", + old_user, + ) + old_user = "admin" old_hash = self._config["password_hash"] self._config = { "users": { @@ -154,6 +177,30 @@ class AuthManager: self._save() logger.info(f"Migrated single-user auth to multi-user (admin: {old_user})") + def _drop_reserved_loaded_users(self): + """Fail closed for legacy/manual auth rows that collide with sentinels.""" + users = self._config.get("users") + if not isinstance(users, dict): + return + normalized = {} + removed = [] + for username, data in users.items(): + key = str(username or "").strip().lower() + if not key: + continue + if key in RESERVED_USERNAMES: + removed.append(key) + continue + normalized[key] = data + if removed or normalized != users: + self._config["users"] = normalized + self._save() + if removed: + logger.warning( + "Removed reserved username(s) from auth config: %s", + ", ".join(sorted(set(removed))), + ) + def _migrate_legacy_admin_role(self): """Normalize setup.py's old role='admin' marker to is_admin=True.""" changed = False @@ -236,6 +283,22 @@ class AuthManager: return False if not self.users.get(requesting_user, {}).get("is_admin"): return False + # Revoke API bearer tokens before removing the auth row. The bearer + # path authenticates from ApiToken rows and does not require the + # owner to still exist, so a successful delete must not leave active + # rows behind. If the token store is unavailable, fail closed and + # keep the user/session state intact so the admin can retry. + try: + from core.database import get_db_session, ApiToken + with get_db_session() as db: + removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete() + if removed_tokens: + logger.info( + f"Revoked {removed_tokens} API token(s) owned by deleted user '{username}'" + ) + except Exception: + logger.warning(f"Failed to revoke API tokens for deleted user '{username}'") + return False del self._config["users"][username] self._save() # Purge all sessions belonging to this user. validate_token doesn't @@ -250,18 +313,6 @@ class AuthManager: revoked += 1 if revoked: self._save_sessions() - # Also revoke API bearer tokens owned by this user. The bearer auth - # path authenticates straight against ApiToken rows and never - # re-checks that the owner still exists, so leaving the rows behind - # would let a deleted user keep full API access indefinitely. - try: - from core.database import get_db_session, ApiToken - with get_db_session() as db: - removed = db.query(ApiToken).filter(ApiToken.owner == username).delete() - if removed: - logger.info(f"Revoked {removed} API token(s) owned by deleted user '{username}'") - except Exception: - logger.warning(f"Failed to revoke API tokens for deleted user '{username}'") logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)") return True @@ -447,6 +498,12 @@ class AuthManager: username = username.strip().lower() if not self.verify_password(username, password): return None + return self.create_session_trusted(username) + + def create_session_trusted(self, username: str) -> str: + """Issue a session token for an already-verified user. + Call only after verify_password (and TOTP if enabled) have passed.""" + username = username.strip().lower() token = secrets.token_hex(32) with self._sessions_lock: self._sessions[token] = { diff --git a/core/constants.py b/core/constants.py index 5dcf9e91e..d71bb0aed 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1,40 +1,12 @@ -# src/constants.py -"""Application-wide constants and configuration values.""" -import os +# core/constants.py +"""Backward-compatible shim — the single source of truth is src/constants.py. -APP_VERSION = "0.9.1" - -# Base paths -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/" -STATIC_DIR = os.path.join(BASE_DIR, "static") -DATA_DIR = os.path.join(BASE_DIR, "data") - -# Data file paths -SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json") -MEMORY_FILE = os.path.join(DATA_DIR, "memory.json") -MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md") -PERSONAL_DIR = os.path.join(DATA_DIR, "personal_docs") -RUNBOOK_DIR = os.path.join(PERSONAL_DIR, "runbook") -UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") -FEATURES_FILE = os.path.join(DATA_DIR, "features.json") -SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") - -# API Configuration -MAX_CONTEXT_MESSAGES = 90 -REQUEST_TIMEOUT = 20 -OPENAI_COMPAT_PATH = "/v1/chat/completions" - -# Environment variables with defaults -DEFAULT_HOST = os.getenv("LLM_HOST", "localhost") -LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()] -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -SEARXNG_INSTANCE = os.getenv('SEARXNG_INSTANCE', 'http://localhost:8080') - - -# Cleanup configuration -CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true" -CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24")) - -# Default parameters -DEFAULT_TEMPERATURE = 1.0 -DEFAULT_MAX_TOKENS = 0 +Historically there were two copies of this module (this one lagged behind at +APP_VERSION 0.9.1 and was missing the consolidated tool-output constants). To +kill the drift, this now simply re-exports everything from src.constants so +there is exactly one place that defines paths and reads ODYSSEUS_DATA_DIR. +internal_api_base() also lives in src.constants now and is re-exported here so +existing `from core.constants import internal_api_base` callers keep working. +""" +from src.constants import * # noqa: F401,F403 +from src.constants import internal_api_base # noqa: F401 (explicit: functions aren't covered by some linters' * checks) diff --git a/core/database.py b/core/database.py index a559f55c5..6eec48d11 100644 --- a/core/database.py +++ b/core/database.py @@ -29,8 +29,9 @@ class TimestampMixin: def updated_at(cls): return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False) -# Get database URL from environment, default to SQLite -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db") +# Get database URL from environment, default to SQLite in DATA_DIR +from src.constants import DATA_DIR, AUTH_FILE, MEMORY_FILE, USER_PREFS_FILE, SETTINGS_FILE +DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR}/app.db") # Create engine engine = create_engine( @@ -360,6 +361,24 @@ class ModelEndpoint(TimestampMixin, Base): # is the historical default. When non-null, the model picker only shows # the endpoint to that user (admins always see everything). owner = Column(String, nullable=True, index=True) + # Optional OAuth/session-backed credential row. Used by subscription-backed + # providers that need refresh tokens instead of a static API key. + provider_auth_id = Column(String, nullable=True, index=True) + + +class ProviderAuthSession(TimestampMixin, Base): + """Encrypted OAuth/session credentials for refresh-aware model providers.""" + __tablename__ = "provider_auth_sessions" + + id = Column(String, primary_key=True, index=True) + provider = Column(String, nullable=False, index=True) + owner = Column(String, nullable=True, index=True) + label = Column(String, nullable=True) + base_url = Column(String, nullable=False) + access_token = Column(EncryptedText, nullable=True) + refresh_token = Column(EncryptedText, nullable=True) + last_refresh = Column(DateTime, nullable=True) + auth_mode = Column(String, nullable=True) class McpServer(TimestampMixin, Base): """Admin-configured MCP (Model Context Protocol) tool servers.""" @@ -669,6 +688,7 @@ def _migrate_add_last_message_at_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -694,10 +714,14 @@ def _migrate_add_last_message_at_column(): "ON sessions(archived, last_message_at)" ) conn.commit() - conn.close() logging.getLogger(__name__).info("Migrated: added + backfilled 'last_message_at' on sessions") except Exception as e: logging.getLogger(__name__).warning(f"last_message_at migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_document_archived_column(): """Add `archived` to documents (soft-archive flag). Guarded + idempotent.""" @@ -705,6 +729,7 @@ def _migrate_add_document_archived_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(documents)") @@ -713,9 +738,13 @@ def _migrate_add_document_archived_column(): conn.execute("ALTER TABLE documents ADD COLUMN archived BOOLEAN DEFAULT 0") conn.commit() logging.getLogger(__name__).info("Migrated: added 'archived' to documents") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"documents.archived migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_owner_column(): @@ -724,6 +753,7 @@ def _migrate_add_owner_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -733,9 +763,13 @@ def _migrate_add_owner_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_sessions_owner ON sessions(owner)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'owner' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_model_endpoints(): """Recreate model_endpoints table if schema changed (url->base_url).""" @@ -743,6 +777,7 @@ def _migrate_model_endpoints(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -751,9 +786,13 @@ def _migrate_model_endpoints(): conn.execute("DROP TABLE IF EXISTS model_endpoints") conn.commit() logging.getLogger(__name__).info("Migrated: dropped old model_endpoints table (schema change)") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints migration check failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_hidden_models_column(): """Add hidden_models column to model_endpoints if it doesn't exist.""" @@ -761,6 +800,7 @@ def _migrate_add_hidden_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -769,9 +809,13 @@ def _migrate_add_hidden_models_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN hidden_models TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'hidden_models' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"hidden_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_endpoint_owner_column(): """Add owner column to model_endpoints if it doesn't exist. @@ -786,6 +830,7 @@ def _migrate_add_model_endpoint_owner_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -795,9 +840,38 @@ def _migrate_add_model_endpoint_owner_column(): conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_owner ON model_endpoints(owner)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'owner' column + index to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints.owner migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass + + +def _migrate_add_provider_auth_id_column(): + """Add provider_auth_id column to model_endpoints if it doesn't exist.""" + import sqlite3 + db_path = DATABASE_URL.replace("sqlite:///", "") + if not os.path.exists(db_path): + return + conn = None + try: + conn = sqlite3.connect(db_path) + cursor = conn.execute("PRAGMA table_info(model_endpoints)") + columns = [row[1] for row in cursor.fetchall()] + if columns and "provider_auth_id" not in columns: + conn.execute("ALTER TABLE model_endpoints ADD COLUMN provider_auth_id VARCHAR") + conn.execute("CREATE INDEX IF NOT EXISTS ix_model_endpoints_provider_auth_id ON model_endpoints(provider_auth_id)") + conn.commit() + logging.getLogger(__name__).info("Migrated: added 'provider_auth_id' column + index to model_endpoints") + except Exception as e: + logging.getLogger(__name__).warning(f"model_endpoints.provider_auth_id migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_type_column(): @@ -806,6 +880,7 @@ def _migrate_add_model_type_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -814,9 +889,13 @@ def _migrate_add_model_type_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_type TEXT DEFAULT 'llm'") conn.commit() logging.getLogger(__name__).info("Migrated: added 'model_type' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_type migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_model_endpoint_refresh_columns(): """Add endpoint classification / refresh policy columns if missing.""" @@ -824,6 +903,7 @@ def _migrate_add_model_endpoint_refresh_columns(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -837,9 +917,13 @@ def _migrate_add_model_endpoint_refresh_columns(): if columns and "model_refresh_timeout" not in columns: conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_refresh_timeout INTEGER") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"model_endpoints refresh-policy migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_task_run_model_column(): """Add model column to task_runs if it doesn't exist (records which model ran).""" @@ -847,6 +931,7 @@ def _migrate_add_task_run_model_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(task_runs)") @@ -855,9 +940,13 @@ def _migrate_add_task_run_model_column(): conn.execute("ALTER TABLE task_runs ADD COLUMN model TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'model' column to task_runs") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"task_runs model migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_supports_tools_column(): """Add supports_tools column to model_endpoints if it doesn't exist.""" @@ -865,6 +954,7 @@ def _migrate_add_supports_tools_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -873,9 +963,13 @@ def _migrate_add_supports_tools_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN supports_tools BOOLEAN") conn.commit() logging.getLogger(__name__).info("Migrated: added 'supports_tools' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"supports_tools migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_cached_models_column(): @@ -884,6 +978,7 @@ def _migrate_add_cached_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -891,9 +986,13 @@ def _migrate_add_cached_models_column(): if columns and "cached_models" not in columns: conn.execute("ALTER TABLE model_endpoints ADD COLUMN cached_models TEXT") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"cached_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_pinned_models_column(): """Add pinned_models column to model_endpoints if it doesn't exist.""" @@ -901,6 +1000,7 @@ def _migrate_add_pinned_models_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(model_endpoints)") @@ -909,9 +1009,13 @@ def _migrate_add_pinned_models_column(): conn.execute("ALTER TABLE model_endpoints ADD COLUMN pinned_models TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'pinned_models' column to model_endpoints") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"pinned_models migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_notes_sort_order(): """Add sort_order, image_url, repeat columns to notes if they don't exist.""" @@ -919,6 +1023,7 @@ def _migrate_add_notes_sort_order(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(notes)") @@ -936,9 +1041,13 @@ def _migrate_add_notes_sort_order(): if columns and "agent_session_id" not in columns: conn.execute("ALTER TABLE notes ADD COLUMN agent_session_id TEXT") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"notes migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_mode_column(): """Add mode column to sessions table if it doesn't exist.""" @@ -946,6 +1055,7 @@ def _migrate_add_mode_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -954,9 +1064,13 @@ def _migrate_add_mode_column(): conn.execute("ALTER TABLE sessions ADD COLUMN mode TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'mode' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for mode failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_folder_column(): """Add folder column to sessions table if it doesn't exist.""" @@ -964,6 +1078,7 @@ def _migrate_add_folder_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -972,9 +1087,13 @@ def _migrate_add_folder_column(): conn.execute("ALTER TABLE sessions ADD COLUMN folder TEXT") conn.commit() logging.getLogger(__name__).info("Migrated: added 'folder' column to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for folder failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_token_columns(): """Add cumulative token tracking columns to sessions table.""" @@ -982,6 +1101,7 @@ def _migrate_add_token_columns(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(sessions)") @@ -991,9 +1111,13 @@ def _migrate_add_token_columns(): conn.execute("ALTER TABLE sessions ADD COLUMN total_output_tokens INTEGER DEFAULT 0") conn.commit() logging.getLogger(__name__).info("Migrated: added token tracking columns to sessions") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration check for token columns failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_owner_to_table(table_name: str, index_name: str): """Generic helper: add owner TEXT column + index to a table if missing.""" @@ -1001,6 +1125,7 @@ def _migrate_add_owner_to_table(table_name: str, index_name: str): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute(f"PRAGMA table_info({table_name})") @@ -1010,9 +1135,13 @@ def _migrate_add_owner_to_table(table_name: str, index_name: str): conn.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name}(owner)") conn.commit() logging.getLogger(__name__).info(f"Migrated: added 'owner' column to {table_name}") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"Migration owner column for {table_name} failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_multiuser_owner_columns(): """Add owner column to memories, gallery_images, user_tools, comparisons.""" @@ -1037,6 +1166,7 @@ def _migrate_add_api_token_scopes_column(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) columns = [row[1] for row in conn.execute("PRAGMA table_info(api_tokens)").fetchall()] @@ -1045,9 +1175,13 @@ def _migrate_add_api_token_scopes_column(): conn.execute("UPDATE api_tokens SET scopes = 'chat' WHERE scopes IS NULL OR scopes = ''") conn.commit() logging.getLogger(__name__).info("Migrated: added scopes column to api_tokens") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"api_tokens.scopes migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_assign_legacy_owner(): """Assign all null-owner data to the first (admin) user. @@ -1065,7 +1199,7 @@ def _migrate_assign_legacy_owner(): # fell through to "first user" every time. auth_path = os.path.join(os.path.dirname(DATABASE_URL.replace("sqlite:///", "")), "auth.json") if not os.path.isabs(auth_path): - auth_path = os.path.join("data", "auth.json") + auth_path = AUTH_FILE admin_user = None try: with open(auth_path, "r", encoding="utf-8") as f: @@ -1089,6 +1223,7 @@ def _migrate_assign_legacy_owner(): return logger = logging.getLogger(__name__) + conn = None try: conn = sqlite3.connect(db_path) # Every table with an `owner` column. New tables added later will be @@ -1113,12 +1248,16 @@ def _migrate_assign_legacy_owner(): except Exception as e: logger.warning(f"Legacy owner assignment for {table} failed: {e}") conn.commit() - conn.close() except Exception as e: logger.warning(f"Legacy owner migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass # Also migrate memory.json - mem_path = os.path.join("data", "memory.json") + mem_path = MEMORY_FILE try: if os.path.exists(mem_path): with open(mem_path, "r", encoding="utf-8") as f: @@ -1136,7 +1275,7 @@ def _migrate_assign_legacy_owner(): logger.warning(f"memory.json legacy migration failed: {e}") # Also migrate user_prefs.json to per-user format - prefs_path = os.path.join("data", "user_prefs.json") + prefs_path = USER_PREFS_FILE try: if os.path.exists(prefs_path): with open(prefs_path, "r", encoding="utf-8") as f: @@ -1530,7 +1669,7 @@ def _migrate_seed_email_account(): import json as _json import uuid as _uuid from pathlib import Path - settings_file = Path("data/settings.json") + settings_file = Path(SETTINGS_FILE) if not settings_file.exists(): return try: @@ -1598,6 +1737,7 @@ def init_db(): _migrate_add_model_type_column() _migrate_add_model_endpoint_refresh_columns() _migrate_add_model_endpoint_owner_column() + _migrate_add_provider_auth_id_column() _migrate_add_supports_tools_column() _migrate_add_task_run_model_column() _migrate_add_owner_column() @@ -1631,6 +1771,33 @@ def init_db(): _migrate_encrypt_email_passwords() _migrate_encrypt_signatures() _migrate_encrypt_endpoint_keys() + _migrate_backfill_task_folders() + + +def _migrate_backfill_task_folders(): + """Backfill folder='Tasks' on pre-existing task/research sessions. + + Sessions created by the task scheduler (LLM tasks, action tasks, research + runs) now set folder='Tasks' at creation time. This migration tags any + older sessions that predate that assignment. Idempotent — only touches + rows where folder is NULL or empty and the title matches known prefixes. + """ + try: + with engine.connect() as conn: + cols = [r[1] for r in conn.execute(text("PRAGMA table_info(sessions)"))] + if "folder" not in cols: + return + res = conn.execute(text( + "UPDATE sessions SET folder = 'Tasks' " + "WHERE (folder IS NULL OR folder = '') " + "AND (name LIKE '[Task] %' OR name LIKE '[Research] %')" + )) + conn.commit() + if res.rowcount: + logging.getLogger(__name__).info( + f"Backfilled folder='Tasks' on {res.rowcount} task/research sessions") + except Exception as e: + logging.getLogger(__name__).warning(f"task folder backfill: {e}") def _migrate_chat_messages_fts(): @@ -1706,6 +1873,7 @@ def _migrate_add_email_smtp_security(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(email_accounts)") @@ -1721,9 +1889,13 @@ def _migrate_add_email_smtp_security(): ) conn.commit() logging.getLogger(__name__).info("Migrated: added smtp_security column to email_accounts") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"smtp_security migration skipped: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_encrypt_endpoint_keys(): @@ -1824,6 +1996,7 @@ def _migrate_add_calendar_is_utc(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1832,9 +2005,13 @@ def _migrate_add_calendar_is_utc(): conn.execute("ALTER TABLE calendar_events ADD COLUMN is_utc BOOLEAN DEFAULT 0 NOT NULL") conn.commit() logging.getLogger(__name__).info("Migrated: added 'is_utc' column to calendar_events") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"is_utc migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_origin(): @@ -1845,6 +2022,7 @@ def _migrate_add_calendar_origin(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1854,9 +2032,13 @@ def _migrate_add_calendar_origin(): conn.execute("CREATE INDEX IF NOT EXISTS ix_calendar_events_origin ON calendar_events(origin)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'origin' column to calendar_events") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_account_id(): @@ -1866,6 +2048,7 @@ def _migrate_add_calendar_account_id(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendars)") @@ -1875,9 +2058,13 @@ def _migrate_add_calendar_account_id(): conn.execute("CREATE INDEX IF NOT EXISTS ix_calendars_account_id ON calendars(account_id)") conn.commit() logging.getLogger(__name__).info("Migrated: added 'account_id' column to calendars") - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendars.account_id migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def _migrate_add_calendar_metadata(): @@ -1886,6 +2073,7 @@ def _migrate_add_calendar_metadata(): db_path = DATABASE_URL.replace("sqlite:///", "") if not os.path.exists(db_path): return + conn = None try: conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA table_info(calendar_events)") @@ -1897,9 +2085,13 @@ def _migrate_add_calendar_metadata(): if columns and "last_pinged" not in columns: conn.execute("ALTER TABLE calendar_events ADD COLUMN last_pinged DATETIME") conn.commit() - conn.close() except Exception as e: logging.getLogger(__name__).warning(f"calendar_events migration failed: {e}") + finally: + try: + conn.close() + except Exception: + pass def get_db(): """ diff --git a/core/middleware.py b/core/middleware.py index 82d1d0324..550ee3bd7 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -17,6 +17,15 @@ INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token" +def is_cors_preflight(method: str, headers) -> bool: + """True for a genuine CORS preflight: an OPTIONS request carrying the + Access-Control-Request-Method header. Such requests are credential-less by + design and must reach CORSMiddleware to be answered -- gating them on auth + 401s the preflight and breaks every cross-origin browser/WebView client. + Pure so it can be unit-tested without standing up the app.""" + return method == "OPTIONS" and "access-control-request-method" in headers + + def require_admin(request: Request): """Raise 403 if the current user isn't an admin. Allows access when auth is explicitly disabled, or when the request carries @@ -58,11 +67,22 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): # Tool render endpoints are served inside iframes — allow framing by self is_tool_render = path.startswith("/api/tools/") and path.endswith("/render") + # PDF previews are embedded by the in-app document library. Keep the + # exception route-scoped so normal app pages remain unframeable. + is_document_pdf_preview = path.startswith("/api/document/") and path.endswith("/render-pdf") # Visual report pages are self-contained HTML — need inline scripts + external images is_report = path.startswith("/api/research/report/") response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=()" + + is_https = ( + request.url.scheme == "https" + or request.headers.get("X-Forwarded-Proto") == "https" + ) + if is_https: + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" if is_report: response.headers["Content-Security-Policy"] = ( @@ -79,6 +99,12 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): # sandbox="allow-scripts" attribute provides isolation. # Don't overwrite the route's own restrictive CSP either. pass + elif is_document_pdf_preview: + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Content-Security-Policy"] = ( + "default-src 'none'; " + "frame-ancestors 'self'" + ) else: response.headers["X-Frame-Options"] = "DENY" # NOTE: `style-src 'unsafe-inline'` is intentionally retained. diff --git a/core/models.py b/core/models.py index 1adae65ed..56f05dc4e 100644 --- a/core/models.py +++ b/core/models.py @@ -11,14 +11,24 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING if TYPE_CHECKING: from .session_manager import SessionManager -# Module-level session manager reference (set at app startup) -_session_manager: Optional["SessionManager"] = None +# Module-level session manager singleton (single source of truth) +_SESSION_MANAGER_INSTANCE: Optional["SessionManager"] = None -def set_session_manager(manager: "SessionManager"): - """Set the global session manager reference.""" - global _session_manager - _session_manager = manager +def set_session_manager_instance(manager: "SessionManager"): + """Set the global SessionManager singleton.""" + global _SESSION_MANAGER_INSTANCE + _SESSION_MANAGER_INSTANCE = manager + + +def get_session_manager_instance() -> Optional["SessionManager"]: + """Get the global SessionManager singleton.""" + return _SESSION_MANAGER_INSTANCE + + +# Keep legacy name for backward compatibility +set_session_manager = set_session_manager_instance +get_session_manager = get_session_manager_instance @dataclass @@ -42,7 +52,17 @@ class ChatMessage: @dataclass class Session: - """A chat session — pure data container.""" + """A chat session — pure data container. + + ``.history`` is the authoritative mutable message list. Callers may + read, append, pop, or reassign it directly — these changes take + effect immediately. ``_history`` remains a compatibility alias that + always resolves to the authoritative ``history`` list. + + Each session gets its own unique history list at construction time + (the dataclass default is never shared between instances). + """ + id: str name: str endpoint_url: str @@ -56,24 +76,35 @@ class Session: message_count: int = 0 def __post_init__(self): - if self.history is None: - self.history = [] if self.headers is None: self.headers = {} + # Ensure each session gets its OWN list (not the shared dataclass default) + if self.history is None: + self.history = [] + + @property + def _history(self) -> List[ChatMessage]: + """Compatibility alias for callers that still reference ``_history``.""" + return self.history + + @_history.setter + def _history(self, messages: List[ChatMessage]): + self.history = messages def add_message(self, message: ChatMessage): """ Add a message to this session. - Delegates to SessionManager for persistence if available, - otherwise just appends to history. + Appends to the authoritative history list and increments + message_count. Delegates to SessionManager for persistence + if available. """ self.history.append(message) self.message_count = len(self.history) # Delegate to session manager for persistence - if _session_manager: - _session_manager._persist_message(self.id, message) + if _SESSION_MANAGER_INSTANCE: + _SESSION_MANAGER_INSTANCE._persist_message(self.id, message) def get_context_messages(self) -> List[Dict[str, Any]]: """Get messages in format for LLM API. @@ -94,3 +125,7 @@ class Session: def get(self, key: str, default=None): """Dict-like access for compatibility.""" return getattr(self, key, default) + + def __getitem__(self, key: str): + """Allow session['field'] syntax.""" + return getattr(self, key) diff --git a/core/platform_compat.py b/core/platform_compat.py index f2160d9f2..b3b157111 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -18,10 +18,22 @@ import ntpath import shutil import subprocess from pathlib import Path +import sys from typing import List, Optional +import platform IS_WINDOWS = os.name == "nt" IS_POSIX = not IS_WINDOWS +# Allows APFEL support and ARM-native binary recommendations on Apple Silicon Macs. +IS_APPLE_SILICON = ( + IS_POSIX + and platform.system() == "Darwin" + and platform.machine().lower() + in { + "arm64", + "aarch64", + } +) # ── File permissions ──────────────────────────────────────────────────────── @@ -53,9 +65,8 @@ def detached_popen_kwargs() -> dict: and is detached from any console. """ if IS_WINDOWS: - flags = ( - getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) - | getattr(subprocess, "DETACHED_PROCESS", 0x00000008) + flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr( + subprocess, "DETACHED_PROCESS", 0x00000008 ) return {"creationflags": flags} return {"start_new_session": True} @@ -150,6 +161,29 @@ _WINDOWS_BASH_RELATIVE_PATHS = ( ("usr", "bin", "bash.exe"), ) +# Paths to add to the remote SSH probe command to find tools like nvidia-smi that may not be on PATH. +_SSH_PATH_MEMBERS = ( + "/usr/bin", + "/usr/local/bin", + "/usr/local/cuda/bin", + "/usr/lib/wsl/lib" +) +# Fallback locations for nvidia-smi on WSL and other Linux distros where it may not be on PATH. +NVIDIA_PATH_CANDIDATES = ( + "/usr/bin/nvidia-smi", + "/usr/local/bin/nvidia-smi", + "/usr/local/cuda/bin/nvidia-smi", + "/usr/lib/wsl/lib/nvidia-smi", +) + + +def _ssh_path_override() -> str: + """Build the PATH export snippet used for remote SSH shell probes.""" + return f"export PATH=\"$PATH:{':'.join(_SSH_PATH_MEMBERS)}\"; " + + +SSH_PATH_OVERRIDE = _ssh_path_override() + def _windows_bash_fallbacks() -> List[str]: roots: List[str] = [] @@ -257,3 +291,160 @@ def run_script_argv(script_path) -> List[str]: comspec = os.environ.get("ComSpec", "cmd.exe") return [comspec, "/c", str(script_path)] return ["sh", str(script_path)] + + +def is_wsl() -> bool: + """True if running inside Windows Subsystem for Linux (WSL).""" + import sys + if sys.platform.startswith("linux") or os.name == "posix": + try: + with open("/proc/version", "r") as f: + if "microsoft" in f.read().lower(): + return True + except Exception: + pass + return False + + +def translate_path(path_str: str) -> str: + """Translate a path (possibly a Windows path) to the current OS format. + + Particularly handles Windows paths (e.g. C:\\foo or C:/foo) when running + under WSL, translating them to /mnt/c/foo. + Also handles standard path normalization to avoid string breakages. + """ + if not path_str: + return path_str + + if is_wsl(): + path_str = path_str.replace("\\", "/") + import re + m = re.match(r"^([a-zA-Z]):(.*)", path_str) + if m: + drive = m.group(1).lower() + rest = m.group(2) + if not rest.startswith("/"): + rest = "/" + rest + return f"/mnt/{drive}{rest}" + + try: + return str(Path(path_str).resolve()) + except Exception: + return path_str + + +def get_wsl_windows_user_profile() -> Optional[str]: + """Retrieve the Windows host User Profile path from inside WSL.""" + if not is_wsl(): + return None + try: + r = run_wsl_windows_powershell("Write-Output $env:USERPROFILE", timeout=5) + if r.returncode == 0 and r.stdout.strip(): + return translate_path(r.stdout.strip()) + except Exception: + pass + + try: + users_dir = "/mnt/c/Users" + if os.path.isdir(users_dir): + for entry in os.listdir(users_dir): + if entry not in ("All Users", "Default", "Default User", "desktop.ini", "Public"): + path = os.path.join(users_dir, entry) + if os.path.isdir(path): + return path + except Exception: + pass + return None + + +def _ssh_exec_argv( + remote: str, + ssh_port: str | None, + *, + remote_cmd: str | None = None, + connect_timeout: int | None = None, + strict_host_key_checking: bool | None = None, +) -> list[str]: + """Build a consistent ssh argv for remote command execution.""" + remote_value = str(remote or "").strip() + remote_host = remote_value.rsplit("@", 1)[-1] + if not remote_value or remote_value.startswith("-") or not remote_host or remote_host.startswith("-"): + raise ValueError("Invalid SSH remote host") + argv = ["ssh"] + if connect_timeout is not None: + argv.extend(["-o", f"ConnectTimeout={int(connect_timeout)}"]) + if strict_host_key_checking is not None: + argv.extend( + [ + "-o", + "StrictHostKeyChecking=yes" + if strict_host_key_checking + else "StrictHostKeyChecking=no", + ] + ) + if ssh_port and ssh_port != "22": + argv.extend(["-p", str(ssh_port)]) + argv.append(remote) + if remote_cmd is not None: + argv.append(remote_cmd) + return argv + + +def run_ssh_command( + remote: str, + ssh_port: str | None, + remote_cmd: str, + *, + timeout: float, + connect_timeout: int | None = None, + strict_host_key_checking: bool | None = None, + text: bool = True, +) -> subprocess.CompletedProcess: + """Run an ssh command with centralized timeout and stderr/stdout capture.""" + return subprocess.run( + _ssh_exec_argv( + remote, + ssh_port, + remote_cmd=remote_cmd, + connect_timeout=connect_timeout, + strict_host_key_checking=strict_host_key_checking, + ), + timeout=timeout, + capture_output=True, + text=text, + ) + + +def _windows_powershell_argv( + command: str, + *, + no_profile: bool = True, + non_interactive: bool = True, +) -> List[str]: + argv: List[str] = ["powershell.exe"] + if no_profile: + argv.append("-NoProfile") + if non_interactive: + argv.append("-NonInteractive") + argv.extend(["-Command", command]) + return argv + + +def run_wsl_windows_powershell( + command: str, + *, + timeout: float = 5, +) -> subprocess.CompletedProcess[str]: + """Run a PowerShell command on the Windows host from WSL. + + Raises ``RuntimeError`` when called outside WSL. + """ + + if not is_wsl(): + raise RuntimeError("run_wsl_windows_powershell is only supported in WSL") + return subprocess.run( + _windows_powershell_argv(command), + capture_output=True, + text=True, + timeout=timeout, + ) diff --git a/core/session_manager.py b/core/session_manager.py index ecc23e088..914205a7d 100644 --- a/core/session_manager.py +++ b/core/session_manager.py @@ -17,6 +17,9 @@ from typing import Dict, Optional from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive from .models import Session, ChatMessage +# Re-export singleton accessors from models for convenience +from .models import set_session_manager_instance, get_session_manager_instance + logger = logging.getLogger(__name__) @@ -188,12 +191,17 @@ class SessionManager: """ Add a message to a session and persist to database. + Updates the authoritative history list and persists through this + manager directly so tests and temporary managers do not depend on the + process-wide session-manager singleton. + Args: session_id: Session ID message: ChatMessage to add """ session = self.get_session(session_id) session.history.append(message) + session._history = session.history session.message_count = len(session.history) self._persist_message(session_id, message) @@ -232,7 +240,10 @@ class SessionManager: ) db.add(db_message) - db_session.message_count = len(self.sessions.get(session_id, {}).history) if session_id in self.sessions else 0 + if session_id in self.sessions: + db_session.message_count = len(self.sessions[session_id].history) + else: + db_session.message_count = 0 _now = datetime.now(timezone.utc) db_session.last_accessed = _now # Clean "last conversation" timestamp — only bumped here on a @@ -283,6 +294,7 @@ class SessionManager: # Update in-memory session.history = session.history[:keep_count] + session._history = session.history logger.info(f"Truncated session {session_id} to {keep_count} messages") return True @@ -333,6 +345,7 @@ class SessionManager: db.commit() session.history = list(messages) + session._history = session.history session.message_count = len(messages) logger.info("Replaced session %s history with %d messages", session_id, len(messages)) return True @@ -608,24 +621,52 @@ class SessionManager: def save_sessions(self): """No-op for DB compatibility.""" + def ensure_task_session(self, session_id: str, name: str, endpoint_url: str, model: str, owner: str = None, task: object = None) -> Session: + """Create a task session if it doesn't exist, or return the existing one. + + Unlike create_session, this checks the cache first and does NOT + overwrite an existing in-memory session. The task scheduler must + use this instead of direct dict assignment. + """ + if session_id in self.sessions: + return self.sessions[session_id] + + session = self.create_session(session_id, name, endpoint_url, model, owner=owner) + if task is not None: + task.session_id = session_id + return session + # ------------------------------------------------------------------ # Cleanup # ------------------------------------------------------------------ - def cleanup_empty_sessions(self, auto_archive_days: int = 30) -> dict: - """Clean up empty and old sessions.""" + def cleanup_empty_sessions(self, auto_archive_days: int = 30, min_age_hours: int = 1) -> dict: + """Clean up empty and old sessions. + + Args: + auto_archive_days: Age in days before non-important sessions are archived. + min_age_hours: Minimum age in hours before an empty session can be deleted. + Prevents deleting sessions that were just created. + """ db = SessionLocal() stats = {'deleted_empty': 0, 'archived_old': 0, 'total_checked': 0} try: all_sessions = db.query(DbSession).all() cutoff_date = utcnow_naive() - timedelta(days=auto_archive_days) + min_age = utcnow_naive() - timedelta(hours=min_age_hours) for db_session in all_sessions: stats['total_checked'] += 1 - # Delete empty sessions + # Delete empty sessions only if older than min_age_hours if db_session.message_count == 0: + if db_session.created_at is not None: + created = db_session.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + if created > min_age: + continue # Too young to delete if db_session.id in self.sessions: del self.sessions[db_session.id] db.delete(db_session) diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 6d87cb6e3..b95dde1bf 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -59,6 +59,7 @@ services: - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} + - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index f61d22a4b..fa50896ba 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -58,6 +58,7 @@ services: - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} + - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.yml b/docker-compose.yml index b5b3fd93d..9841b1dca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,7 @@ services: - ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1} - ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1} - ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost} + - ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docs/email-outlook.md b/docs/email-outlook.md new file mode 100644 index 000000000..1f8b97d5d --- /dev/null +++ b/docs/email-outlook.md @@ -0,0 +1,17 @@ +# Outlook / Office 365 email accounts + +Odysseus email accounts currently use IMAP and SMTP with username/password +authentication. That works for providers that still allow app passwords or +mailbox passwords for IMAP/SMTP. + +Microsoft disables basic authentication for Outlook and Microsoft 365 in most +modern accounts and tenants. If you try to add an Outlook account with a normal +password, Microsoft may return errors such as: + +- `IMAP: AUTHENTICATE failed` +- `SMTP: 535 5.7.139 Authentication unsuccessful, basic authentication is disabled` + +This is expected. Odysseus does not support Microsoft OAuth or Graph Mail yet, +so Outlook / Office 365 accounts cannot currently be added through the password +form. Use another email provider with app-password support, or track the future +Microsoft Graph OAuth integration. diff --git a/mcp_servers/_common.py b/mcp_servers/_common.py deleted file mode 100644 index 341bfe64e..000000000 --- a/mcp_servers/_common.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -_common.py - -Shared constants and helpers for built-in MCP servers. -""" - -MAX_OUTPUT_CHARS = 10_000 -MAX_READ_CHARS = 20_000 -SHELL_TIMEOUT = 60 -PYTHON_TIMEOUT = 30 -SEARCH_TIMEOUT = 30 - - -def truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str: - """Truncate text to *limit* characters with a suffix note.""" - if not isinstance(text, str): - # Tool output is occasionally None or a non-string; len(None) would - # raise. Coerce so this shared helper never crashes a tool response. - text = "" if text is None else str(text) - if len(text) > limit: - return text[:limit] + f"\n... (truncated, {len(text)} chars total)" - return text diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py index ba75dd026..b807937cd 100644 --- a/mcp_servers/email_server.py +++ b/mcp_servers/email_server.py @@ -22,6 +22,7 @@ import os import os.path from pathlib import Path from datetime import datetime, timedelta +import uuid from mcp.server import Server from mcp.server.stdio import stdio_server @@ -31,7 +32,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) server = Server("email") EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20")) -DATA_DIR = Path(__file__).resolve().parent.parent / "data" +from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR +DATA_DIR = Path(_DATA_DIR) def _b(value) -> bytes: @@ -63,7 +65,60 @@ def _clean_header_value(value) -> str: def _db_path() -> Path: - return DATA_DIR / "app.db" + return Path(APP_DB) + + +def _load_email_writing_style() -> str: + """Return the existing Settings > Email > Writing Style value.""" + try: + settings_path = DATA_DIR / "settings.json" + if not settings_path.exists(): + return "" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + return str(settings.get("email_writing_style") or "").strip() + except Exception: + return "" + + +def _writing_style_guidance() -> str: + style = _load_email_writing_style() + if not style: + return ( + "No saved writing style is configured in Settings > Email > Writing Style. " + "Use a concise, natural tone and do not invent facts." + ) + return ( + "Use this saved writing style from Settings > Email > Writing Style when " + "drafting the body. It overrides generic tone guidance:\n" + f"{style}" + ) + + +def _default_document_owner() -> str | None: + """Best-effort owner for MCP-created documents. + + MCP stdio tools do not receive the browser request's authenticated user, + but the document library is owner-filtered. Stamp drafts to the configured + single/default admin so assistant-created email drafts are visible. + """ + owner = os.environ.get("ODYSSEUS_DOCUMENT_OWNER", "").strip() + if owner: + return owner + try: + auth_path = DATA_DIR / "auth.json" + if not auth_path.exists(): + return None + users = (json.loads(auth_path.read_text(encoding="utf-8")).get("users") or {}) + if not isinstance(users, dict) or not users: + return None + admins = [name for name, data in users.items() if isinstance(data, dict) and data.get("is_admin")] + if len(admins) == 1: + return admins[0] + if len(users) == 1: + return next(iter(users)) + return admins[0] if admins else next(iter(users)) + except Exception: + return None def _list_accounts_raw() -> list: @@ -162,7 +217,7 @@ def _load_config(account: str | None = None) -> dict: "trash_folder": os.environ.get("TRASH_FOLDER", "Trash"), "cache_db": os.environ.get( "EMAIL_CACHE_DB", - str(DATA_DIR / "email_cache.db"), + EMAIL_CACHE_DB, ), "account_id": None, "account_name": None, @@ -204,7 +259,7 @@ def _load_config(account: str | None = None) -> dict: else: # Legacy fallback: settings.json flat keys try: - settings_path = Path(__file__).resolve().parent.parent / "data" / "settings.json" + settings_path = Path(_SETTINGS_FILE) if settings_path.exists(): settings = json.loads(settings_path.read_text(encoding="utf-8")) for key in ( @@ -244,10 +299,27 @@ def _imap_connect(account: str | None = None): timeout=EMAIL_SOCKET_TIMEOUT, ) if cfg["imap_starttls"]: - conn.starttls() + try: + conn.starttls() + except Exception: + # Don't leak the open plain socket on a rejected STARTTLS. (#3174) + try: + conn.shutdown() + except Exception: + pass + raise if getattr(conn, "sock", None): conn.sock.settimeout(EMAIL_SOCKET_TIMEOUT) - conn.login(cfg["imap_user"], cfg["imap_password"]) + try: + conn.login(cfg["imap_user"], cfg["imap_password"]) + except Exception: + # A failed login otherwise orphans the connected socket; close it + # before propagating (shutdown() is the pre-auth low-level close). (#3174) + try: + conn.shutdown() + except Exception: + pass + raise return conn @@ -423,68 +495,71 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False, Pass unread_only=True and/or unresponded_only=True for attention scans. account selects mailbox (None = default). """ - conn = _imap_connect(account) - select_status, _ = conn.select(_q(folder), readonly=True) - if select_status != "OK": - conn.logout() - raise ValueError(f"IMAP folder not found: {folder}") + conn = None + try: + conn = _imap_connect(account) + select_status, _ = conn.select(_q(folder), readonly=True) + if select_status != "OK": + raise ValueError(f"IMAP folder not found: {folder}") - if unread_only and unresponded_only: - status, data = conn.uid("SEARCH", None, "(UNSEEN UNANSWERED)") - elif unread_only: - status, data = conn.uid("SEARCH", None, "(UNSEEN)") - elif unresponded_only: - # Was missing — unresponded_only=True (without unread_only) fell through - # to "ALL" and returned answered mail too, despite the documented - # "emails without replies" behaviour. - status, data = conn.uid("SEARCH", None, "(UNANSWERED)") - else: - # Include read too — IMAP search "ALL" returns the entire folder - status, data = conn.uid("SEARCH", None, "ALL") + if unread_only and unresponded_only: + status, data = conn.uid("SEARCH", None, "(UNSEEN UNANSWERED)") + elif unread_only: + status, data = conn.uid("SEARCH", None, "(UNSEEN)") + elif unresponded_only: + # Was missing — unresponded_only=True (without unread_only) fell through + # to "ALL" and returned answered mail too, despite the documented + # "emails without replies" behaviour. + status, data = conn.uid("SEARCH", None, "(UNANSWERED)") + else: + # Include read too — IMAP search "ALL" returns the entire folder + status, data = conn.uid("SEARCH", None, "ALL") - if status != "OK" or not data[0]: - conn.logout() - return [] + if status != "OK" or not data[0]: + return [] - uid_list = list(reversed(data[0].split()))[:max_results] - cache = _get_cached_summaries() - results = [] + uid_list = list(reversed(data[0].split()))[:max_results] + cache = _get_cached_summaries() + results = [] - for uid in uid_list: - try: - status, msg_data = conn.uid("FETCH", uid, "(RFC822.HEADER)") - if status != "OK": + for uid in uid_list: + try: + status, msg_data = conn.uid("FETCH", uid, "(RFC822.HEADER)") + if status != "OK": + continue + raw_header = msg_data[0][1] + msg = email.message_from_bytes(raw_header) + + subject = _decode_header(msg.get("Subject", "(no subject)")) + sender = _decode_header(msg.get("From", "unknown")) + date_str = msg.get("Date", "") + message_id = msg.get("Message-ID", "") + + # Parse sender name + sender_name, sender_addr = email.utils.parseaddr(sender) + sender_display = sender_name or sender_addr + + # Check cache for summary + cached = cache.get(subject, {}) + summary = cached.get("summary", "") + + results.append({ + "uid": uid.decode(), + "message_id": message_id, + "subject": subject, + "from": sender_display, + "from_address": sender_addr, + "date": date_str, + "summary": summary, + }) + except Exception: continue - raw_header = msg_data[0][1] - msg = email.message_from_bytes(raw_header) - subject = _decode_header(msg.get("Subject", "(no subject)")) - sender = _decode_header(msg.get("From", "unknown")) - date_str = msg.get("Date", "") - message_id = msg.get("Message-ID", "") - - # Parse sender name - sender_name, sender_addr = email.utils.parseaddr(sender) - sender_display = sender_name or sender_addr - - # Check cache for summary - cached = cache.get(subject, {}) - summary = cached.get("summary", "") - - results.append({ - "uid": uid.decode(), - "message_id": message_id, - "subject": subject, - "from": sender_display, - "from_address": sender_addr, - "date": date_str, - "summary": summary, - }) - except Exception: - continue - - conn.logout() - return results + return results + finally: + if conn: + try: conn.logout() + except Exception: pass def _result_sort_time(result: dict) -> datetime: @@ -657,54 +732,55 @@ def _extract_attachment_to_disk(msg, index, target_dir): def _read_email(uid=None, message_id=None, folder="INBOX", account=None): """Read full email content by UID or message-ID. account = mailbox selector.""" cfg = _load_config(account) - conn = _imap_connect(account) - conn.select(_q(folder), readonly=True) + conn = None + try: + conn = _imap_connect(account) + conn.select(_q(folder), readonly=True) - if message_id and not uid: - status, data = conn.uid("SEARCH", None, f'(HEADER Message-ID "{message_id}")') - if status != "OK" or not data[0]: - conn.logout() - return {"error": f"Email not found with Message-ID: {message_id}"} - uid = data[0].split()[-1] + if message_id and not uid: + status, data = conn.uid("SEARCH", None, f'(HEADER Message-ID "{message_id}")') + if status != "OK" or not data[0]: + return {"error": f"Email not found with Message-ID: {message_id}"} + uid = data[0].split()[-1] - if not uid: - conn.logout() - return {"error": "No UID or Message-ID provided"} + if not uid: + return {"error": "No UID or Message-ID provided"} - status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") - if status != "OK": - conn.logout() - return {"error": f"Failed to fetch email UID {uid}"} - if not msg_data or not msg_data[0] or not isinstance(msg_data[0], tuple) or len(msg_data[0]) < 2: - conn.logout() - return {"error": f"Email not found with UID {uid}"} + status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") + if status != "OK": + return {"error": f"Failed to fetch email UID {uid}"} + if not msg_data or not msg_data[0] or not isinstance(msg_data[0], tuple) or len(msg_data[0]) < 2: + return {"error": f"Email not found with UID {uid}"} - raw = msg_data[0][1] - msg = email.message_from_bytes(raw) + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) - subject = _decode_header(msg.get("Subject", "(no subject)")) - sender = _decode_header(msg.get("From", "unknown")) - date_str = msg.get("Date", "") - message_id_header = msg.get("Message-ID", "") - body = _extract_text(msg) - attachments = _list_attachments_from_msg(msg) + subject = _decode_header(msg.get("Subject", "(no subject)")) + sender = _decode_header(msg.get("From", "unknown")) + date_str = msg.get("Date", "") + message_id_header = msg.get("Message-ID", "") + body = _extract_text(msg) + attachments = _list_attachments_from_msg(msg) - sender_name, sender_addr = email.utils.parseaddr(sender) + sender_name, sender_addr = email.utils.parseaddr(sender) - conn.logout() - return { - "uid": uid.decode() if isinstance(uid, bytes) else str(uid), - "account": cfg.get("account_name") or cfg.get("imap_user") or "default", - "account_email": cfg.get("imap_user") or cfg.get("from_address") or "", - "account_id": cfg.get("account_id"), - "message_id": message_id_header, - "subject": subject, - "from": sender_name or sender_addr, - "from_address": sender_addr, - "date": date_str, - "body": body[:8000], - "attachments": attachments, - } + return { + "uid": uid.decode() if isinstance(uid, bytes) else str(uid), + "account": cfg.get("account_name") or cfg.get("imap_user") or "default", + "account_email": cfg.get("imap_user") or cfg.get("from_address") or "", + "account_id": cfg.get("account_id"), + "message_id": message_id_header, + "subject": subject, + "from": sender_name or sender_addr, + "from_address": sender_addr, + "date": date_str, + "body": body[:8000], + "attachments": attachments, + } + finally: + if conn: + try: conn.logout() + except Exception: pass def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"): @@ -773,7 +849,16 @@ def _smtp_connect(account=None, cfg=None): port, timeout=EMAIL_SOCKET_TIMEOUT, ) - conn.starttls() + try: + conn.starttls() + except Exception: + # Don't leak the open plain socket on a rejected STARTTLS. SMTP has + # no shutdown(); close() is the low-level socket close (no QUIT). (#3174) + try: + conn.close() + except Exception: + pass + raise elif security == "ssl": conn = smtplib.SMTP_SSL( cfg["smtp_host"], @@ -787,7 +872,16 @@ def _smtp_connect(account=None, cfg=None): timeout=EMAIL_SOCKET_TIMEOUT, ) if cfg["smtp_user"] and cfg["smtp_password"]: - conn.login(cfg["smtp_user"], cfg["smtp_password"]) + try: + conn.login(cfg["smtp_user"], cfg["smtp_password"]) + except Exception: + # A failed login otherwise orphans the connected socket; close it + # before propagating (SMTP has no shutdown(); close() = socket close). (#3174) + try: + conn.close() + except Exception: + pass + raise return conn @@ -856,8 +950,185 @@ def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, b } -def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None): - """Reply to an existing email by UID. Threads via In-Reply-To/References.""" +def _build_email_document_content( + to, + subject, + body, + *, + cc=None, + bcc=None, + in_reply_to=None, + references=None, + source_uid=None, + source_folder=None, +): + header_lines = [f"To: {to or ''}"] + if cc: + header_lines.append(f"Cc: {cc}") + if bcc: + header_lines.append(f"Bcc: {bcc}") + header_lines.append(f"Subject: {subject or ''}") + if in_reply_to: + header_lines.append(f"In-Reply-To: {in_reply_to}") + if references: + header_lines.append(f"References: {references}") + if source_uid: + header_lines.append(f"X-Source-UID: {source_uid}") + if source_folder: + header_lines.append(f"X-Source-Folder: {source_folder}") + return "\n".join(header_lines) + "\n---\n" + (body or "") + + +def _merge_email_reply_body(existing_content: str, reply_body: str) -> str: + """Preserve email headers and quoted chain while replacing the editable reply body.""" + if "\n---\n" not in (existing_content or ""): + return reply_body or "" + head, body = existing_content.split("\n---\n", 1) + quote_markers = ( + "---------- Previous message ----------", + "-----Original Message-----", + "----- Original Message -----", + ) + quote_index = -1 + for marker in quote_markers: + idx = body.find(marker) + if idx != -1 and (quote_index == -1 or idx < quote_index): + quote_index = idx + quote = body[quote_index:].strip() if quote_index != -1 else "" + merged_body = (reply_body or "").strip() + if quote: + merged_body = f"{merged_body}\n\n{quote}" if merged_body else quote + return f"{head}\n---\n{merged_body}" + + +def _create_email_draft_document( + *, + to, + subject, + body, + title=None, + cc=None, + bcc=None, + in_reply_to=None, + references=None, + source_uid=None, + source_folder=None, + account=None, + source_message_id=None, +): + """Create an Odysseus email compose document for user review. Does not send.""" + from core.database import SessionLocal, Document, DocumentVersion + try: + from src.event_bus import fire_event + except Exception: + fire_event = None + + cfg = _load_config(account) if account else _load_config(None) + content = _build_email_document_content( + to, + subject, + body, + cc=cc, + bcc=bcc, + in_reply_to=in_reply_to, + references=references, + source_uid=source_uid, + source_folder=source_folder, + ) + doc_id = str(uuid.uuid4()) + ver_id = str(uuid.uuid4()) + doc_title = (title or subject or "Email draft").strip() or "Email draft" + doc_owner = _default_document_owner() + + db = SessionLocal() + try: + if source_uid and source_folder: + existing = ( + db.query(Document) + .filter(Document.is_active == True) + .filter(Document.language == "email") + .filter(Document.owner == doc_owner) + .filter(Document.source_email_uid == str(source_uid)) + .filter(Document.source_email_folder == source_folder) + .order_by(Document.updated_at.desc()) + .first() + ) + if existing and "\n---\n" in (existing.current_content or ""): + existing.current_content = _merge_email_reply_body(existing.current_content, body or "") + existing.version_count = (existing.version_count or 0) + 1 + ver = DocumentVersion( + id=ver_id, + document_id=existing.id, + version_number=existing.version_count, + content=existing.current_content, + summary="Updated by email MCP draft tool", + source="ai", + ) + db.add(ver) + db.commit() + if fire_event: + try: + fire_event("document_updated", doc_owner) + except Exception: + pass + return { + "draft": True, + "updated": True, + "doc_id": existing.id, + "title": existing.title, + "language": existing.language, + "account": cfg.get("account_name"), + "account_id": cfg.get("account_id"), + "to": to, + "subject": subject, + } + + doc = Document( + id=doc_id, + session_id=None, + title=doc_title, + language="email", + current_content=content, + version_count=1, + is_active=True, + owner=doc_owner, + source_email_uid=source_uid, + source_email_folder=source_folder, + source_email_account_id=cfg.get("account_id"), + source_email_message_id=source_message_id, + ) + ver = DocumentVersion( + id=ver_id, + document_id=doc_id, + version_number=1, + content=content, + summary="Created by email MCP draft tool", + source="ai", + ) + db.add(doc) + db.add(ver) + db.commit() + if fire_event: + try: + fire_event("document_created", doc_owner) + except Exception: + pass + return { + "draft": True, + "doc_id": doc_id, + "title": doc_title, + "language": "email", + "account": cfg.get("account_name"), + "account_id": cfg.get("account_id"), + "to": to, + "subject": subject, + } + finally: + db.close() + + +def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None): + """Create a threaded Odysseus reply draft document. Does not send.""" conn = _imap_connect(account) conn.select(_q(folder), readonly=True) status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") @@ -877,6 +1148,168 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None): _, sender_addr = email.utils.parseaddr(sender) to_addrs = sender_addr + cc = None + if reply_all: + cc_addrs = [] + cfg = _load_config(account) + own_addrs = { + (cfg.get("imap_user") or "").strip().lower(), + (cfg.get("from_address") or "").strip().lower(), + } + for header_name in ("To", "Cc"): + for _, addr in email.utils.getaddresses([orig.get(header_name, "")]): + addr_l = (addr or "").strip().lower() + if addr and addr != sender_addr and addr_l not in own_addrs: + cc_addrs.append(addr) + if cc_addrs: + cc = ", ".join(dict.fromkeys(cc_addrs)) + + return _create_email_draft_document( + to=to_addrs, + subject=reply_subject, + body=body, + title=title or reply_subject, + cc=cc, + in_reply_to=orig_message_id, + references=new_references, + source_uid=uid, + source_folder=folder, + account=account, + source_message_id=orig_message_id, + ) + + +async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account=None, title=None): + """Generate a reply with Odysseus' AI-reply prompt/style, then create a compose doc.""" + read_result = _read_email(uid=uid, folder=folder, account=account) + if "error" in read_result: + return read_result + + to_addr = read_result.get("from_address") or email.utils.parseaddr(read_result.get("from") or "")[1] + subject = read_result.get("subject") or "" + reply_subject = subject if subject.lower().startswith("re:") else f"Re: {subject}" + original_body = read_result.get("body") or "" + message_id = read_result.get("message_id") or "" + + if not original_body.strip(): + return {"error": "No email body available for AI reply"} + + try: + from routes.email_helpers import ( + _EMAIL_REPLY_SYS_PROMPT_BASE, + _apply_email_style_mechanics, + _extract_reply, + _load_settings, + ) + from src.endpoint_resolver import ( + resolve_endpoint, + resolve_utility_fallback_candidates, + resolve_chat_fallback_candidates, + ) + from src.llm_core import llm_call_async_with_fallback + except Exception as exc: + return {"error": f"AI reply helpers unavailable: {exc}"} + + settings = _load_settings() + style = settings.get("email_writing_style", "") + system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE + if style: + system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}" + + user_msg = ( + f"Recipient: {to_addr}\nSubject: {reply_subject}\n\n" + f"Original email and any current draft:\n{original_body[:6000]}\n\n" + "Draft a reply. Return only the reply body text." + ) + + candidates = [] + seen = set() + + def _add(url, model, headers): + key = (url or "", model or "") + if not url or not model or key in seen: + return + seen.add(key) + candidates.append((url, model, headers)) + + try: + _add(*resolve_endpoint("utility", owner=None)) + except Exception: + pass + try: + _add(*resolve_endpoint("default", owner=None)) + except Exception: + pass + try: + utility_fallbacks = resolve_utility_fallback_candidates(owner=None) or [] + except TypeError: + utility_fallbacks = resolve_utility_fallback_candidates() or [] + for cand in utility_fallbacks: + _add(*cand) + try: + chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or [] + except TypeError: + chat_fallbacks = resolve_chat_fallback_candidates() or [] + for cand in chat_fallbacks: + _add(*cand) + + if not candidates: + return {"error": "No LLM endpoint configured for AI reply"} + + try: + raw_reply = await llm_call_async_with_fallback( + candidates, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_msg}, + ], + temperature=0.7, + max_tokens=1024, + timeout=60, + ) + except Exception as exc: + return {"error": f"AI reply generation failed: {exc}"} + + reply = _apply_email_style_mechanics(_extract_reply(raw_reply or "")) + if not reply: + return {"error": "AI reply generation returned an empty response"} + + return _draft_reply_to_email( + uid=uid, + body=reply, + folder=folder, + reply_all=reply_all, + account=account, + title=title or reply_subject, + ) + + +def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None): + """Reply to an existing email by UID. Threads via In-Reply-To/References.""" + conn = None + try: + conn = _imap_connect(account) + conn.select(_q(folder), readonly=True) + status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") + finally: + if conn: + try: conn.logout() + except Exception: pass + if status != "OK" or not msg_data or not msg_data[0]: + return {"error": f"Failed to fetch email UID {uid}"} + raw = msg_data[0][1] + orig = email.message_from_bytes(raw) + + orig_subject = _decode_header(orig.get("Subject", "")) + reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}" + orig_message_id = orig.get("Message-ID", "") + orig_references = orig.get("References", "") + new_references = (orig_references + " " + orig_message_id).strip() if orig_references else orig_message_id + + sender = _decode_header(orig.get("From", "")) + _, sender_addr = email.utils.parseaddr(sender) + to_addrs = sender_addr + cc = None if reply_all: cc_addrs = [] @@ -1038,16 +1471,21 @@ def _archive_email(uid, folder="INBOX", account=None): def _download_attachment(uid, index, folder="INBOX", account=None): """Extract a specific attachment to disk and return its local path.""" - conn = _imap_connect(account) - conn.select(_q(folder), readonly=True) - status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") - conn.logout() + conn = None + try: + conn = _imap_connect(account) + conn.select(_q(folder), readonly=True) + status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])") + finally: + if conn: + try: conn.logout() + except Exception: pass if status != "OK": return {"error": f"Failed to fetch email UID {uid}"} raw = msg_data[0][1] msg = email.message_from_bytes(raw) - target_dir = DATA_DIR / "mail-attachments" / f"{folder}_{uid}" + target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}" filepath = _extract_attachment_to_disk(msg, index, target_dir) if not filepath: return {"error": f"Attachment index {index} not found"} @@ -1139,6 +1577,8 @@ async def list_tools() -> list[Tool]: name="send_email", description=( "Send a new email via SMTP. Provide recipient(s), subject, and body. " + "This sends immediately; for normal assistant-written email, prefer " + "draft_email so the user can review and send from Odysseus. " "For replying to an existing thread, use reply_to_email instead. " "Pass `account` to send from a non-default mailbox." ), @@ -1155,10 +1595,35 @@ async def list_tools() -> list[Tool]: "required": ["to", "subject", "body"], }, ), + Tool( + name="draft_email", + description=( + "Create a new Odysseus email compose draft document. This DOES NOT send. " + "Use this as the default way to write an email for the user: it opens " + "a reviewable email document with To/Cc/Bcc/Subject/body, and the user " + "can edit or press Send in Odysseus. " + f"{_writing_style_guidance()}" + ), + inputSchema={ + "type": "object", + "properties": { + "to": {"type": "string", "description": "Recipient email address(es), comma-separated"}, + "subject": {"type": "string", "description": "Email subject line"}, + "body": {"type": "string", "description": "Draft body"}, + "cc": {"type": "string", "description": "CC address(es), comma-separated (optional)"}, + "bcc": {"type": "string", "description": "BCC address(es), comma-separated (optional)"}, + "title": {"type": "string", "description": "Optional Odysseus document title"}, + **ACCOUNT_PROP, + }, + "required": ["to", "subject", "body"], + }, + ), Tool( name="reply_to_email", description=( - "Reply to an existing email by UID. Automatically threads the reply with " + "Reply to an existing email by UID. This sends immediately; for normal " + "assistant-written replies, prefer draft_email_reply so the user can " + "review and send from Odysseus. Automatically threads the reply with " "In-Reply-To and References headers, prefixes 'Re:' on the subject, and " "uses the original sender as the recipient. Set reply_all=true to also CC " "the original To/Cc recipients. For follow-up 'reply ...' requests, use " @@ -1176,6 +1641,49 @@ async def list_tools() -> list[Tool]: "required": ["uid", "body"], }, ), + Tool( + name="draft_email_reply", + description=( + "Create an Odysseus email reply draft document for an existing email UID. " + "This DOES NOT send. It threads the draft with In-Reply-To/References, " + "prefills the recipient and subject, and stores source email metadata so " + "the user can review and send from the normal email composer. " + f"{_writing_style_guidance()}" + ), + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"}, + "body": {"type": "string", "description": "Draft reply body text"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"}, + "reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False}, + "title": {"type": "string", "description": "Optional Odysseus document title"}, + **ACCOUNT_PROP, + }, + "required": ["uid", "body"], + }, + ), + Tool( + name="ai_draft_email_reply", + description=( + "Generate an AI reply using Odysseus' existing AI Reply behavior, " + "including Settings > Email > Writing Style, then create an email " + "compose document for review. This DOES NOT send and does NOT save " + "to the mailbox Drafts folder. Use this when the user asks you to " + "write or draft a reply to an email without dictating the exact body." + ), + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"}, + "reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False}, + "title": {"type": "string", "description": "Optional Odysseus document title"}, + **ACCOUNT_PROP, + }, + "required": ["uid"], + }, + ), Tool( name="archive_email", description="Move an email out of the inbox into the Archive folder. Use after handling an email you want to keep but no longer need in the inbox.", @@ -1502,6 +2010,31 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: acct_note = f" (from {result['account']})" if result.get("account") else "" return [TextContent(type="text", text=f"Sent email to {result['to']} with subject '{result['subject']}'{acct_note}.")] + elif name == "draft_email": + to = arguments.get("to") + subject = arguments.get("subject") + body = arguments.get("body") + if not to or not subject or body is None: + return [TextContent(type="text", text="Error: to, subject, and body are required")] + result = _create_email_draft_document( + to=to, + subject=subject, + body=body, + title=arguments.get("title"), + cc=arguments.get("cc"), + bcc=arguments.get("bcc"), + account=acct, + ) + acct_note = f" from {result['account']}" if result.get("account") else "" + return [TextContent( + type="text", + text=( + f"Created Odysseus email draft `{result['title']}` " + f"(document ID: {result['doc_id']}){acct_note}. " + "It has not been sent; open the document in Odysseus to review and send." + ), + )] + elif name == "reply_to_email": uid = arguments.get("uid") body = arguments.get("body") @@ -1523,6 +2056,54 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: pass return [TextContent(type="text", text=f"Replied to UID {uid}: '{result['subject']}' → {result['to']}")] + elif name == "draft_email_reply": + uid = arguments.get("uid") + body = arguments.get("body") + if not uid or body is None: + return [TextContent(type="text", text="Error: uid and body are required")] + result = _draft_reply_to_email( + uid=uid, + body=body, + folder=arguments.get("folder", "INBOX"), + reply_all=bool(arguments.get("reply_all", False)), + account=acct, + title=arguments.get("title"), + ) + if "error" in result: + return [TextContent(type="text", text=f"Error: {result['error']}")] + acct_note = f" from {result['account']}" if result.get("account") else "" + return [TextContent( + type="text", + text=( + f"Created Odysseus reply draft `{result['title']}` for UID {uid} " + f"(document ID: {result['doc_id']}){acct_note}. " + "It has not been sent; open the document in Odysseus to review and send." + ), + )] + + elif name == "ai_draft_email_reply": + uid = arguments.get("uid") + if not uid: + return [TextContent(type="text", text="Error: uid is required")] + result = await _ai_draft_reply_to_email( + uid=uid, + folder=arguments.get("folder", "INBOX"), + reply_all=bool(arguments.get("reply_all", False)), + account=acct, + title=arguments.get("title"), + ) + if "error" in result: + return [TextContent(type="text", text=f"Error: {result['error']}")] + acct_note = f" from {result['account']}" if result.get("account") else "" + return [TextContent( + type="text", + text=( + f"Generated AI reply and created Odysseus compose draft " + f"`{result['title']}` for UID {uid} (document ID: {result['doc_id']}){acct_note}. " + "It has not been sent; open the document in Odysseus to review and send." + ), + )] + elif name == "archive_email": uid = arguments.get("uid") if not uid: diff --git a/mcp_servers/image_gen_server.py b/mcp_servers/image_gen_server.py index 4607b0834..0c8d3884a 100644 --- a/mcp_servers/image_gen_server.py +++ b/mcp_servers/image_gen_server.py @@ -16,6 +16,8 @@ from mcp.types import Tool, TextContent sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from src.constants import GENERATED_IMAGES_DIR + server = Server("image_gen") @@ -121,7 +123,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: _pub_base = (get_setting("app_public_url", "") or "").rstrip("/") if img.get("b64_json"): - img_dir = Path("data/generated_images") + img_dir = Path(GENERATED_IMAGES_DIR) img_dir.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex[:12]}.png" img_path = img_dir / filename diff --git a/package-lock.json b/package-lock.json index 80eac7ebf..8e0812dd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "odysseus-ui", + "name": "odysseus", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/pyproject.toml b/pyproject.toml index 116b1376c..da00ee259 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,22 @@ [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +# Test-taxonomy markers added at collection time by tests/conftest.py. The +# stable area_* markers are declared here; the dynamic sub_ +# markers are registered before collection by pytest_configure in +# tests/conftest.py, so unknown-mark warnings still flag genuine typos outside +# the taxonomy. See tests/_taxonomy.py and tests/README.md. +markers = [ + "area_security: tests covering auth, owner-scope, SSRF, XSS, confinement, redaction", + "area_routes: tests covering HTTP route / API behavior", + "area_services: tests covering service-layer behavior (llm, cookbook, email, calendar, ...)", + "area_cli: tests covering CLI / script behavior", + "area_js: JavaScript / Node-backed tests", + "area_helpers: self-tests for the shared test helpers in tests/helpers/", + "area_unit: pure parser / utility tests that do not clearly belong elsewhere", + "area_uncategorized: tests not yet matched by the taxonomy (fallback)", + # Fast-lane marker (issue #3443). Opt-in and orthogonal to the area_*/sub_* + # taxonomy. The fast lane runs `not slow`; mark a test slow only with + # duration evidence (see tests/run_focus.py --durations and tests/README.md). + "slow: opt-in marker for known-slow tests; excluded by the fast lane (not slow)", +] diff --git a/requirements-optional.txt b/requirements-optional.txt index eeb57c151..b4b654232 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -15,7 +15,7 @@ faster-whisper # DuckDuckGo as a search provider option. # Install if you want DDG in the search-provider dropdown. # Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE. -duckduckgo-search +ddgs # PDF form-filling feature (fillable AcroForm detection, field extraction, # value/annotation/signature stamping, page rendering for the form overlay). diff --git a/routes/_validators.py b/routes/_validators.py new file mode 100644 index 000000000..aa4cf00cc --- /dev/null +++ b/routes/_validators.py @@ -0,0 +1,31 @@ +import re + +from fastapi import HTTPException + + +_REMOTE_HOST_RE = re.compile( + r"^(?:[A-Za-z0-9][A-Za-z0-9._-]*@)?[A-Za-z0-9][A-Za-z0-9._-]*$" +) +_SSH_PORT_RE = re.compile(r"^\d{1,5}$") + + +def validate_remote_host(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _REMOTE_HOST_RE.match(v): + raise HTTPException( + 400, + "Invalid remote_host — must be host or user@host, no SSH option syntax", + ) + return v + + +def validate_ssh_port(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _SSH_PORT_RE.fullmatch(str(v)): + raise HTTPException(400, "Invalid ssh_port") + port = int(v) + if port < 1 or port > 65535: + raise HTTPException(400, "Invalid ssh_port") + return str(port) diff --git a/routes/admin_wipe_routes.py b/routes/admin_wipe_routes.py index 01511c373..212e2a768 100644 --- a/routes/admin_wipe_routes.py +++ b/routes/admin_wipe_routes.py @@ -31,7 +31,7 @@ from core.database import ( CalendarEvent, CalendarCal, ) -from src.constants import DATA_DIR +from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR logger = logging.getLogger(__name__) @@ -107,7 +107,7 @@ def setup_admin_wipe_routes(session_manager): # Skills live as SKILL.md files under data/skills/. Drop # the entire directory; the SkillsManager re-creates the # tree on next write. - skills_dir = os.path.join(DATA_DIR, "skills") + skills_dir = SKILLS_DIR count = 0 if os.path.isdir(skills_dir): # Count SKILL.md files for the response — quick walk. @@ -115,7 +115,7 @@ def setup_admin_wipe_routes(session_manager): count += sum(1 for f in files if f == "SKILL.md") _rmtree_quiet(skills_dir) # Legacy fallback file - legacy = os.path.join(DATA_DIR, "skills.json") + legacy = SKILLS_FILE if os.path.exists(legacy): try: os.remove(legacy) @@ -151,8 +151,8 @@ def setup_admin_wipe_routes(session_manager): db.query(GalleryAlbum).delete() db.commit() # Also drop the upload dir so disk doesn't keep orphans. - _rmtree_quiet(os.path.join(DATA_DIR, "gallery")) - _rmtree_quiet(os.path.join(DATA_DIR, "gallery_uploads")) + _rmtree_quiet(GALLERY_DIR) + _rmtree_quiet(GALLERY_UPLOADS_DIR) return {"status": "deleted", "kind": kind, "count": count} if kind == "calendar": diff --git a/routes/api_token_routes.py b/routes/api_token_routes.py index 68d150368..6f8ac2fc9 100644 --- a/routes/api_token_routes.py +++ b/routes/api_token_routes.py @@ -25,6 +25,8 @@ ALLOWED_SCOPES = { "calendar:write", "memory:read", "memory:write", + "cookbook:read", + "cookbook:launch", } TOKEN_PROFILES = { "chat": ["chat"], @@ -65,6 +67,7 @@ def _normalize_scopes(scopes: str | list[str] | None = None, profile: str | None ensure_before("calendar:write", "calendar:read") ensure_before("memory:write", "memory:read") ensure_before("email:draft", "email:read") + ensure_before("cookbook:launch", "cookbook:read") return normalized or [DEFAULT_SCOPES] @@ -155,22 +158,30 @@ def setup_api_token_routes() -> APIRouter: payload = await request.json() except Exception: payload = {} - scope_list = _normalize_scopes(payload.get("scopes")) - scopes_value = ",".join(scope_list) with get_db_session() as db: token = db.query(ApiToken).filter(ApiToken.id == token_id).first() if not token: raise HTTPException(404, "Token not found") if isinstance(payload.get("name"), str) and payload["name"].strip(): token.name = payload["name"].strip()[:MAX_NAME_LEN] - token.scopes = scopes_value + # Only touch scopes when the caller actually sent them. A partial + # update such as a rename ({"name": ...} with no "scopes" key) must + # not silently reset the token to the default scope — that dropped + # every previously granted scope. + if "scopes" in payload: + token.scopes = ",".join(_normalize_scopes(payload.get("scopes"))) db.add(token) + current_scopes = [ + s.strip() + for s in (getattr(token, "scopes", "") or DEFAULT_SCOPES).split(",") + if s.strip() + ] response = { "id": token_id, "name": getattr(token, "name", ""), "owner": getattr(token, "owner", None), "token_prefix": getattr(token, "token_prefix", ""), - "scopes": scope_list, + "scopes": current_scopes, } _invalidate_cache(request) return response diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 96284e4d0..b9158c93a 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -7,7 +7,13 @@ import asyncio import logging import os +import json +import re +from pathlib import Path + +from core.atomic_io import atomic_write_json, atomic_write_text from core.auth import AuthManager +from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings from src.settings import ( @@ -131,10 +137,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: return {"ok": False, "requires_totp": True, "username": username} if not auth_manager.totp_verify(username, body.totp_code): raise HTTPException(401, "Invalid 2FA code") - # All checks passed — create session - token = await asyncio.to_thread(auth_manager.create_session, username, body.password) - if not token: - raise HTTPException(401, "Invalid credentials") + # All checks passed — create session (password already verified above) + token = await asyncio.to_thread(auth_manager.create_session_trusted, username) cookie_kwargs = dict( key=SESSION_COOKIE, value=token, @@ -293,9 +297,30 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: if new_username in auth_manager.users: raise HTTPException(409, "Username already taken") + # Gate on auth first. Every mutation below is contingent on this + # succeeding — doing it last meant a rejected rename (e.g. reserved + # username) left file-backed owner fields already rewritten with no + # way to roll them back. + ok = auth_manager.rename_user(old_username, new_username, user) + if not ok: + raise HTTPException(400, "Cannot rename user") + + def _rollback_auth_rename() -> bool: + # On self-rename the admin session has already moved to the new + # username, so the rollback must authenticate as the new user. + rollback_user = new_username if user == old_username else user + try: + return bool(auth_manager.rename_user(new_username, old_username, rollback_user)) + except Exception as rollback_err: + logger.error( + "Failed to roll back auth rename %s -> %s after owner migration failure: %s", + new_username, old_username, rollback_err, + ) + return False + # Usernames are ownership keys for user data. Rename the common - # owner-scoped DB rows before changing auth so the account keeps - # access to its sessions, docs, email accounts, tasks, etc. + # owner-scoped DB rows so the account keeps access to its sessions, + # docs, email accounts, tasks, etc. try: from sqlalchemy import func from core.database import Base, SessionLocal @@ -318,6 +343,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: db.close() except Exception as e: logger.error("Failed to rename owner references %s -> %s: %s", old_username, new_username, e) + if not _rollback_auth_rename(): + logger.error( + "Auth rename %s -> %s could not be rolled back after owner migration failure", + old_username, new_username, + ) raise HTTPException(500, "Failed to rename user data") # Per-user prefs are JSON-backed, not SQL-backed. @@ -337,9 +367,105 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: except Exception as e: logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e) - ok = auth_manager.rename_user(old_username, new_username, user) - if not ok: - raise HTTPException(400, "Cannot rename user") + # In-flight deep-research tasks live in the process-local + # ResearchHandler registry. They are not covered by the persisted JSON + # migration above, but the research routes filter and cancel by this + # owner field while the job is running. Do this before sweeping + # completed JSON files so a job that finishes during the rename saves + # with the new owner or is caught by the disk sweep below. + try: + rh = getattr(request.app.state, "research_handler", None) + rename_owner = getattr(rh, "rename_owner", None) + if callable(rename_owner): + rename_owner(old_username, new_username) + except Exception as e: + logger.warning("Failed to rename active research tasks %s -> %s: %s", old_username, new_username, e) + + # deep_research: each completed report is a standalone JSON file with + # an `owner` field. research_routes filters by d.get("owner") == user, + # so a stale owner makes every report invisible to the renamed user. + try: + dr_dir = Path(DEEP_RESEARCH_DIR) + if dr_dir.is_dir(): + for p in dr_dir.glob("*.json"): + try: + d = json.loads(p.read_text(encoding="utf-8")) + if str(d.get("owner", "")).strip().lower() == old_username: + d["owner"] = new_username + atomic_write_json(str(p), d) + except Exception as err: + logger.warning("Failed to update research owner in %s: %s", p.name, err) + except Exception as e: + logger.warning("Failed to rename research owner references %s -> %s: %s", old_username, new_username, e) + + # memory.json: a flat JSON array where each entry carries an `owner` + # field. memory_manager.load(owner=user) filters on it, so stale + # entries disappear from the memory panel. + try: + if os.path.isfile(MEMORY_FILE): + with open(MEMORY_FILE, encoding="utf-8") as fh: + entries = json.loads(fh.read()) + if isinstance(entries, list): + changed = False + for entry in entries: + if isinstance(entry, dict) and str(entry.get("owner", "")).strip().lower() == old_username: + entry["owner"] = new_username + changed = True + if changed: + atomic_write_json(MEMORY_FILE, entries) + except Exception as e: + logger.warning("Failed to rename memory.json owner references %s -> %s: %s", old_username, new_username, e) + + # skills: SKILL.md frontmatter carries owner: ; the usage + # sidecar (_usage.json) keys entries as owner::skill-name. Both must + # be updated or the renamed user's Skills panel goes empty. + try: + skills_root = Path(SKILLS_DIR) + if skills_root.is_dir(): + _owner_re = re.compile( + r'(?m)^(owner:\s*)' + re.escape(old_username) + r'\s*$', + re.IGNORECASE, + ) + for p in skills_root.rglob("SKILL.md"): + try: + text = p.read_text(encoding="utf-8") + new_text = _owner_re.sub(r'\g<1>' + new_username, text) + if new_text != text: + atomic_write_text(str(p), new_text) + except Exception as err: + logger.warning("Failed to update skill owner in %s: %s", p, err) + usage_path = skills_root / "_usage.json" + if usage_path.is_file(): + try: + usage = json.loads(usage_path.read_text(encoding="utf-8")) + if isinstance(usage, dict): + new_usage = {} + changed = False + for k, v in usage.items(): + owner_part, sep, skill_part = k.partition("::") + if sep and owner_part.lower() == old_username: + new_usage[new_username + "::" + skill_part] = v + changed = True + else: + new_usage[k] = v + if changed: + atomic_write_json(str(usage_path), new_usage) + except Exception as err: + logger.warning("Failed to update skills usage keys %s -> %s: %s", old_username, new_username, err) + except Exception as e: + logger.warning("Failed to rename skills owner references %s -> %s: %s", old_username, new_username, e) + + # The in-memory session cache (session_manager.sessions) stores each + # session's owner at load time. Without this patch the renamed user's + # sessions are invisible on the next /api/sessions call because + # get_sessions_for_user does an exact `s.owner == username` comparison + # against stale in-memory values. + sm = getattr(request.app.state, "session_manager", None) + if sm is not None: + for sess in list(getattr(sm, "sessions", {}).values()): + if str(getattr(sess, "owner", None) or "").strip().lower() == old_username: + sess.owner = new_username + # The owner-rename loop above updated ApiToken.owner in the DB, but the # bearer-token cache still maps each token to the OLD owner. Without # refreshing it, the renamed user's API tokens resolve to the old (now @@ -380,7 +506,23 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: user = _get_current_user(request) if not user or not auth_manager.is_admin(user): raise HTTPException(403, "Admin only") - ok = auth_manager.delete_user(body.username, user) + + def _invalidate_api_token_cache(): + try: + invalidator = getattr(request.app.state, "invalidate_token_cache", None) + if invalidator: + invalidator() + except Exception: + pass + + try: + ok = auth_manager.delete_user(body.username, user) + except Exception: + # delete_user can touch ApiToken rows before a later auth-store write + # fails. Dirty the bearer cache anyway so a partial token purge does + # not leave already-cached tokens authenticating until restart. + _invalidate_api_token_cache() + raise if not ok: raise HTTPException(400, "Cannot delete user") # delete_user removes the user's ApiToken rows, but the bearer-auth @@ -388,12 +530,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: # rebuilds when flagged dirty. Without this, a deleted user's already # cached token keeps authenticating until some other token op or a # restart clears the cache. Mirror what the token routes do. - try: - invalidator = getattr(request.app.state, "invalidate_token_cache", None) - if invalidator: - invalidator() - except Exception: - pass + _invalidate_api_token_cache() return {"ok": True} # ---- Feature visibility (admin-managed) ---- diff --git a/routes/backup_routes.py b/routes/backup_routes.py index 2b92a1529..313369370 100644 --- a/routes/backup_routes.py +++ b/routes/backup_routes.py @@ -101,24 +101,74 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo # ── Skills ── if "skills" in body and isinstance(body["skills"], list): existing = skills_manager.load_all() - existing_ids = {s.get("id") for s in existing} - existing_titles = {s.get("title", "").strip().lower() for s in existing} + # Dedup against THIS user's own skills only. Using every tenant's + # rows (load_all) meant a skill whose id/name/title matched any + # other user's was silently skipped, so the importing user lost + # their own data — same cross-tenant bug fixed for memories above. + # The full store is still saved back below. + own = [s for s in existing if s.get("owner") == user] + existing_names = {s.get("name") for s in own if s.get("name")} + existing_ids = {s.get("id") for s in own if s.get("id")} + existing_titles = { + (s.get("title") or s.get("description") or "").strip().lower() + for s in own + } added = 0 for skill in body["skills"]: - if not isinstance(skill, dict) or not skill.get("title"): + if not isinstance(skill, dict): continue - # Skip if same id or same title already exists - if skill.get("id") in existing_ids: + title = ( + skill.get("title") or skill.get("description") + or skill.get("name") or "" + ).strip() + if not title: continue - if skill["title"].strip().lower() in existing_titles: + sid = skill.get("id") or skill.get("name") + if sid and sid in existing_ids: continue - if user and not skill.get("owner"): - skill["owner"] = user - existing.append(skill) - existing_ids.add(skill.get("id")) - existing_titles.add(skill["title"].strip().lower()) + nm = skill.get("name") + if nm and nm in existing_names: + continue + if title.lower() in existing_titles: + continue + owner = skill.get("owner") + if user and not owner: + owner = user + # Skills live on disk as SKILL.md files; the old JSON-era + # skills_manager.save() no longer exists. Write each new skill + # via add_skill (source="user" skips auto-dedup — this is an + # explicit backup restore). + result = skills_manager.add_skill( + title=title, + name=skill.get("name"), + description=skill.get("description"), + problem=skill.get("problem", ""), + solution=skill.get("solution", ""), + steps=skill.get("steps"), + tags=skill.get("tags"), + source="user", + teacher_model=skill.get("teacher_model"), + confidence=skill.get("confidence", 0.8), + owner=owner, + category=skill.get("category", "general"), + when_to_use=skill.get("when_to_use"), + procedure=skill.get("procedure"), + pitfalls=skill.get("pitfalls"), + verification=skill.get("verification"), + platforms=skill.get("platforms"), + requires_toolsets=skill.get("requires_toolsets"), + fallback_for_toolsets=skill.get("fallback_for_toolsets"), + status=skill.get("status", "draft"), + version=skill.get("version", "1.0.0"), + ) + if result.get("_deduped"): + continue + if result.get("name"): + existing_names.add(result["name"]) + if result.get("id"): + existing_ids.add(result["id"]) + existing_titles.add(title.lower()) added += 1 - skills_manager.save(existing) imported.append(f"{added} skills") # ── Presets ── diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 75b6a5715..7b36df06a 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -13,7 +13,7 @@ from dateutil.rrule import rrulestr from core.database import SessionLocal, CalendarCal, CalendarEvent from src.auth_helpers import require_user -from src.upload_limits import read_upload_limited +from src.upload_limits import read_upload_limited, ICS_MAX_BYTES logger = logging.getLogger(__name__) @@ -258,6 +258,17 @@ def parse_due_for_user(s: str) -> str: if t is not None: return base.replace(hour=t[0], minute=t[1]).isoformat() + # Time-first: "3pm today", "11pm today", "9am tomorrow" + m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower) + if m: + time_part, word = m.group(1).strip(), m.group(2) + base = today + if word in ("tomorrow", "tmrw"): base = today + _td(days=1) + elif word == "yesterday": base = today - _td(days=1) + t = _parse_time(time_part) + if t is not None: + return base.replace(hour=t[0], minute=t[1]).isoformat() + m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower) if m: n = int(m.group(1)); unit = m.group(2) @@ -840,28 +851,27 @@ def setup_calendar_routes() -> APIRouter: from src.caldav_sync import sync_caldav return await sync_caldav(owner) + @router.delete("/calendars/{cal_id}") - async def delete_calendar(cal_id: str, request: Request): + async def delete_calendar(request: Request, cal_id: str): owner = _require_user(request) db = SessionLocal() try: - cal = db.query(CalendarCal).filter( - CalendarCal.id == cal_id, - CalendarCal.owner == owner, - ).first() - if not cal: - raise HTTPException(404, "Calendar not found") + cal = _get_or_404_calendar(db, cal_id, owner) + db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete() db.delete(cal) db.commit() return {"ok": True} except HTTPException: raise except Exception as e: + db.rollback() logger.error("Failed to delete calendar %s: %s", cal_id, e) raise HTTPException(500, "Failed to delete calendar") finally: db.close() + @router.get("/calendars") async def list_calendars(request: Request): owner = _require_user(request) @@ -1141,27 +1151,10 @@ def setup_calendar_routes() -> APIRouter: finally: db.close() - @router.delete("/calendars/{cal_id}") - async def delete_calendar(request: Request, cal_id: str): - owner = _require_user(request) - db = SessionLocal() - try: - cal = _get_or_404_calendar(db, cal_id, owner) - db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete() - db.delete(cal) - db.commit() - return {"ok": True} - except HTTPException: - raise - except Exception as e: - db.rollback() - return {"error": str(e)} - finally: - db.close() - # 10 MB hard cap on ICS upload. Loading the whole file into memory is - # unavoidable with python-icalendar, so an unbounded upload would OOM. - _ICS_MAX_BYTES = 10 * 1024 * 1024 + # Hard cap on ICS upload (ICS_MAX_BYTES, default 10 MB). Loading the whole + # file into memory is unavoidable with python-icalendar, so an unbounded + # upload would OOM. @router.post("/import") async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""): @@ -1171,7 +1164,7 @@ def setup_calendar_routes() -> APIRouter: owner = _require_user(request) db = SessionLocal() try: - content = await read_upload_limited(file, _ICS_MAX_BYTES, "ICS file") + content = await read_upload_limited(file, ICS_MAX_BYTES, "ICS file") try: cal_data = iCal.from_ical(content) except Exception as e: @@ -1368,7 +1361,7 @@ def setup_calendar_routes() -> APIRouter: "tomorrow", "next Tuesday", "in 30 minutes" resolve correctly. Uses the "utility" endpoint (small / fast model) to keep latency low. """ - _require_user(request) + owner = _require_user(request) from src.endpoint_resolver import resolve_endpoint from src.llm_core import llm_call_async from src.text_helpers import strip_think @@ -1394,9 +1387,9 @@ def setup_calendar_routes() -> APIRouter: if tz_hint: set_user_tz_name(tz_hint) - url, model, headers = resolve_endpoint("utility") + url, model, headers = resolve_endpoint("utility", owner=owner or None) if not url: - url, model, headers = resolve_endpoint("default") + url, model, headers = resolve_endpoint("default", owner=owner or None) if not url or not model: return {"ok": False, "error": "No LLM endpoint configured"} diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index e83c2f36a..c32161bb1 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -88,6 +88,14 @@ def _enforce_chat_privileges(request, sess) -> None: return privs = auth_manager.get_privileges(user) or {} + + # Explicit "block everything" sentinel takes precedence over the + # allowlist — it's the only way to distinguish "user clicked [None]" + # (block all) from "user clicked [All]" (no restriction), since both + # otherwise produce an empty `allowed_models` list. + if privs.get("block_all_models"): + raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.") + allowed_raw = privs.get("allowed_models") allowed = allowed_raw if isinstance(allowed_raw, list) else [] restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed) @@ -196,14 +204,26 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None: Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None. """ import requests as _req - from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base + from src.endpoint_resolver import ( + build_chat_url, + build_headers, + build_models_url, + normalize_base, + resolve_endpoint_runtime, + ) + from src.chatgpt_subscription import is_chatgpt_subscription_base current_url = sess.endpoint_url or "" + owner = getattr(sess, "owner", None) db = SessionLocal() try: - endpoints = db.query(ModelEndpoint).filter( + q = db.query(ModelEndpoint).filter( ModelEndpoint.is_enabled == True - ).all() + ) + if owner: + from src.auth_helpers import owner_filter + q = owner_filter(q, ModelEndpoint, owner) + endpoints = q.all() finally: db.close() @@ -212,26 +232,33 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None: # Skip current endpoint if current_url and base in current_url: continue - # Quick ping - ping_url = build_models_url(base) - headers = build_headers(ep.api_key, base) try: - r = _req.get(ping_url, headers=headers, timeout=5) - r.raise_for_status() - data = r.json() - models = [m.get("id") for m in (data.get("data") or []) if m.get("id")] - if not models: - models = [ - m.get("name") or m.get("model") - for m in (data.get("models") or []) - if m.get("name") or m.get("model") - ] + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + except Exception: + continue + ping_url = build_models_url(base) + headers = build_headers(api_key, base) + try: + if ping_url: + r = _req.get(ping_url, headers=headers, timeout=5) + r.raise_for_status() + data = r.json() + models = [m.get("id") for m in (data.get("data") or []) if m.get("id")] + if not models: + models = [ + m.get("name") or m.get("model") + for m in (data.get("models") or []) + if m.get("name") or m.get("model") + ] + else: + models = json.loads(ep.cached_models or "[]") if not models: continue # Found a working endpoint — update session new_model = models[0] chat_url = build_chat_url(base) - new_headers = build_headers(ep.api_key, base) + new_headers = build_headers(api_key, base) + persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers sess.model = new_model sess.endpoint_url = chat_url @@ -243,7 +270,7 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None: _db.query(DBSession).filter(DBSession.id == session_id).update({ "model": new_model, "endpoint_url": chat_url, - "headers": json.dumps(new_headers), + "headers": persisted_headers, }) _db.commit() finally: @@ -277,11 +304,16 @@ def extract_preset(chat_handler, preset_id) -> PresetInfo: async def preprocess( chat_handler, message, att_ids, sess, auto_opened_docs: Optional[list] = None, + allow_tool_preprocessing: bool = True, ) -> PreprocessedMessage: """Run chat_handler.preprocess_message and wrap the result.""" enhanced, user_content, text_ctx, yt_transcripts, att_meta = ( await chat_handler.preprocess_message( - message, att_ids, sess, auto_opened_docs=auto_opened_docs + message, + att_ids, + sess, + auto_opened_docs=auto_opened_docs, + allow_tool_preprocessing=allow_tool_preprocessing, ) ) return PreprocessedMessage( @@ -331,16 +363,26 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool: return False +def _has_auth_keys(headers) -> bool: + """True if a headers dict carries an Authorization/x-api-key entry.""" + return isinstance(headers, dict) and any( + k.lower() in ('authorization', 'x-api-key') for k in headers + ) + + def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None): """Ensure session has auth headers — resolve from endpoint DB if missing.""" - has_auth = sess.headers and isinstance(sess.headers, dict) and any( - k.lower() in ('authorization', 'x-api-key') for k in sess.headers - ) - if has_auth: + try: + from src.chatgpt_subscription import is_chatgpt_subscription_base + is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "") + except Exception: + is_chatgpt_subscription = False + has_auth = _has_auth_keys(sess.headers) + if has_auth and not is_chatgpt_subscription: return try: - from src.endpoint_resolver import build_headers, normalize_base + from src.endpoint_resolver import build_headers, resolve_endpoint_runtime db = SessionLocal() try: target_url = getattr(sess, "endpoint_url", "") or "" @@ -356,10 +398,30 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None): for ep in q.all(): if not _session_url_matches_endpoint(target_url, ep.base_url or ""): continue - if not ep.api_key: + try: + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + except Exception as e: + logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e) + return + if not api_key: + # No usable key (e.g. ChatGPT Subscription needs re-auth). + return + sess.headers = build_headers(api_key, base) + if is_chatgpt_subscription: + # The bearer is short-lived and re-resolved per request, so it + # stays request-local and is never written to the plaintext + # sessions.headers column. Proactively strip any bearer an + # older code path may have persisted so it does not linger. + stale_q = db.query(DBSession).filter(DBSession.id == session_id) + if owner: + stale_q = stale_q.filter(DBSession.owner == owner) + stored = stale_q.first() + if stored is not None and _has_auth_keys(stored.headers): + stale_q.update({"headers": {}}) + db.commit() + logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}") + logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}") return - base = normalize_base(ep.base_url or "") - sess.headers = build_headers(ep.api_key, base) update_q = db.query(DBSession).filter(DBSession.id == session_id) if owner: update_q = update_q.filter(DBSession.owner == owner) @@ -403,7 +465,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]: db = SessionLocal() try: - endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() + q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + owner = getattr(sess, "owner", None) + if owner: + from src.auth_helpers import owner_filter + q = owner_filter(q, ModelEndpoint, owner) + endpoints = q.all() for ep in endpoints: try: if normalize_base(getattr(ep, "base_url", "") or "") != session_base: @@ -450,6 +517,7 @@ async def build_chat_context( webhook_manager=None, use_enhanced_message: bool = False, agent_mode: bool = False, + allow_tool_preprocessing: bool = True, ) -> ChatContext: """Build the full context (preface + messages) for an LLM call. @@ -467,6 +535,7 @@ async def build_chat_context( preprocessed = await preprocess( chat_handler, message, att_ids or [], sess, auto_opened_docs=auto_opened_docs, + allow_tool_preprocessing=allow_tool_preprocessing, ) # Add user message to history @@ -485,6 +554,9 @@ async def build_chat_context( # Skills injection respects its own enable toggle (mirrors memory_enabled). # When off, the "Available skills" index is not added to the prompt. skills_enabled = not incognito and uprefs.get("skills_enabled", True) + if not allow_tool_preprocessing: + mem_enabled = False + skills_enabled = False logger.debug( "Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)", mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"), @@ -492,11 +564,11 @@ async def build_chat_context( # Use RAG? use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True - if incognito: + if incognito or not allow_tool_preprocessing: use_rag_val = False # If pre-fetched search context was provided (compare mode), skip live web search - skip_web = bool(search_context) + skip_web = bool(search_context) or not allow_tool_preprocessing # Build context preface # The stream path uses enhanced_message (with CoT/preprocessing applied), @@ -523,7 +595,7 @@ async def build_chat_context( used_memories = getattr(chat_processor, '_last_used_memories', []) # Inject pre-fetched search context (compare mode) - if search_context: + if search_context and allow_tool_preprocessing: preface.append(untrusted_context_message("prefetched search context", search_context)) # YouTube transcripts @@ -532,16 +604,40 @@ async def build_chat_context( # Normalize model ID. Prefer cached endpoint models so group chat does not # re-hit slow local /models endpoints on every participant turn. - norm = _normalize_model_id_from_cache(sess) or normalize_model_id(sess.endpoint_url, sess.model) + norm = _normalize_model_id_from_cache(sess) or normalize_model_id( + sess.endpoint_url, + sess.model, + owner=getattr(sess, "owner", None), + ) if norm: sess.model = norm # Build messages messages = preface + sess.get_context_messages() + # Current date/time — injected as a standalone *user*-role context message + # placed immediately before the latest user turn, NOT folded into the + # system prompt. Its text changes every minute, and local OpenAI-compatible + # backends (llama.cpp / LM Studio) key their KV-cache prefix off the + # system message byte-for-byte; mixing ever-changing timestamp text into + # it would invalidate the cached prefix on every request (issue #2927). + # Placing it at the tail also keeps it out of the stable + # preface+history prefix, so that prefix stays byte-identical turn over + # turn (modulo the genuinely new history entries) and the cache survives. + if not agent_mode: + try: + from src.user_time import current_datetime_context_message + _dt_msg = current_datetime_context_message() + if messages and messages[-1].get("role") == "user": + messages.insert(len(messages) - 1, _dt_msg) + else: + messages.append(_dt_msg) + except Exception: + logger.debug("Failed to add current date/time context", exc_info=True) + # Auto-compact messages, context_length, was_compacted = await maybe_compact( - sess, sess.endpoint_url, sess.model, messages, sess.headers, + sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user, ) messages = trim_for_context(messages, context_length) @@ -835,6 +931,54 @@ def save_assistant_response( return None +def _is_session_stream_active(session_id: str) -> bool: + """Best-effort check for "is a chat completion currently streaming for + this session?" — used to keep background extraction from overlapping a + main completion and competing for the local backend's processing slots + (issue #2927). Lazily imports the route module's live registry to avoid + a circular import (chat_routes imports this module at load time).""" + try: + from routes import chat_routes as _cr + return session_id in getattr(_cr, "_active_streams", {}) + except Exception: + return False + + +async def _run_extraction_jobs_sequentially(session_id: str, jobs: list, max_wait_s: float = 120.0): + """Run queued background-extraction coroutines one at a time, only once + no chat completion is actively streaming for this session. + + As diagnosed in issue #2927, firing memory/skill extraction concurrently + with the main chat completion (or with each other) makes them compete for + the local backend's limited processing slots, evicting the main + conversation's cached KV-cache checkpoint and forcing a full prompt + re-evaluation on the next turn. Waiting for the stream to go idle and then + running the jobs strictly in sequence keeps at most one "side" request in + flight against the backend at any time, and never alongside the user's + own conversation. + """ + # Wait for the triggering turn's own stream to finish winding down (it + # almost always already has by the time this task gets scheduled — this + # is a small safety margin, not the primary mechanism). + waited = 0.0 + poll = 0.25 + while _is_session_stream_active(session_id) and waited < max_wait_s: + await asyncio.sleep(poll) + waited += poll + + for name, job in jobs: + # Re-check before each job: a fast follow-up message from the user + # may have started a new stream for this session while we waited. + waited = 0.0 + while _is_session_stream_active(session_id) and waited < max_wait_s: + await asyncio.sleep(poll) + waited += poll + try: + await job + except Exception: + logger.warning("[bg-extract] %s extraction job failed for session %s", name, session_id, exc_info=True) + + def run_post_response_tasks( sess, session_manager, @@ -855,21 +999,37 @@ def run_post_response_tasks( skills_manager=None, owner: str = None, extract_skills: bool = True, + allow_background_extraction: bool = True, ): - """Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.""" + """Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction. + + Memory/skill extraction are queued to run *sequentially*, after the main + completion stream for this session has fully wound down — never + concurrently with it or with each other. As diagnosed in issue #2927, + firing these "side" LLM calls in parallel with the main chat completion + makes them compete for the local backend's limited processing slots + (llama.cpp defaults to 4), evicting the main conversation's cached + checkpoint and forcing a full prompt re-evaluation on the next turn. By + the time this function runs the main response is already saved, but the + extraction calls themselves are still async — queuing them through + ``_queue_background_extraction`` keeps them from overlapping the *next* + turn's request too. + """ + _extraction_jobs: list = [] + # Memory extraction — only every 4th message pair to avoid excess LLM calls _msg_count = len(sess.history) if hasattr(sess, 'history') else 0 _should_extract = (_msg_count >= 4) and (_msg_count % 4 == 0) - if not incognito and not compare_mode and _should_extract and uprefs.get("auto_memory", True): + if allow_background_extraction and not incognito and not compare_mode and _should_extract and uprefs.get("auto_memory", True): from services.memory.memory_extractor import extract_and_store from src.task_endpoint import resolve_task_endpoint t_url, t_model, t_headers = resolve_task_endpoint( sess.endpoint_url, sess.model, sess.headers, owner=owner, ) - asyncio.create_task(extract_and_store( + _extraction_jobs.append(("memory", extract_and_store( sess, memory_manager, memory_vector, t_url, t_model, t_headers, - )) + ))) # Skill extraction from complex agent runs. Only when the user actually # chose agent mode — not a chat we auto-escalated for a notes/calendar @@ -887,6 +1047,7 @@ def run_post_response_tasks( ) if ( extract_skills + and allow_background_extraction and auto_skills_enabled and not incognito and not compare_mode @@ -904,12 +1065,15 @@ def run_post_response_tasks( sess.endpoint_url, sess.model, sess.headers, owner=owner, ) logger.debug("[skill-extract] dispatching extractor (model=%s)", s_model) - asyncio.create_task(maybe_extract_skill( + _extraction_jobs.append(("skill", maybe_extract_skill( sess, skills_manager, s_url, s_model, s_headers, agent_rounds, agent_tool_calls, owner=owner, - )) + ))) + + if _extraction_jobs: + asyncio.create_task(_run_extraction_jobs_sequentially(session_id, _extraction_jobs)) # Token accumulation if last_metrics: diff --git a/routes/chat_routes.py b/routes/chat_routes.py index 9554e243f..3e18bf5c6 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -40,6 +40,7 @@ from routes.chat_helpers import ( _enforce_chat_privileges, ) from src.action_intents import classify_tool_intent as _classify_tool_intent +from src.tool_policy import build_effective_tool_policy logger = logging.getLogger(__name__) @@ -168,13 +169,20 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None Covers the window between endpoint setup and the first chat send: the picker showed a model in the dropdown but the session record never got written (Issue #587 — UI uses the cached endpoint list, not s.model). - Without this, we'd POST the upstream with model="" and get a generic - 401/503 instead of using the model the user already picked. - - Returns True iff sess.model was repaired. + For ChatGPT Subscription, also repairs stale OpenAI API model names such as + ``gpt-5`` that are not accepted by the Codex-backed ChatGPT account route. """ - if getattr(sess, "model", None): - return False + current_model = (getattr(sess, "model", "") or "").strip() + endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip() + is_chatgpt_subscription = False + if current_model: + try: + from src.chatgpt_subscription import is_chatgpt_subscription_base + is_chatgpt_subscription = is_chatgpt_subscription_base(endpoint_url) + if not is_chatgpt_subscription: + return False + except Exception: + return False db = SessionLocal() try: # Prefer the endpoint whose base URL matches the session — we know the @@ -193,16 +201,51 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None break if not ep: return False + if not is_chatgpt_subscription: + try: + from src.chatgpt_subscription import is_chatgpt_subscription_base + is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(ep, "base_url", "") or endpoint_url) + except Exception: + is_chatgpt_subscription = False try: cached = json.loads(ep.cached_models) if isinstance(ep.cached_models, str) else (ep.cached_models or []) except Exception: cached = [] if not cached: + visible = [] + else: + try: + visible = _visible_models(cached, getattr(ep, "hidden_models", None)) + except Exception: + visible = cached + if current_model and current_model in {str(item).strip() for item in visible}: return False - try: - visible = _visible_models(cached, getattr(ep, "hidden_models", None)) - except Exception: - visible = cached + if is_chatgpt_subscription: + live_models = [] + if getattr(ep, "provider_auth_id", None): + try: + from src.chatgpt_subscription import fetch_available_models + from src.endpoint_resolver import resolve_endpoint_runtime + _base, api_key = resolve_endpoint_runtime(ep, owner=owner) + if api_key: + live_models = fetch_available_models(api_key) + if live_models: + ep.cached_models = json.dumps(live_models) + db.commit() + except Exception: + live_models = [] + # ChatGPT Subscription recovery must use the live Codex catalog. + # Cached rows are only trusted above to avoid revalidating a model + # that is already present in the visible picker list. + cached = live_models + if not cached: + return False + try: + visible = _visible_models(cached, getattr(ep, "hidden_models", None)) + except Exception: + visible = cached + if current_model and current_model in {str(item).strip() for item in visible}: + return False if not visible: return False model = visible[0] @@ -212,14 +255,17 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None # Persist so the next request, websocket reconnect, or page reload # picks up the same model (we'd otherwise re-pick on every send # and silently switch on the user if the cached order shifts). - db_session = db.query(DBSession).filter(DBSession.id == session_id).first() + db_session_q = db.query(DBSession).filter(DBSession.id == session_id) + if owner: + db_session_q = db_session_q.filter(DBSession.owner == owner) + db_session = db_session_q.first() if db_session: db_session.model = model db_session.updated_at = datetime.utcnow() db.commit() sess.model = model logger.info( - "Recovered empty session model for %s — picked %r from endpoint %s", + "Recovered session model for %s — picked %r from endpoint %s", session_id, model, ep.id, ) return True @@ -305,8 +351,13 @@ def setup_chat_routes( # non-streaming path can't be used to bypass). _enforce_chat_privileges(request, sess) + tool_policy = build_effective_tool_policy(last_user_message=message) + allow_tool_preprocessing = not tool_policy.block_all_tool_calls + # Inline memory command - memory_response = await chat_handler.handle_memory_command(sess, message) + memory_response = None + if not tool_policy.blocks("manage_memory"): + memory_response = await chat_handler.handle_memory_command(sess, message) if memory_response: return {"response": memory_response} @@ -320,10 +371,15 @@ def setup_chat_routes( use_web=use_web, time_filter=time_filter, webhook_manager=webhook_manager, + allow_tool_preprocessing=allow_tool_preprocessing, ) # Research injection - if use_research: + research_blocked_by_policy = ( + tool_policy.blocks("trigger_research") + or tool_policy.blocks("manage_research") + ) + if use_research and not research_blocked_by_policy: try: _r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess) research_ctx = await research_handler.call_research_service( @@ -344,6 +400,7 @@ def setup_chat_routes( temperature=ctx.preset.temperature, max_tokens=ctx.preset.max_tokens, prompt_type=preset_id, + session_id=session, ) _clean_reply, _clean_md = clean_thinking_for_save(reply, {"model": sess.model}) sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md)) @@ -358,6 +415,7 @@ def setup_chat_routes( ctx.uprefs, memory_manager, memory_vector, webhook_manager, character_name=ctx.preset.character_name, owner=ctx.user, + allow_background_extraction=not tool_policy.block_all_tool_calls, ) return {"response": reply} @@ -395,14 +453,10 @@ def setup_chat_routes( search_context = form_data.get("search_context") # pre-fetched web search results (compare mode) compare_mode = str(form_data.get("compare_mode", "")).lower() == "true" incognito = str(form_data.get("incognito", "")).lower() == "true" - plan_mode = str(form_data.get("plan_mode", "")).lower() == "true" + # Plan mode is not part of the merge-ready UI. Ignore stale clients or + # manual form posts that still send plan_mode=true. + plan_mode = False chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent' - # Workspace: confine the agent's file/shell tools to this folder. Validate - # it's a real directory; ignore (no confinement) otherwise. - workspace = (form_data.get("workspace") or "").strip() - if workspace: - _ws_real = os.path.realpath(os.path.expanduser(workspace)) - workspace = _ws_real if os.path.isdir(_ws_real) else "" # Plan mode is a modifier on agent mode — it only makes sense with tools. if plan_mode: chat_mode = "agent" @@ -492,11 +546,6 @@ def setup_chat_routes( do_research = True logger.info(f"Session {session} in research_pending — auto-triggering research") - # Persist session mode (research > agent > chat) - _effective_mode = 'research' if do_research else (chat_mode or 'chat') - if _effective_mode in ('agent', 'research', 'chat'): - set_session_mode(session, _effective_mode) - att_ids = [] if body and isinstance(body.get("attachments"), list): att_ids = [str(x) for x in body["attachments"]] @@ -507,6 +556,10 @@ def setup_chat_routes( pass no_memory = str(form_data.get("no_memory", "")).lower() == "true" + pre_context_tool_policy = build_effective_tool_policy( + last_user_message=message, + ) + allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls # Build shared context (stream path uses enhanced_message for context preface) ctx = await build_chat_context( @@ -528,6 +581,7 @@ def setup_chat_routes( # manage_skills (agent mode). In plain chat or incognito the # index would be useless / unwanted noise. agent_mode=(chat_mode == "agent"), + allow_tool_preprocessing=allow_tool_preprocessing, ) _research_flags = {"do": do_research} # Mutable container for generator scope @@ -581,7 +635,7 @@ def setup_chat_routes( # leak a doc that belongs to a DIFFERENT session. if not active_doc: try: - from src.tool_implementations import get_active_document + from src.agent_tools.document_tools import get_active_document _mem_id = get_active_document() if _mem_id: _mem_q = _doc_db.query(DBDocument).filter(DBDocument.id == _mem_id) @@ -679,6 +733,25 @@ def setup_chat_routes( from src.tool_security import plan_mode_disabled_tools disabled_tools.update(plan_mode_disabled_tools()) + tool_policy = build_effective_tool_policy( + disabled_tools=disabled_tools, + last_user_message=message, + ) + disabled_tools = tool_policy.all_disabled_names() + research_blocked_by_policy = bool( + tool_policy.blocks("trigger_research") + or tool_policy.blocks("manage_research") + ) + effective_do_research = bool( + do_research and _research_flags["do"] and not research_blocked_by_policy + ) + + # Persist session mode after policy/privilege gates so blocked research + # turns remain ordinary chat/agent streams and saved messages. + _effective_mode = 'research' if effective_do_research else (chat_mode or 'chat') + if _effective_mode in ('agent', 'research', 'chat'): + set_session_mode(session, _effective_mode) + async def stream_with_save() -> AsyncGenerator[str, None]: # _effective_mode is read-only here; closure captures it from # the outer scope. (Was `nonlocal` but never reassigned.) @@ -686,7 +759,7 @@ def setup_chat_routes( web_sources = ctx.web_sources # Register active stream for partial-save safety net - _active_streams[session] = {"status": "streaming", "partial": "", "query": message, "is_research": do_research, "mode": _effective_mode} + _active_streams[session] = {"status": "streaming", "partial": "", "query": message, "is_research": effective_do_research, "mode": _effective_mode} if ctx.preprocessed.attachment_meta: yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n" @@ -710,7 +783,7 @@ def setup_chat_routes( yield f"data: {json.dumps({'type': 'memories_used', 'data': ctx.used_memories})}\n\n" # Run research as a background task (survives page refresh) - if do_research and _research_flags["do"]: + if effective_do_research: _r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess) _auth_keys = list(_r_headers.keys()) if _r_headers else [] logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}") @@ -849,7 +922,7 @@ def setup_chat_routes( _fallback_candidates = [] # Send model name early so the frontend can show it during streaming - _model_suffix = "Research" if do_research else None + _model_suffix = "Research" if effective_do_research else None _model_info = {"type": "model_info", "model": sess.model} if _model_suffix: _model_info["suffix"] = _model_suffix @@ -859,6 +932,12 @@ def setup_chat_routes( if _is_image_generation_session(sess, owner=_user): from src.settings import get_setting + if tool_policy.blocks("generate_image"): + _blocked_msg = tool_policy.reason_for("generate_image") + yield f'data: {json.dumps({"delta": _blocked_msg})}\n\n' + yield "data: [DONE]\n\n" + _active_streams.pop(session, None) + return if not get_setting("image_gen_enabled", True): yield f'data: {json.dumps({"delta": "Image generation is disabled by the administrator."})}\n\n' yield "data: [DONE]\n\n" @@ -910,6 +989,7 @@ def setup_chat_routes( max_tokens=ctx.preset.max_tokens, prompt_type=preset_id, tools=None, + session_id=session, ): if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): try: @@ -988,7 +1068,7 @@ def setup_chat_routes( rag_sources=ctx.rag_sources, research_sources=research_sources, used_memories=ctx.used_memories, - do_research=do_research, + do_research=effective_do_research, incognito=incognito, ) if _saved_id: @@ -998,7 +1078,8 @@ def setup_chat_routes( last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager, incognito=incognito, compare_mode=compare_mode, character_name=ctx.preset.character_name, - owner=_user, + owner=_user, + allow_background_extraction=not tool_policy.block_all_tool_calls, ) _stream_set(session, status="done") yield chunk @@ -1052,9 +1133,9 @@ def setup_chat_routes( active_document=active_doc, session_id=session, disabled_tools=disabled_tools if disabled_tools else None, + tool_policy=tool_policy, owner=_user, fallbacks=_fallback_candidates, - workspace=workspace or None, plan_mode=plan_mode, approved_plan=approved_plan or None, ): @@ -1130,6 +1211,7 @@ def setup_chat_routes( skills_manager=skills_manager, owner=_user, extract_skills=user_requested_agent, + allow_background_extraction=not tool_policy.block_all_tool_calls, ) _stream_set(session, status="done") yield chunk @@ -1169,11 +1251,29 @@ def setup_chat_routes( finally: _active_streams.pop(session, None) - # Run the stream as a DETACHED background task so it survives the client - # closing the tab / navigating away (true terminal-agent behavior). The - # SSE response just subscribes (replay buffered output + live); dropping - # the SSE only removes a subscriber — the run keeps going and saves the - # assistant message on completion regardless. Reconnect via /api/chat/resume. + # Compare panes are short-lived, single-shot generations whose sessions + # exist only to drive that one pane — there's nothing to "resume" and + # the user expects the pane's Stop button (which aborts the fetch, + # closing this SSE) to promptly cancel the upstream LLM call. Detaching + # them would keep burning upstream tokens/compute after the pane is + # stopped or the comparison is abandoned, and would surface a stale + # "still streaming" /resume target for a session nobody will revisit. + # + # So: stream them directly (no agent_runs wrapping). Starlette cancels + # the underlying async generator (raising CancelledError/GeneratorExit + # inside it) as soon as it notices the client disconnected — which the + # mode-specific except blocks above already handle by saving the + # partial response exactly once. This stops the upstream call promptly + # without waiting on the next streamed chunk. + # + # Normal chat/agent streams keep the DETACHED behavior below: they + # survive the client closing the tab / navigating away. The SSE response just subscribes (replay + # buffered output + live); dropping the SSE only removes a subscriber — + # the run keeps going and saves the assistant message on completion + # regardless. Reconnect via /api/chat/resume. + if compare_mode: + return StreamingResponse(_safe_stream(), media_type="text/event-stream") + agent_runs.start(session, _safe_stream()) return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-stream") diff --git a/routes/chatgpt_subscription_routes.py b/routes/chatgpt_subscription_routes.py new file mode 100644 index 000000000..9c695b371 --- /dev/null +++ b/routes/chatgpt_subscription_routes.py @@ -0,0 +1,170 @@ +"""ChatGPT Subscription device-flow setup routes.""" + +import json +import logging +import uuid +from typing import Dict, Optional + +from fastapi import HTTPException, Request + +from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive +from routes.device_flow import ( + DeviceFlowPoll, + DeviceFlowStart, + PendingDeviceFlowStore, + create_device_flow_router, +) +from src.auth_helpers import get_current_user +from src import chatgpt_subscription + +logger = logging.getLogger(__name__) + +_DEVICE_FLOW_STORE = PendingDeviceFlowStore() + + +def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict: + access_token = tokens.get("access_token") + refresh_token = tokens.get("refresh_token") + if not access_token or not refresh_token: + raise ValueError("ChatGPT token response was missing access_token or refresh_token") + + base = chatgpt_subscription.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL + models = chatgpt_subscription.fetch_available_models(access_token) + if not models: + raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.") + db = SessionLocal() + try: + auth = ( + db.query(ProviderAuthSession) + .filter( + ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, + ProviderAuthSession.owner == owner, + ) + .first() + ) + if auth is None: + auth = ProviderAuthSession( + id=str(uuid.uuid4())[:8], + provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER, + owner=owner, + label="ChatGPT Subscription", + base_url=base, + auth_mode="chatgpt", + ) + db.add(auth) + auth.base_url = base + auth.access_token = access_token + auth.refresh_token = refresh_token + auth.last_refresh = utcnow_naive() + auth.auth_mode = "chatgpt" + + ep = ( + db.query(ModelEndpoint) + .filter( + ModelEndpoint.base_url == base, + ModelEndpoint.provider_auth_id == auth.id, + ModelEndpoint.owner == owner, + ) + .first() + ) + if ep is None: + ep = ModelEndpoint( + id=str(uuid.uuid4())[:8], + name="ChatGPT Subscription", + base_url=base, + model_type="llm", + endpoint_kind="api", + owner=owner, + ) + db.add(ep) + ep.name = "ChatGPT Subscription" + ep.base_url = base + ep.api_key = None + ep.provider_auth_id = auth.id + ep.is_enabled = True + ep.supports_tools = False + ep.model_type = "llm" + ep.endpoint_kind = "api" + ep.model_refresh_mode = "manual" + ep.cached_models = json.dumps(models) + db.commit() + result = { + "id": ep.id, + "name": ep.name, + "base_url": ep.base_url, + "models": models, + } + finally: + db.close() + + try: + from routes.model_routes import _invalidate_models_cache + + _invalidate_models_cache() + except Exception: + pass + return result + + +def _start_device_flow(request: Request, _form) -> DeviceFlowStart: + try: + data = chatgpt_subscription.request_device_code() + except Exception as exc: + raise chatgpt_subscription.to_http_exception(exc) + + device_auth_id = data.get("device_auth_id") + user_code = data.get("user_code") + if not device_auth_id or not user_code: + raise HTTPException(502, "ChatGPT did not return a complete device code") + verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device" + return DeviceFlowStart( + pending={ + "device_auth_id": device_auth_id, + "user_code": user_code, + "owner": get_current_user(request) or None, + }, + response={ + "user_code": user_code, + "verification_uri": verification_uri, + }, + interval=int(data.get("interval") or 5), + expires_in=int(data.get("expires_in") or 900), + ) + + +def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll: + try: + data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"]) + except Exception as exc: + logger.debug("ChatGPT device poll failed: %s", exc) + return DeviceFlowPoll.pending(str(exc)) + + authorization_code = data.get("authorization_code") + code_verifier = data.get("code_verifier") + if authorization_code and code_verifier: + try: + tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier) + result = _provision_endpoint(tokens, pending["owner"]) + except Exception as exc: + logger.exception("ChatGPT Subscription endpoint provisioning failed") + raise chatgpt_subscription.to_http_exception(exc) + return DeviceFlowPoll.authorized(result) + + err = data.get("error") or data.get("status") + if err in ("authorization_pending", "pending", None): + return DeviceFlowPoll.pending() + if err == "slow_down": + return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None) + if err in ("expired_token", "access_denied", "denied"): + return DeviceFlowPoll.failed(err) + return DeviceFlowPoll.pending(err or "unknown") + + +def setup_chatgpt_subscription_routes(): + return create_device_flow_router( + prefix="/api/chatgpt-subscription", + tags=["chatgpt-subscription"], + store=_DEVICE_FLOW_STORE, + start_flow=_start_device_flow, + poll_flow=_poll_device_flow, + ) diff --git a/routes/codex_routes.py b/routes/codex_routes.py index 9898daed2..1afac02b9 100644 --- a/routes/codex_routes.py +++ b/routes/codex_routes.py @@ -15,8 +15,9 @@ from typing import Any from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request from fastapi.responses import StreamingResponse -from src.auth_helpers import require_user +from src.auth_helpers import require_authenticated_request, require_user from src.tool_implementations import do_manage_notes +from src.constants import COOKBOOK_STATE_FILE COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"} @@ -41,7 +42,9 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs): the scope-gated owner (not the "api" pseudo-user the bearer middleware sets). Restores the original value when done. Works for sync and async handlers.""" orig = getattr(request.state, "current_user", None) + orig_api_token = getattr(request.state, "api_token", None) request.state.current_user = owner + request.state.api_token = False try: result = fn(*args, **kwargs) if asyncio.iscoroutine(result): @@ -49,6 +52,13 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs): return result finally: request.state.current_user = orig + if orig_api_token is None: + try: + delattr(request.state, "api_token") + except AttributeError: + pass + else: + request.state.api_token = orig_api_token def _scope_owner(request: Request, allowed: set[str]) -> str: @@ -146,7 +156,7 @@ def setup_codex_routes( @router.get("/plugin.zip") def plugin_zip(request: Request): - require_user(request) + require_authenticated_request(request) root = Path(__file__).resolve().parent.parent / "integrations" / "codex" if not root.exists(): raise HTTPException(404, "Codex plugin bundle not found") @@ -415,8 +425,8 @@ def setup_codex_routes( def _read_cookbook_state() -> dict: from pathlib import Path as _Path - import os as _os, json as _json - p = _Path(_os.environ.get("DATA_DIR", "data")) / "cookbook_state.json" + import json as _json + p = _Path(COOKBOOK_STATE_FILE) if not p.exists(): return {} try: @@ -724,7 +734,7 @@ def setup_codex_routes( import time as _t, json as _json from core.atomic_io import atomic_write_json from pathlib import Path as _Path - cookbook_state_path = _Path("/app/data/cookbook_state.json") + cookbook_state_path = _Path(COOKBOOK_STATE_FILE) try: state = _json.loads(cookbook_state_path.read_text(encoding="utf-8")) except Exception: @@ -762,7 +772,7 @@ def setup_claude_routes() -> APIRouter: @router.get("/plugin.zip") def plugin_zip(request: Request): - require_user(request) + require_authenticated_request(request) # Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump # README.md or other bundle metadata into the user's claude config dir. skills_root = Path(__file__).resolve().parent.parent / "integrations" / "claude" / "skills" diff --git a/routes/compare_routes.py b/routes/compare_routes.py index 35cd21289..ad42f1a89 100644 --- a/routes/compare_routes.py +++ b/routes/compare_routes.py @@ -12,6 +12,7 @@ import logging from core.database import Comparison, SessionLocal from core.session_manager import SessionManager from src.auth_helpers import get_current_user +from routes.session_routes import _reject_raw_endpoint_url_for_non_admin logger = logging.getLogger(__name__) @@ -38,6 +39,24 @@ def _owned_endpoint_by_url(db, base_url, owner): return owner_filter(q, ModelEndpoint, owner).first() +def _owned_endpoint_by_id(db, endpoint_id, owner): + """ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their + own rows + legacy null-owner "shared" rows); None otherwise. + + Preferred over _owned_endpoint_by_url for credential resolution: two visible + endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two + accounts on the same provider). A base_url-only match returns whichever row + sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session. + An id pins the exact registered endpoint, so /api/compare/start prefers it and + only falls back to URL matching for legacy / admin raw-URL callers. Owner + scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op). + """ + from core.database import ModelEndpoint + from src.auth_helpers import owner_filter + q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id) + return owner_filter(q, ModelEndpoint, owner).first() + + class RecordVoteRequest(BaseModel): prompt: str models: List[str] @@ -54,8 +73,10 @@ def setup_compare_routes(session_manager: SessionManager): prompt: str = Form(...), model_a: str = Form(...), model_b: str = Form(...), - endpoint_a: str = Form(...), - endpoint_b: str = Form(...), + endpoint_a: str = Form(""), + endpoint_b: str = Form(""), + endpoint_a_id: str = Form(""), + endpoint_b_id: str = Form(""), is_blind: str = Form("true"), ): """Create two ephemeral sessions and a comparison record. @@ -63,10 +84,10 @@ def setup_compare_routes(session_manager: SessionManager): Returns the comparison ID and the two session IDs so the client can fire two independent SSE streams to /api/chat_stream. """ + user = getattr(request.state, 'current_user', None) comp_id = str(uuid.uuid4()) sid_a = str(uuid.uuid4()) sid_b = str(uuid.uuid4()) - user = getattr(request.state, 'current_user', None) # Blind mapping: randomly assign left/right blind = str(is_blind).lower() == "true" @@ -87,31 +108,94 @@ def setup_compare_routes(session_manager: SessionManager): # de-anonymizing the comparison before the user votes (issue #1285). slot_name = {session_left: "Model A", session_right: "Model B"} - # Create ephemeral sessions (prefixed [CMP]) - for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]: + # SECURITY: resolve and validate BOTH endpoints before creating any + # session. Compare copies a registered endpoint's Authorization header + # into the [CMP] session, so validating one endpoint while creating its + # session, then rejecting the other, would leave a partial compare + # session behind with that header attached. Doing all the owner-scope + # resolution + raw-URL rejection up front means a 403 on either endpoint + # aborts the whole request with nothing created and no header copied. + from src.endpoint_resolver import build_chat_url, build_headers, normalize_base + resolved = [] + db = SessionLocal() + try: + for sid, model, endpoint, endpoint_id in [ + (sid_a, model_a, endpoint_a, endpoint_a_id), + (sid_b, model_b, endpoint_b, endpoint_b_id), + ]: + # Prefer an explicit endpoint id: it pins the EXACT registered + # endpoint (and its api_key), even when two endpoints visible to + # the caller share a base_url with different keys — a URL-only + # match would copy whichever row sorts first, i.e. possibly the + # wrong key. Fall back to URL resolution only for legacy / admin + # raw-URL callers that don't send an id. + eid = endpoint_id.strip() if isinstance(endpoint_id, str) else "" + if eid: + ep = _owned_endpoint_by_id(db, eid, user) + if ep is None: + # An id the caller can't see (wrong owner / deleted) must + # NOT silently fall back to a same-URL row with a different + # key — that's exactly the mix-up ids exist to prevent. + raise HTTPException(404, "Model endpoint not found") + # The id already resolved the endpoint; ignore any raw URL the + # caller also sent and dial the stored config instead. + endpoint = ep.base_url + elif not endpoint: + raise HTTPException( + 422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required" + ) + else: + # Resolve the supplied URL to a ModelEndpoint the caller owns + # (their own rows + legacy null-owner shared rows), scoped so a + # comparison can't borrow another user's private endpoint key. + base = normalize_base(endpoint) + ep = _owned_endpoint_by_url(db, base, user) + # Reject *unregistered* raw URLs for signed-in non-admins; a + # matched registered endpoint supplies an id so the caller can + # still compare endpoints they own. Blanket-rejecting here (the + # earlier `endpoint_id=None` call) locked non-admins out of + # compare entirely, since compare resolves endpoints by URL with + # no endpoint_id. Mirrors the gallery inpaint/harmonize checks. + # Raised here (phase 1), before any session exists. + _reject_raw_endpoint_url_for_non_admin( + request, user, str(ep.id) if ep is not None else None, endpoint + ) + # Bind the [CMP] session to the RESOLVED endpoint, not the raw + # caller-supplied string. When the URL matches a registered + # endpoint visible to the caller, use that row's own normalized + # base URL (the same value owner scoping + endpoint validation + # already vetted) so the session dials exactly where the stored + # config points. The raw `endpoint` only survives for callers + # allowed to pass one — admins / single-user mode, where + # `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep` + # is None. Mirrors the registered-endpoint path in session_routes. + session_endpoint_url = ( + build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint + ) + # Headers come only from a matched endpoint's key; None when + # `ep` is None (raw admin URL or no match), so a comparison can + # never inherit another user's key/headers. + headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None + resolved.append((sid, model, session_endpoint_url, headers)) + finally: + db.close() + + # Both endpoints validated — only now create the ephemeral [CMP] + # sessions and copy any resolved headers. + for sid, model, session_endpoint_url, headers in resolved: name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}" session_manager.create_session( session_id=sid, name=name, - endpoint_url=endpoint, + endpoint_url=session_endpoint_url, model=model, rag=False, owner=user, ) - # Copy API key from endpoint config - db = SessionLocal() - try: - from src.endpoint_resolver import build_headers, normalize_base - # Find matching endpoint by URL, scoped to the caller so a - # comparison can't borrow another user's private endpoint key. - base = normalize_base(endpoint) - ep = _owned_endpoint_by_url(db, base, user) - if ep and ep.api_key: - s = session_manager.sessions.get(sid) - if s: - s.headers = build_headers(ep.api_key, ep.base_url) - finally: - db.close() + if headers: + s = session_manager.sessions.get(sid) + if s: + s.headers = headers # Store comparison record db = SessionLocal() @@ -121,8 +205,12 @@ def setup_compare_routes(session_manager: SessionManager): prompt=prompt, model_a=model_a, model_b=model_b, - endpoint_a=endpoint_a, - endpoint_b=endpoint_b, + # Record the URL the session actually dials. For URL callers this + # is their raw input; for id-only callers (empty endpoint_a/_b) + # fall back to the resolved endpoint URL so the column stays + # meaningful and non-null. resolved is in [a, b] order. + endpoint_a=endpoint_a or resolved[0][2], + endpoint_b=endpoint_b or resolved[1][2], is_blind=blind, blind_mapping=json.dumps(mapping), owner=user, diff --git a/routes/contacts_routes.py b/routes/contacts_routes.py index 8a90cf473..58a57a1e1 100644 --- a/routes/contacts_routes.py +++ b/routes/contacts_routes.py @@ -25,9 +25,10 @@ from src.url_safety import check_outbound_url logger = logging.getLogger(__name__) -DATA_DIR = Path(__file__).resolve().parent.parent / "data" -SETTINGS_FILE = DATA_DIR / "settings.json" -LOCAL_CONTACTS_FILE = DATA_DIR / "contacts.json" +from src.constants import DATA_DIR as _DATA_DIR, SETTINGS_FILE as _SETTINGS_FILE, CONTACTS_FILE as _CONTACTS_FILE +DATA_DIR = Path(_DATA_DIR) +SETTINGS_FILE = Path(_SETTINGS_FILE) +LOCAL_CONTACTS_FILE = Path(_CONTACTS_FILE) def _load_settings(): @@ -728,8 +729,11 @@ def setup_contacts_routes(): @router.post("/import") async def import_vcf(data: dict, _admin: str = Depends(require_admin)): """Import contacts from .vcf or CSV. Body: {"vcf": "..."} or {"csv": "..."}.""" - text = data.get("vcf") or data.get("text") or "" - csv_text = data.get("csv") or "" + # Coerce defensively: a non-string vcf/text/csv (e.g. a number or list + # in the JSON body) would otherwise reach .strip() and 500 with an + # AttributeError instead of degrading to a clean "no data" response. + text = str(data.get("vcf") or data.get("text") or "") + csv_text = str(data.get("csv") or "") if text.strip(): if "BEGIN:VCARD" not in text.upper(): return {"success": False, "error": "No vCard data found"} diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index 298a336d6..53bdde80e 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -11,6 +11,9 @@ import shlex from fastapi import HTTPException from pydantic import BaseModel +from routes._validators import validate_remote_host, validate_ssh_port +from core.platform_compat import _ssh_exec_argv + logger = logging.getLogger(__name__) @@ -28,20 +31,24 @@ _LOCAL_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _OLLAMA_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$") # Include pattern is a glob: allow typical safe glyphs only. _INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$") -# Remote host: user@host (optionally with :port-free hostname parts). -_REMOTE_HOST_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$") # HF tokens and API tokens are url-safe base64-like. _TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$") # Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef". # Anything beyond plain alphanumerics + dash + underscore could break out # of the shell/PowerShell contexts the value lands in. _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") -_SSH_PORT_RE = re.compile(r"^\d{1,5}$") _GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$") # A download target directory. Absolute or ~-relative path; safe path glyphs -# only (no quotes, shell metacharacters, or spaces) since it lands in a shell -# command. A leading ~ is expanded to $HOME at command-build time. -_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$") +# only (no quotes or shell metacharacters). Spaces are allowed because command +# builders pass the value through quoted shell/Python contexts. The character +# class uses ``\w`` — Unicode word characters under Python 3's default str +# matching — so non-ASCII folder names pass validation too: Cyrillic, accented +# Latin, CJK, e.g. ``/Volumes/Модели`` or ``D:\AI Models\Модели``. This stays +# shell-safe: none of ``; & | ` $ '' "" () {}`` newlines etc. are in ``[\w. -]``, +# so injection vectors remain rejected. A leading ~ is expanded to $HOME at +# command-build time. (Drive letters stay ASCII: ``[A-Za-z]:``.) +_LOCAL_DIR_RE = re.compile(r"^~?(?:/[\w. -]*)+$|^~$") +_WINDOWS_LOCAL_DIR_RE = re.compile(r"^[A-Za-z]:[\\/](?:[\w. -]+(?:[\\/][\w. -]+)*[\\/]?)?$") _WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]") @@ -75,14 +82,6 @@ def _validate_include(v: str | None) -> str | None: return v -def _validate_remote_host(v: str | None) -> str | None: - if v is None or v == "": - return None - if not _REMOTE_HOST_RE.match(v): - raise HTTPException(400, "Invalid remote_host — must be user@host, no SSH option syntax") - return v - - def _validate_token(v: str | None) -> str | None: if v is None or v == "": return None @@ -94,23 +93,22 @@ def _validate_token(v: str | None) -> str | None: def _validate_local_dir(v: str | None) -> str | None: if v is None or v == "": return None + if len(v) >= 2 and v[0] == v[-1] and v[0] in {"'", '"'}: + v = v[1:-1] v = v.rstrip("/") or "/" - if not _LOCAL_DIR_RE.match(v): - raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no spaces or shell metacharacters") + if not (_LOCAL_DIR_RE.match(v) or _WINDOWS_LOCAL_DIR_RE.match(v)): + raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no shell metacharacters") + # Reject path segments that start with '-' (option injection). '-' is in the + # allowlist, so a dir like ``/models/-rf`` or ``D:\models\-rf`` could be read + # as a CLI flag by hf/etc. — and quoting does NOT stop a value from being + # parsed as an option. This is the one residual that command-build-time + # quoting can't cover, so the guard lives here, keeping the safety wholly + # inside the validator rather than relying on consumers. + if any(seg.startswith("-") for seg in re.split(r"[\\/]", v) if seg): + raise HTTPException(400, "Invalid local_dir — path segments cannot start with '-'") return v -def _validate_ssh_port(v: str | None) -> str | None: - if v is None or v == "": - return None - if not _SSH_PORT_RE.fullmatch(str(v)): - raise HTTPException(400, "Invalid ssh_port") - port = int(v) - if port < 1 or port > 65535: - raise HTTPException(400, "Invalid ssh_port") - return str(port) - - def _validate_gpus(v: str | None) -> str | None: if v is None or v == "": return None @@ -122,7 +120,7 @@ def _validate_gpus(v: str | None) -> str | None: def _shell_path(p: str) -> str: """Render a validated path for a double-quoted shell context, expanding a leading ~ to $HOME (single quotes wouldn't expand it). Safe because - _validate_local_dir already restricts the charset.""" + _validate_local_dir already rejects quotes and shell metacharacters.""" if p == "~": return '"$HOME"' if p.startswith("~/"): @@ -195,6 +193,20 @@ def _pip_install_attempt(pip_cmd: str) -> str: ) +def _pip_command(python_cmd: str) -> str: + """Return a pip command for either a pip executable or a Python executable.""" + cmd = python_cmd.strip() + if " -m pip" in cmd or cmd in {"pip", "pip3"}: + return python_cmd + if cmd in {"python", "python3", "python.exe"} or cmd.endswith(("/python", "/python3", "\\python.exe")): + return f"{python_cmd} -m pip" + return python_cmd + + +def _pip_break_system_packages_check(pip_cmd: str) -> str: + return f"{pip_cmd} install --help 2>/dev/null | grep -q -- --break-system-packages" + + def _pip_install_fallback_chain(package: str, *, python_cmd: str = "python3 -m pip", upgrade: bool = False) -> str: """Build a bash pip install fallback chain that surfaces errors. @@ -213,30 +225,37 @@ def _pip_install_fallback_chain(package: str, *, python_cmd: str = "python3 -m p # before being embedded in the install command. Plain names (e.g. # ``huggingface_hub``) are returned unchanged by ``shlex.quote``. pkg = shlex.quote(package) - if IS_WINDOWS and "llama-cpp-python" in package: + # llama-cpp-python source builds are brittle on older distro pip/packaging + # stacks (common on WSL images). Prefer the prebuilt wheel index whenever + # this package is requested so dependency-install tasks are reliable. + if "llama-cpp-python" in package: pkg += " --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu" - base = _pip_install_attempt(f"{python_cmd} install -q{upgrade_flag} {pkg}") - user = _pip_install_attempt(f"{python_cmd} install --user --break-system-packages -q{upgrade_flag} {pkg}") + pip_cmd = _pip_command(python_cmd) + base = _pip_install_attempt(f"{pip_cmd} install -q{upgrade_flag} {pkg}") + user = _pip_install_attempt(f"{pip_cmd} install --user -q{upgrade_flag} {pkg}") + user_break_system = _pip_install_attempt(f"{pip_cmd} install --user --break-system-packages -q{upgrade_flag} {pkg}") + user_fallback = f"( {user} || {{ {_pip_break_system_packages_check(pip_cmd)} && {user_break_system}; }} )" # Derive the python executable for the venv detection check. # Must use the same interpreter that pip belongs to; hardcoding # python3 breaks when pip lives in a venv that only has "python". - if " -m pip" in python_cmd: - python_exe = python_cmd.replace(" -m pip", "") - elif python_cmd.strip() == "pip": + if " -m pip" in pip_cmd: + python_exe = pip_cmd.replace(" -m pip", "") + elif pip_cmd.strip() == "pip": python_exe = "python" - elif python_cmd.strip() == "pip3": + elif pip_cmd.strip() == "pip3": python_exe = "python3" else: python_exe = "python3" venv_check = f'{python_exe} -c "import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)"' - # Negated: `! venv_check` succeeds (exit 0) when NOT in a venv → `&&` tries - # --user. When IN a venv `! venv_check` fails → `&&` skips --user and the + # Negated: `! venv_check` succeeds (exit 0) when NOT in a venv -> `&&` tries + # --user. When IN a venv `! venv_check` fails -> `&&` skips --user and the # group exits non-zero, propagating the base-install failure instead of # masking it as success (the `|| { venv_check || … }` shape from #903 # swallowed the exit code because venv_check's exit-0 became the group's - # result). - return f"{base} || {{ ! {venv_check} && {user}; }}" + # result). `--break-system-packages` is only attempted when the active pip + # supports it; older pip versions abort with "no such option" otherwise. + return f"{base} || {{ ! {venv_check} && {user_fallback}; }}" def _venv_safe_local_pip_install_cmd(cmd: str, *, local: bool, in_venv: bool) -> str: @@ -267,6 +286,55 @@ def _venv_safe_local_pip_install_cmd(cmd: str, *, local: bool, in_venv: bool) -> return shlex.join(stripped) +def _pip_install_command_without_break_system_packages(cmd: str) -> str: + try: + parts = shlex.split(cmd) + except ValueError: + return cmd + stripped = [part for part in parts if part != "--break-system-packages"] + return shlex.join(stripped) + + +def _pip_install_help_check_from_cmd(cmd: str) -> str | None: + try: + parts = shlex.split(cmd) + except ValueError: + return None + try: + install_index = parts.index("install") + except ValueError: + return None + if install_index <= 0: + return None + pip_prefix = parts[:install_index] + return f"{shlex.join(pip_prefix + ['install', '--help'])} 2>/dev/null | grep -q -- --break-system-packages" + + +def _append_pip_install_runner_lines(runner_lines: list[str], cmd: str) -> None: + """Append a pip install command, guarding --break-system-packages support. + + The Dependencies UI may submit ``python3 -m pip install --user + --break-system-packages ...`` for non-venv installs. That flag is useful on + PEP-668-locked distros, but older pip (including Ubuntu 22.04's apt pip in + the NVIDIA CUDA base image) aborts with "no such option". Branch at runner + time so stale browser JS and remote targets are handled by the server too. + """ + if "--break-system-packages" not in (cmd or ""): + runner_lines.append(cmd) + return + help_check = _pip_install_help_check_from_cmd(cmd) + without_break = _pip_install_command_without_break_system_packages(cmd) + if not help_check or without_break == cmd: + runner_lines.append(cmd) + return + runner_lines.append(f"if {help_check}; then") + runner_lines.append(f" {cmd}") + runner_lines.append("else") + runner_lines.append(' echo "[odysseus] pip does not support --break-system-packages; installing without it."') + runner_lines.append(f" {without_break}") + runner_lines.append("fi") + + def _user_shell_path_bootstrap() -> list[str]: return [ 'ODYSSEUS_USER_SHELL="${SHELL:-}"', @@ -275,11 +343,14 @@ def _user_shell_path_bootstrap() -> list[str]: ' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi', 'fi', 'command -v python3 >/dev/null 2>&1 || python3() { python "$@"; }', + 'command -v python >/dev/null 2>&1 || python() { python3 "$@"; }', ] -def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str: - """Build the standalone Python scanner used by /api/model/cached.""" +def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache: str | None = None) -> str: + """Build the standalone Python scanner used by /api/model/cached. + Allows for an additional HuggingFace cache path to be scanned (i.e. Windows HF cache for local WSL envs.) + """ lines = [ "import json, os, re, shutil, subprocess, urllib.request", "models = []", @@ -310,6 +381,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str: " for root, dirs, fns in safe_walk(base):", " for fn in sorted(fns):", " if not fn.lower().endswith('.gguf'): continue", + " if fn.startswith('._'): continue # macOS AppleDouble sidecar, not a real GGUF", " fp = os.path.join(root, fn)", " try: size = os.path.getsize(fp)", " except Exception: size = 0", @@ -359,6 +431,21 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str: " if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True", " for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)", " models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})", + "def hf_cache_paths():", + " candidates = []", + " def add(p):", + " if not p: return", + " p = os.path.expanduser(p)", + " if p not in candidates: candidates.append(p)", + " add(os.environ.get('HUGGINGFACE_HUB_CACHE'))", + " hf_home = os.environ.get('HF_HOME')", + " if hf_home: add(os.path.join(hf_home, 'hub'))", + " add('~/.cache/huggingface/hub')", + " # Docker images mount ./data/huggingface at /app/.cache/huggingface.", + " # When HOME is /root, expanduser() misses that persisted cache.", + " add('/app/.cache/huggingface/hub')", + f" add({add_hf_cache!r})" if add_hf_cache else "", + " return candidates", "def scan_dir(p):", " if not os.path.isdir(p) or not safe_path(p): return", " for d in sorted(os.listdir(p)):", @@ -422,7 +509,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str: " seen.add(name)", " models.append({'repo_id':name,'size_bytes':size_bytes,'nb_files':1,'has_incomplete':False,'path':'ollama','backend':'ollama','is_ollama':True})", " return", - "scan_hf(os.path.expanduser('~/.cache/huggingface/hub'))", + "for _hf_cache in hf_cache_paths(): scan_hf(_hf_cache)", "scan_ollama()", "scan_ollama_api()", ] @@ -697,6 +784,7 @@ def _llama_cpp_rebuild_cmd() -> str: class ModelDownloadRequest(BaseModel): repo_id: str + backend: str | None = None # "hf" (default) or "ollama" include: str | None = None # glob pattern e.g. "*Q4_K_M*" hf_token: str | None = None env_prefix: str | None = None # e.g. "source ~/venv/bin/activate" @@ -975,3 +1063,40 @@ def _diagnose_serve_output(text: str) -> dict | None: "suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}], } return None + + +async def run_ssh_command_async( + remote: str, + ssh_port: str | None, + remote_cmd: str, + *, + timeout: float, + connect_timeout: int | None = None, + strict_host_key_checking: bool | None = None, + stdin_data: bytes | None = None, +) -> tuple[int, bytes, bytes]: + """Run an ssh command with centralized timeout and stderr/stdout capture. + Async version of core.platform_compat.run_ssh_command_sync. + """ + import asyncio + proc = await asyncio.create_subprocess_exec( + *_ssh_exec_argv( + remote, + ssh_port, + remote_cmd=remote_cmd, + connect_timeout=connect_timeout, + strict_host_key_checking=strict_host_key_checking, + ), + stdin=asyncio.subprocess.PIPE if stdin_data is not None else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(input=stdin_data), timeout=timeout + ) + except asyncio.TimeoutError: + proc.kill() + await proc.communicate() + raise + return proc.returncode or 0, stdout, stderr diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 04ad05522..36f98aeae 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -15,14 +15,15 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, Request, Depends from src.auth_helpers import require_user +from src.constants import COOKBOOK_STATE_FILE from pydantic import BaseModel from core.middleware import require_admin +from routes._validators import validate_remote_host, validate_ssh_port from core.platform_compat import ( IS_WINDOWS, detached_popen_kwargs, find_bash, - git_bash_path, kill_process_tree, pid_alive, safe_chmod, @@ -33,15 +34,13 @@ from routes.shell_routes import TMUX_LOG_DIR logger = logging.getLogger(__name__) from routes.cookbook_helpers import ( - _SSH_PORT_RE, _REMOTE_HOST_RE, _SESSION_ID_RE, - _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_remote_host, _validate_token, - _validate_local_dir, _validate_ssh_port, _validate_gpus, _shell_path, + _SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token, + _validate_local_dir, _validate_gpus, _shell_path, _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, _safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, _append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script, - _append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain, - _pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd, - _diagnose_serve_output, + _ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache, + _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd, ModelDownloadRequest, ServeRequest, ) @@ -56,7 +55,7 @@ _HF_TOKEN_STATUS_SNIPPET = ( def setup_cookbook_routes() -> APIRouter: router = APIRouter(tags=["cookbook"]) - _cookbook_state_path = Path(os.environ.get("DATA_DIR", "data")) / "cookbook_state.json" + _cookbook_state_path = Path(COOKBOOK_STATE_FILE) def _mask_secret(value: str) -> str: if not value: @@ -83,6 +82,127 @@ def setup_cookbook_routes() -> APIRouter: task["payload"].pop("hf_token", None) return state + def _diagnose_serve_output(text: str) -> dict | None: + """Server-side mirror of the Cookbook UI's common serve diagnoses. + + The browser uses cookbook-diagnosis.js for clickable fixes. This gives + the agent/tool path the same structured signal so it can retry with an + adjusted command instead of guessing from raw tmux output. + """ + if not text: + return None + tail = text[-6000:] + patterns = [ + ( + r"No available memory for the cache blocks|Available KV cache memory:.*-", + "No GPU memory left for KV cache after loading model.", + [ + {"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"}, + {"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"}, + ], + ), + ( + r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization", + "GPU ran out of memory during startup or warmup.", + [ + {"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"}, + {"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"}, + {"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"}, + ], + ), + ( + r"not divisib|must be divisible|attention heads.*divisible", + "Tensor parallel size is incompatible with the model.", + [ + {"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"}, + {"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"}, + ], + ), + ( + r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context", + "Context length is too large for available GPU memory.", + [ + {"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"}, + {"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"}, + ], + ), + ( + r"enable-auto-tool-choice requires --tool-call-parser", + "Auto tool choice requires an explicit tool call parser.", + [{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}], + ), + ( + r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not", + "Model requires custom code or newer model support.", + [{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}], + ), + ( + r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer", + "vLLM/Transformers kernel package mismatch.", + [{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}], + ), + ( + r"Address already in use|bind.*address.*in use", + "Port is already in use.", + [{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}], + ), + ( + r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid", + "No GPUs are visible to the serve process.", + [{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}], + ), + ( + r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available", + "vLLM could not find a supported GPU (CUDA or ROCm). " + "This machine may have integrated or unsupported graphics only.", + [ + {"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"}, + {"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"}, + ], + ), + ( + r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed", + "vLLM is not installed or not in PATH on this server.", + [{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}], + ), + ( + r"sglang.*command not found|No module named sglang|SGLang is not installed", + "SGLang is not installed or not in PATH on this server.", + [{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}], + ), + ( + r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'|git: command not found|cmake: command not found", + "llama.cpp / llama-cpp-python dependencies are missing.", + [{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"No GGUF found on this host|no \.gguf file|No GGUF file found", + "No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.", + [{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}], + ), + ( + r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers", + "Diffusion serving requires PyTorch and diffusers.", + [{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}], + ), + ( + r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review", + "Model access is gated or unauthorized.", + [{"label": "set HF token and request model access on HuggingFace", "op": "manual"}], + ), + ] + for pattern, message, suggestions in patterns: + if re.search(pattern, tail, re.I): + return {"message": message, "suggestions": suggestions} + if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search( + r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I + ): + return { + "message": "Python traceback detected during serve startup.", + "suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}], + } + return None + def _state_for_client(state): """Return cookbook state without raw secrets for browser clients.""" _strip_task_secrets(state) @@ -176,7 +296,6 @@ def setup_cookbook_routes() -> APIRouter: safe_chmod(key_path.with_suffix(".pub"), 0o644) return {"ok": True, "public_key": _read_cookbook_public_key()} - def _needs_binary(cmd: str, binary: str) -> bool: return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or "")) @@ -237,8 +356,8 @@ def setup_cookbook_routes() -> APIRouter: # POSIX form + shell-quoting so drive paths / spaces survive. inner = TMUX_LOG_DIR / f"{session_id}_run.sh" inner.write_text("\n".join(bash_lines) + "\n", encoding="utf-8") - lp = shlex.quote(git_bash_path(log_path)) - ip = shlex.quote(git_bash_path(inner)) + lp = shlex.quote(log_path.as_posix()) + ip = shlex.quote(inner.as_posix()) script_path = TMUX_LOG_DIR / f"{session_id}.sh" script_path.write_text( f"bash {ip} > {lp} 2>&1\n", @@ -279,24 +398,33 @@ def setup_cookbook_routes() -> APIRouter: require_admin(request) # Defence-in-depth: even though this endpoint is admin-gated, refuse # values that would land in shell contexts with metacharacters. - _validate_repo_id(req.repo_id) - _validate_include(req.include) - _validate_remote_host(req.remote_host) - req.ssh_port = _validate_ssh_port(req.ssh_port) + backend = (req.backend or "").strip().lower() + is_ollama_download = backend == "ollama" or ("/" not in req.repo_id and ":" in req.repo_id) + if is_ollama_download: + _validate_serve_model_id(req.repo_id) + req.include = None + req.local_dir = None + else: + _validate_repo_id(req.repo_id) + _validate_include(req.include) + validate_remote_host(req.remote_host) + req.ssh_port = validate_ssh_port(req.ssh_port) req.local_dir = _validate_local_dir(req.local_dir) - req.hf_token = req.hf_token or _load_stored_hf_token() + req.hf_token = "" if is_ollama_download else (req.hf_token or _load_stored_hf_token()) _validate_token(req.hf_token) TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) session_id = f"cookbook-{uuid.uuid4().hex[:8]}" wrapper_script = TMUX_LOG_DIR / f"{session_id}.sh" - # When a download directory is set, target a per-model subfolder under it - # (/) so the flat-directory cache scan lists it as its own - # model. Without it, hf/snapshot_download falls back to the HF cache. - _dl_short = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id - _dl_base = (req.local_dir.rstrip("/") + "/" + _dl_short) if req.local_dir else None - _dl_shell = _shell_path(_dl_base) if _dl_base else None # for hf CLI / bash - _dl_pyarg = (", local_dir=os.path.expanduser(" + repr(_dl_base) + ")") if _dl_base else "" + # Custom download dir: point the HF cache at /hub via env vars + # (HF_HOME + HUGGINGFACE_HUB_CACHE) instead of --local-dir. local_dir + # produces a flat layout (//) and the local-dir + # bookkeeping files (.cache/huggingface/.gitignore.lock), and it + # also breaks robust resume on flaky transfers — the blob-based hub + # cache survives SSL ReadError mid-stream by reusing .incomplete, + # local_dir does not. See issue #2722. + _dl_hf_home_shell = _shell_path(req.local_dir.rstrip("/")) if req.local_dir else None + _dl_pyarg = "" # snapshot_download honors the env vars too — no kwarg needed # Build the hf download command. Redirection to suppress the interactive # "update available? [Y/n]" prompt is added per-platform further down @@ -304,8 +432,7 @@ def setup_cookbook_routes() -> APIRouter: hf_cmd = f"hf download {req.repo_id}" if req.include: hf_cmd += f" --include '{req.include}'" - if _dl_shell: - hf_cmd += f" --local-dir {_dl_shell}" + ollama_cmd = f"ollama pull {shlex.quote(req.repo_id)}" # Build the shell wrapper — runs hf download directly in tmux (which is a TTY) # No script/tee needed — we'll use tmux capture-pane to read output @@ -313,8 +440,15 @@ def setup_cookbook_routes() -> APIRouter: lines.extend(_user_shell_path_bootstrap()) if req.hf_token: lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'") + if _dl_hf_home_shell and not is_ollama_download: + # Make hf download / snapshot_download honor the chosen dir via the + # standard HF cache (gives us the models--org--name/blobs/... layout + # with resumable .incomplete blobs). + lines.append(f"export HF_HOME={_dl_hf_home_shell}") + lines.append(f"export HUGGINGFACE_HUB_CACHE={_dl_hf_home_shell}/hub") + lines.append(f"export HF_HUB_CACHE={_dl_hf_home_shell}/hub") # Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH - lines.append('export PATH="$HOME/.local/bin:$PATH"') + lines.append('export PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') # When Odysseus runs from a venv (e.g. native macOS install), put its bin # on PATH so the tmux shell finds the bundled `hf`/`python3` without an # activated venv. Local bash runs only — meaningless over SSH. @@ -325,14 +459,25 @@ def setup_cookbook_routes() -> APIRouter: # throughput. Retries set disable_hf_transfer to fall back to the plain, # slower-but-reliable downloader (resumes cleanly from the .incomplete files). # Use `python3 -m pip` not `pip` — macOS has no bare `pip` command. - lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', upgrade=True)}") - if req.disable_hf_transfer: - lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") - lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + if is_ollama_download: + lines.append('if command -v ollama >/dev/null 2>&1; then') + lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}') + lines.append('elif command -v docker >/dev/null 2>&1; then') + lines.append(' ODYSSEUS_OLLAMA_CONTAINER="$(docker ps --format \'{{.Names}}\' 2>/dev/null | grep -E \'^(ollama-rocm|ollama-test)$\' | head -1)"') + lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then') + lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}') + lines.append(' fi') + lines.append('fi') + lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi') else: - lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer')}") - lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") + lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', upgrade=True)}") + if req.disable_hf_transfer: + lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") + lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + else: + lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer')}") + lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") remote = req.remote_host # None for local is_windows = req.platform == "windows" @@ -354,37 +499,48 @@ def setup_cookbook_routes() -> APIRouter: ps_lines = [] ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') - ps_lines.append('$env:PYTHONIOENCODING = "utf-8"') - ps_lines.append('$env:PYTHONUTF8 = "1"') if req.hf_token: ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") + if req.local_dir and not is_ollama_download: + # Mirror the bash branch — point the HF cache at the user's dir + # via env vars instead of --local-dir, so resume works on flaky + # transfers (issue #2722). + _dl_ps = _ps_squote(req.local_dir.rstrip("/")) + ps_lines.append(f"$env:HF_HOME = '{_dl_ps}'") + ps_lines.append(f"$env:HUGGINGFACE_HUB_CACHE = '{_dl_ps}/hub'") + ps_lines.append(f"$env:HF_HUB_CACHE = '{_dl_ps}/hub'") if req.env_prefix: ps_lines.append(_safe_env_prefix(req.env_prefix)) - # Try hf CLI, fall back to Python huggingface_hub, then auto-install - ps_lines.append('try {{') - ps_lines.append(' $hfPath = Get-Command hf -ErrorAction SilentlyContinue') - ps_lines.append(' if ($hfPath) {{') - # Pipe $null to stdin to suppress interactive "update available? [Y/n]" prompt - ps_lines.append(f' $null | {hf_cmd}') - ps_lines.append(' }} else {{') - ps_lines.append(' python -c "import huggingface_hub" 2>$null') - ps_lines.append(' if ($LASTEXITCODE -eq 0) {{') - ps_lines.append(' Write-Host "hf CLI not found, using Python huggingface_hub..."') - ps_lines.append(' python -m pip install -q hf_transfer 2>$null') - ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') - ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") - ps_lines.append(' }} else {{') - ps_lines.append(' Write-Host "Installing huggingface-hub..."') - ps_lines.append(' python -m pip install -q huggingface-hub hf_transfer') - ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') - ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") - ps_lines.append(' }}') - ps_lines.append(' }}') - ps_lines.append(' if ($LASTEXITCODE -eq 0) {{ Write-Host ""; Write-Host "DOWNLOAD_OK" }}') - ps_lines.append(' else {{ Write-Host ""; Write-Host "DOWNLOAD_FAILED (exit $LASTEXITCODE)" }}') - ps_lines.append('}} catch {{') - ps_lines.append(' Write-Host ""; Write-Host "DOWNLOAD_FAILED ($_)"') - ps_lines.append('}}') + if is_ollama_download: + ps_lines.append('if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) { Write-Host "ERROR: Ollama not found. Install from https://ollama.com/download/windows"; exit 127 }') + ps_lines.append(f"$null | ollama pull '{_ps_squote(req.repo_id)}'") + ps_lines.append('if ($LASTEXITCODE -eq 0) { Write-Host ""; Write-Host "DOWNLOAD_OK" } else { Write-Host ""; Write-Host "DOWNLOAD_FAILED (exit $LASTEXITCODE)" }') + else: + # Try hf CLI, fall back to Python huggingface_hub, then auto-install + ps_lines.append('try {{') + ps_lines.append(' $hfPath = Get-Command hf -ErrorAction SilentlyContinue') + ps_lines.append(' if ($hfPath) {{') + # Pipe $null to stdin to suppress interactive "update available? [Y/n]" prompt + ps_lines.append(f' $null | {hf_cmd}') + ps_lines.append(' }} else {{') + ps_lines.append(' python -c "import huggingface_hub" 2>$null') + ps_lines.append(' if ($LASTEXITCODE -eq 0) {{') + ps_lines.append(' Write-Host "hf CLI not found, using Python huggingface_hub..."') + ps_lines.append(' python -m pip install -q hf_transfer 2>$null') + ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') + ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") + ps_lines.append(' }} else {{') + ps_lines.append(' Write-Host "Installing huggingface-hub..."') + ps_lines.append(' python -m pip install -q huggingface-hub hf_transfer') + ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') + ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") + ps_lines.append(' }}') + ps_lines.append(' }}') + ps_lines.append(' if ($LASTEXITCODE -eq 0) {{ Write-Host ""; Write-Host "DOWNLOAD_OK" }}') + ps_lines.append(' else {{ Write-Host ""; Write-Host "DOWNLOAD_FAILED (exit $LASTEXITCODE)" }}') + ps_lines.append('}} catch {{') + ps_lines.append(' Write-Host ""; Write-Host "DOWNLOAD_FAILED ($_)"') + ps_lines.append('}}') ps_lines.append(f'Remove-Item -Force "$HOME\\{remote_runner}" -ErrorAction SilentlyContinue') runner_path = TMUX_LOG_DIR / f"{session_id}_run.ps1" runner_path.write_text("\r\n".join(ps_lines) + "\r\n", encoding="utf-8") @@ -415,6 +571,10 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append("deactivate 2>/dev/null; hash -r") if req.hf_token: runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'") + if _dl_hf_home_shell and not is_ollama_download: + runner_lines.append(f"export HF_HOME={_dl_hf_home_shell}") + runner_lines.append(f"export HUGGINGFACE_HUB_CACHE={_dl_hf_home_shell}/hub") + runner_lines.append(f"export HF_HUB_CACHE={_dl_hf_home_shell}/hub") if req.env_prefix: runner_lines.append(_safe_env_prefix(req.env_prefix)) else: @@ -425,42 +585,67 @@ def setup_cookbook_routes() -> APIRouter: 'done' ) # Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH - runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') + runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') # Install hf CLI + optional hf_transfer best-effort. Retries disable # hf_transfer because the Rust parallel path is fast but has been # flaky near the end of very large multi-file downloads. # Use --break-system-packages on PEP-668 systems (Arch, newer Debian) so it doesn't bail. - runner_lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='pip', upgrade=True)}") - if req.disable_hf_transfer: - runner_lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") - runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + if is_ollama_download: + runner_lines.append('if command -v ollama >/dev/null 2>&1; then') + runner_lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}') + runner_lines.append('elif command -v docker >/dev/null 2>&1; then') + runner_lines.append(' ODYSSEUS_OLLAMA_CONTAINER="$(docker ps --format \'{{.Names}}\' 2>/dev/null | grep -E \'^(ollama-rocm|ollama-test)$\' | head -1)"') + runner_lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then') + runner_lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}') + runner_lines.append(' fi') + runner_lines.append('fi') + runner_lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi') else: - runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}") - runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") - # Surface whether the HF token actually reached THIS server, so a gated - # download's "not authorized" failure can be told apart from a missing - # token (the token is masked — we only print applied / not-set). - runner_lines.append(_HF_TOKEN_STATUS_SNIPPET) - # Try hf CLI first, fall back to Python huggingface_hub, then auto-install - runner_lines.append('if command -v hf &>/dev/null; then') - # < /dev/null suppresses interactive "update available? [Y/n]" prompt - runner_lines.append(f' {hf_cmd} < /dev/null') - runner_lines.append('elif python3 -c "import huggingface_hub" 2>/dev/null; then') - runner_lines.append(' echo "hf CLI not found, using Python huggingface_hub..."') - runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"') - runner_lines.append('else') - runner_lines.append(' echo "Installing huggingface-hub and dependencies..."') - runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null') - if req.disable_hf_transfer: - runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null') - runner_lines.append(' export HF_HUB_ENABLE_HF_TRANSFER=0') + runner_lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', python_cmd='pip', upgrade=True)}") + if req.disable_hf_transfer: + runner_lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") + runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + else: + runner_lines.append(f"python3 -c 'import hf_transfer' 2>/dev/null || {_pip_install_fallback_chain('hf_transfer', python_cmd='pip')}") + runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") + # Surface whether the HF token actually reached THIS server, so a gated + # download's "not authorized" failure can be told apart from a missing + # token (the token is masked — we only print applied / not-set). + runner_lines.append(_HF_TOKEN_STATUS_SNIPPET) + # Wrap the download in a retry loop. Large HF/Ollama transfers can + # hit transient network failures; both backends resume cached partials. + mw = 4 if req.disable_hf_transfer else 8 + runner_lines.append('_max_retries=10; _attempt=0; _ec=0') + runner_lines.append('while [ $_attempt -lt $_max_retries ]; do') + runner_lines.append(' _attempt=$((_attempt+1))') + if is_ollama_download: + runner_lines.append(' eval "$ODYSSEUS_OLLAMA_PULL_CMD" < /dev/null') else: - runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null') - runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") - runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={4 if req.disable_hf_transfer else 8})"') - runner_lines.append('fi') - runner_lines.append('_ec=$?; if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec)"; fi') + runner_lines.append(' if command -v hf &>/dev/null; then') + runner_lines.append(f' {hf_cmd} < /dev/null') + runner_lines.append(' elif python3 -c "import huggingface_hub" 2>/dev/null; then') + runner_lines.append(' [ $_attempt -eq 1 ] && echo "hf CLI not found, using Python huggingface_hub..."') + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={mw})"') + runner_lines.append(' else') + runner_lines.append(' echo "Installing huggingface-hub and dependencies..."') + runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null') + if req.disable_hf_transfer: + runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null') + runner_lines.append(' export HF_HUB_ENABLE_HF_TRANSFER=0') + else: + runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null') + runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers={mw})"') + runner_lines.append(' fi') + runner_lines.append(' _ec=$?') + runner_lines.append(' if [ $_ec -eq 0 ]; then break; fi') + runner_lines.append(' if [ $_attempt -lt $_max_retries ]; then') + runner_lines.append(' echo ""; echo "Download attempt $_attempt failed (exit $_ec) — retrying in 30s..."') + runner_lines.append(' sleep 30') + runner_lines.append(' fi') + runner_lines.append('done') + runner_lines.append('if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec after $_attempt attempts)"; fi') runner_lines.append(f"rm -f {remote_runner}") runner_lines.append('exec "${SHELL:-/bin/bash}"') runner_path = TMUX_LOG_DIR / f"{session_id}_run.sh" @@ -486,23 +671,30 @@ def setup_cookbook_routes() -> APIRouter: lines.append("deactivate 2>/dev/null; hash -r") # Show whether the HF token reached this run (masked) — tells a gated # "not authorized" failure apart from a missing token. - lines.append(_HF_TOKEN_STATUS_SNIPPET) - if IS_WINDOWS: - # Detached path: no controlling TTY, so skip `< /dev/null` - # (handled by Popen stdin=DEVNULL) and don't keep a shell open. - lines.append(hf_cmd) - lines.append('_ec=$?; if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec)"; fi') - else: - # < /dev/null suppresses interactive "update available? [Y/n]" prompt - lines.append(f"{hf_cmd} < /dev/null") - lines.append('_ec=$?; if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec)"; fi') + if not is_ollama_download: + lines.append(_HF_TOKEN_STATUS_SNIPPET) + # Retry loop — same rationale as the remote-bash path. Issue #2722. + _hf_invoke = 'eval "$ODYSSEUS_OLLAMA_PULL_CMD" < /dev/null' if is_ollama_download else (hf_cmd if IS_WINDOWS else f"{hf_cmd} < /dev/null") + lines.append('_max_retries=10; _attempt=0; _ec=0') + lines.append('while [ $_attempt -lt $_max_retries ]; do') + lines.append(' _attempt=$((_attempt+1))') + lines.append(f' {_hf_invoke}') + lines.append(' _ec=$?') + lines.append(' if [ $_ec -eq 0 ]; then break; fi') + lines.append(' if [ $_attempt -lt $_max_retries ]; then') + lines.append(' echo ""; echo "Download attempt $_attempt failed (exit $_ec) — retrying in 30s..."') + lines.append(' sleep 30') + lines.append(' fi') + lines.append('done') + lines.append('if [ $_ec -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $_ec after $_attempt attempts)"; fi') + if not IS_WINDOWS: lines.append(f"rm -f '{wrapper_script}'") lines.append('exec "${SHELL:-/bin/bash}"') wrapper_script.write_text("\n".join(lines) + "\n", encoding="utf-8") wrapper_script.chmod(0o755) setup_cmd = None if IS_WINDOWS else f"tmux new-session -d -s {session_id} {shlex.quote(str(wrapper_script))}" - logger.info(f"Model download: {req.repo_id} (include={req.include}, session={session_id}, remote={remote})") + logger.info(f"Model download: {req.repo_id} (backend={'ollama' if is_ollama_download else 'hf'}, include={req.include}, session={session_id}, remote={remote})") logger.info(f"Download setup_cmd: {setup_cmd}") if setup_cmd is None: @@ -547,9 +739,8 @@ def setup_cookbook_routes() -> APIRouter: # Validate shell-bound inputs, matching the sibling list_gpus endpoint — # `host`/`ssh_port` are interpolated into an ssh command below, so an # unvalidated value (e.g. "x'; rm -rf ~ #") would be command injection. - host = _validate_remote_host(host) - if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(host) + ssh_port = validate_ssh_port(ssh_port) TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) model_dirs = [] @@ -698,11 +889,16 @@ def setup_cookbook_routes() -> APIRouter: # listening" check without requiring ss/netstat/nmap. ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"] if ssh_port and str(ssh_port) != "22": - if not _SSH_PORT_RE.match(str(ssh_port)): + try: + ssh_port = validate_ssh_port(ssh_port) + except HTTPException: return None ssh_base.extend(["-p", str(ssh_port)]) - host_arg = remote - if not _REMOTE_HOST_RE.match(host_arg): + try: + host_arg = validate_remote_host(remote) + except HTTPException: + return None + if not host_arg: return None probe_ports = " ".join(str(start_port + i) for i in range(max_offset + 1)) script = ( @@ -734,6 +930,100 @@ def setup_cookbook_routes() -> APIRouter: return p return None + async def _serve_crash_watchdog( + endpoint_id: str, + session_id: str, + remote: str | None, + ssh_port: str | None, + is_windows: bool, + ) -> None: + """Drop a freshly-registered endpoint when the cookbook serve dies early. + + The runner script always emits ``=== Process exited with code N ===`` + when the launched cmd terminates (success or failure). We poll the + tmux pane periodically; on a non-zero exit detected within the watch + window, the endpoint row is deleted so the picker doesn't keep a + dead model around. A zero exit (rare for a long-running serve, but + possible for fast-failing builds that the runner reports as code 0) + and "missing exit marker" both leave the endpoint alone — that's + the loading-but-not-yet-bound state, which the probe-marks-offline + logic already handles. + + Times are picked to outlast realistic vLLM load times (Qwen3.5-122B + takes ~3 min to load) without burning resources on a stuck-forever + wait. After the last check, the watchdog gives up — the picker's + per-endpoint probe takes over from there. + """ + # Cumulative wait points: 25 s, 60 s, 2 min, 5 min. + _waits = [25, 35, 60, 180] + # Tmux capture-pane equivalent of the polling path used elsewhere in + # this file. Build it once and reuse on each tick. Skip the watchdog + # entirely on native-Windows local runs (no tmux). The Windows + # detached-process path writes its log to a known file and has its + # own lifecycle tracking; punting here keeps the code simple. + local_win = is_windows and not remote + if local_win: + return + if remote: + ssh_args = ["ssh"] + if ssh_port and ssh_port != "22": + ssh_args.extend(["-p", str(ssh_port)]) + capture_cmd = ssh_args + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"] + else: + capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"] + + _exit_re = re.compile(r"=== Process exited with code (-?\d+) ===") + for wait_s in _waits: + await asyncio.sleep(wait_s) + try: + proc = await asyncio.create_subprocess_exec( + *capture_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=8) + output = stdout.decode("utf-8", errors="replace") + except Exception as e: + logger.debug(f"crash-watchdog: capture-pane failed (will retry): {e!r}") + continue + # Last occurrence wins — a serve that exits/restarts under the + # runner's "exec bash -i" trail will emit multiple markers; the + # most-recent code is the one that matters. + matches = list(_exit_re.finditer(output)) + if not matches: + continue + try: + exit_code = int(matches[-1].group(1)) + except (ValueError, IndexError): + continue + if exit_code == 0: + # Exit 0 on a long-running serve is unusual (a normal "loaded + # then ready" path keeps the process alive) but it happens for + # commands like "ollama pull" the user might launch through + # the same form. Don't drop the endpoint on a clean exit; + # let the probe layer mark it offline if nothing's listening. + logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}") + return + # Non-zero exit — drop the endpoint. + try: + from core.database import SessionLocal as _SL, ModelEndpoint as _ME + db = _SL() + try: + ep = db.query(_ME).filter(_ME.id == endpoint_id).first() + if ep: + logger.info( + f"crash-watchdog: dropping endpoint {endpoint_id} " + f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}" + ) + db.delete(ep) + db.commit() + finally: + db.close() + except Exception as e: + logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}") + return + logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}") + def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None: """Register a freshly-served LLM as a model endpoint so it appears in the model picker without a manual /setup step — the text-model sibling of @@ -745,6 +1035,10 @@ def setup_cookbook_routes() -> APIRouter: probing /v1/models and dims the endpoint until the server is reachable, so registering immediately (before the server finishes loading) is safe. """ + logger.info( + f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} " + f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}" + ) import re from core.database import SessionLocal, ModelEndpoint @@ -769,16 +1063,20 @@ def setup_cookbook_routes() -> APIRouter: else: port = 8080 # llama.cpp's llama-server default — the Apple Silicon path - # Determine host (mirrors the image path: SSH alias for remote serves). - # For local serves while Odysseus runs inside Docker, "localhost" - # resolves to the container itself — useless. Use host.docker.internal - # which compose maps to the actual host, matching what /setup adds - # for Ollama by hand. + # Determine host. The cookbook tmux for `local=true` serves runs INSIDE + # the odysseus container — so the right URL for the in-container + # backend to reach it is `localhost`, NOT `host.docker.internal` + # (the latter points at the docker HOST, which doesn't have a server + # on that port). The previous host.docker.internal fallback only made + # sense for /setup-added external services like systemd Ollama on the + # host — and those go through manual setup, not this auto-register + # code path. For remote serves we still use the SSH host alias. if remote: host = remote.split("@")[-1] if "@" in remote else remote + elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""): + host = "host.docker.internal" else: - from routes.model_routes import _docker_host_gateway_reachable - host = "host.docker.internal" if _docker_host_gateway_reachable() else "localhost" + host = "localhost" base_url = f"http://{host}:{port}/v1" @@ -787,7 +1085,9 @@ def setup_cookbook_routes() -> APIRouter: # If the serve command opts models into OpenAI tool-calling, record it so # agent_loop trusts emitted tool_calls instead of the name heuristic. + is_ollama_endpoint = "ollama" in (req.cmd or "").lower() supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None + pinned_models = [req.repo_id] if is_ollama_endpoint and req.repo_id else [] db = SessionLocal() try: @@ -797,14 +1097,43 @@ def setup_cookbook_routes() -> APIRouter: existing.is_enabled = True existing.model_type = "llm" existing.name = display_name + if is_ollama_endpoint: + existing.endpoint_kind = "ollama" + if pinned_models: + existing.cached_models = json.dumps(pinned_models) + existing.pinned_models = json.dumps(pinned_models) if supports_tools is not None: existing.supports_tools = supports_tools - # Wipe stale model lists so the picker re-probes and discovers - # the newly-served model instead of showing the old one. - existing.cached_models = None - existing.hidden_models = None db.commit() logger.info(f"Updated existing local model endpoint: {base_url}") + # Re-probe so cached_models matches what the server actually + # serves right now (the URL may have stayed the same but the + # model behind it changed across launches). + try: + from routes.model_routes import _probe_endpoint + import json as _json2 + probed = _probe_endpoint(base_url, existing.api_key, timeout=5) + if probed: + existing.cached_models = _json2.dumps(probed) + db.commit() + except Exception as _pe: + logger.warning(f"Re-probe failed for {base_url}: {_pe!r}") + # Sweep stale dupes: other endpoints with the same display name + # at DIFFERENT URLs (likely failed earlier-attempt ports) get + # deleted so the picker doesn't show an offline ghost next to + # the working one. Only sweeps endpoints whose id starts with + # `local-` so we never touch a user's hand-added DeepSeek/OpenAI/ + # etc. entry with a coincidentally matching name. + stale = (db.query(ModelEndpoint) + .filter(ModelEndpoint.name == display_name) + .filter(ModelEndpoint.base_url != base_url) + .filter(ModelEndpoint.id.like("local-%")) + .all()) + for s in stale: + logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})") + db.delete(s) + if stale: + db.commit() return existing.id ep_id = f"local-{uuid.uuid4().hex[:8]}" @@ -815,11 +1144,42 @@ def setup_cookbook_routes() -> APIRouter: api_key=None, is_enabled=True, model_type="llm", + endpoint_kind="ollama" if is_ollama_endpoint else "auto", + cached_models=json.dumps(pinned_models) if pinned_models else None, + pinned_models=json.dumps(pinned_models) if pinned_models else None, supports_tools=supports_tools, ) db.add(ep) db.commit() logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}") + # Same sweep on first-register path: drop any pre-existing local-* + # endpoints with this display name pointed elsewhere. + stale = (db.query(ModelEndpoint) + .filter(ModelEndpoint.name == display_name) + .filter(ModelEndpoint.id != ep_id) + .filter(ModelEndpoint.id.like("local-%")) + .all()) + for s in stale: + logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})") + db.delete(s) + if stale: + db.commit() + # Probe /v1/models NOW and write cached_models so the chat + # picker actually shows the model on the next /api/models + # call. Without this immediate probe, the endpoint has empty + # cached_models until the next background refresh fires (up + # to a minute later) and the picker shows nothing — even + # though the endpoint is in the DB and the server is up. + try: + from routes.model_routes import _probe_endpoint + import json as _json2 + probed = _probe_endpoint(base_url, None, timeout=5) + if probed: + ep.cached_models = _json2.dumps(probed) + db.commit() + logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}") + except Exception as _pe: + logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}") return ep_id except Exception as e: logger.error(f"Failed to auto-register local model endpoint: {e}") @@ -841,8 +1201,8 @@ def setup_cookbook_routes() -> APIRouter: """ require_admin(request) # Defence-in-depth: reject values that could break out of shell contexts. - _validate_remote_host(req.remote_host) - req.ssh_port = _validate_ssh_port(req.ssh_port) + validate_remote_host(req.remote_host) + req.ssh_port = validate_ssh_port(req.ssh_port) req.gpus = _validate_gpus(req.gpus) req.hf_token = req.hf_token or _load_stored_hf_token() _validate_token(req.hf_token) @@ -859,21 +1219,17 @@ def setup_cookbook_routes() -> APIRouter: in_venv=sys.prefix != sys.base_prefix, ) is_pip_install = bool(req.cmd and "pip install" in req.cmd) - remote = req.remote_host - is_windows = req.platform == "windows" - local_windows = IS_WINDOWS and not remote - if is_windows or local_windows: - if req.cmd.startswith("python3 "): - req.cmd = "python " + req.cmd[len("python3 "):] - if is_pip_install and ("llama-cpp-python" in req.cmd or "llama_cpp" in req.cmd) and (is_windows or local_windows): - if "--extra-index-url" not in req.cmd: - req.cmd += " --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu" - if is_pip_install: # Keep big dependency wheel builds (vLLM, …) off the home filesystem's # pip cache so they don't fail mid-build with "No space left" (#1219) # and leave the dep installed-but-unusable (#1459). req.cmd = _pip_install_no_cache(req.cmd) + # Accept common aliases and enforce server extras for llama-cpp so + # `python -m llama_cpp.server` has all runtime dependencies. + req.cmd = re.sub(r"(?=!~,` for version specifiers. # v2 review HIGH-14: tightened from the previous regex which @@ -896,7 +1252,12 @@ def setup_cookbook_routes() -> APIRouter: # Otherwise the runner script picks one at runtime and `_auto_register` # below still registers the stale 11434 default — which on a host with # a systemd ollama lands on the wrong (unreachable-from-docker) service. - if "ollama" in req.cmd and "OLLAMA_HOST=" not in req.cmd: + # Match "ollama serve" as a phrase (with optional flags after), not + # any substring containing "ollama" — otherwise commands like + # `docker exec ollama-test ollama-import …` get wrapped as if they + # were native `ollama serve`, prepending OLLAMA_HOST=… and then + # running the ollama-not-found preflight which exits 127. + if re.search(r"\bollama\s+serve\b", req.cmd) and "OLLAMA_HOST=" not in req.cmd: _ollama_bind_host = "0.0.0.0" if remote else "127.0.0.1" _ollama_chosen_port = _pick_free_port_for_ollama( remote, req.ssh_port, start_port=11434, max_offset=10, @@ -926,8 +1287,6 @@ def setup_cookbook_routes() -> APIRouter: ps_lines = [] ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') - ps_lines.append('$env:PYTHONIOENCODING = "utf-8"') - ps_lines.append('$env:PYTHONUTF8 = "1"') if req.hf_token: ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") if req.gpus: @@ -946,7 +1305,7 @@ def setup_cookbook_routes() -> APIRouter: ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}') ps_lines.append('if ($LASTEXITCODE -ne 0) {') ps_lines.append(' Write-Host "Installing llama-cpp-python..."') - ps_lines.append(' python -m pip install llama-cpp-python[server] --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu') + ps_lines.append(' python -m pip install llama-cpp-python[server]') ps_lines.append('}') elif "vllm" in req.cmd: ps_lines.append('Write-Host "ERROR: vLLM is not supported on Windows. Use Ollama or llama.cpp instead."') @@ -1021,58 +1380,46 @@ def setup_cookbook_routes() -> APIRouter: # ollama is found (otherwise macOS falls back to a slow source build). # /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux. runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"') - if local_windows: - # LOCAL Windows: no native source compilation (no cmake/compiler on Git Bash). - # Just check python bindings (using native `python` binary) and fall back to pip install. - runner_lines.append('if ! command -v llama-server &>/dev/null && ! python -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "llama-server not found — installing Python bindings..."') - runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='python')} || true") - runner_lines.append('fi') - runner_lines.append('if ! command -v llama-server &>/dev/null && ! python -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install attempts."') - runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') - runner_lines.append('fi') - else: - runner_lines.append('if [ -d /data/data/com.termux ]; then') - runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') - runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' pkg install -y cmake 2>/dev/null') - runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null') - runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true') - runner_lines.append(' fi') - runner_lines.append('elif ! command -v llama-server &>/dev/null; then') - runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."') - runner_lines.append(' mkdir -p ~/bin') - runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp') - # Build with the right accelerator: Metal on macOS (llama.cpp - # enables it automatically, no flag), CUDA on Linux when present, - # else a plain CPU build. nproc is Linux-only — fall back to - # `sysctl hw.ncpu` on macOS. (Tip: `brew install llama.cpp` ships - # a prebuilt llama-server and skips this whole source build.) - runner_lines.append(' NPROC="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"') - runner_lines.append(' if [ "$(uname -s)" = "Darwin" ]; then') - runner_lines.append(' command -v cmake >/dev/null 2>&1 || echo "WARNING: cmake not found — install it with: brew install cmake (or: brew install llama.cpp for a prebuilt llama-server)."') - # Start from a clean cache: a prior failed configure (e.g. a CUDA - # attempt) poisons build/CMakeCache.txt, so a plain `cmake -B build` - # would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is - # explicit so the binary is optimized (Metal auto-enables on macOS). - runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\') - runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\') - runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') - runner_lines.append(' else') - _append_llama_cpp_linux_accel_build_lines(runner_lines) - runner_lines.append(' fi') - # If the native build failed, fall back to the Python bindings. - runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."') - runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true") - runner_lines.append(' fi') - runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') - runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."') - runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') - runner_lines.append(' fi') - runner_lines.append('fi') - elif "ollama" in req.cmd: + runner_lines.append('if [ -d /data/data/com.termux ]; then') + runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') + runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' pkg install -y cmake 2>/dev/null') + runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null') + runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true') + runner_lines.append(' fi') + runner_lines.append('elif ! command -v llama-server &>/dev/null; then') + runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."') + runner_lines.append(' mkdir -p ~/bin') + runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp') + # Build with the right accelerator: Metal on macOS (llama.cpp + # enables it automatically, no flag), CUDA on Linux when present, + # else a plain CPU build. nproc is Linux-only — fall back to + # `sysctl hw.ncpu` on macOS. (Tip: `brew install llama.cpp` ships + # a prebuilt llama-server and skips this whole source build.) + runner_lines.append(' NPROC="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"') + runner_lines.append(' if [ "$(uname -s)" = "Darwin" ]; then') + runner_lines.append(' command -v cmake >/dev/null 2>&1 || echo "WARNING: cmake not found — install it with: brew install cmake (or: brew install llama.cpp for a prebuilt llama-server)."') + # Start from a clean cache: a prior failed configure (e.g. a CUDA + # attempt) poisons build/CMakeCache.txt, so a plain `cmake -B build` + # would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is + # explicit so the binary is optimized (Metal auto-enables on macOS). + runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\') + runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\') + runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') + runner_lines.append(' else') + _append_llama_cpp_linux_accel_build_lines(runner_lines) + runner_lines.append(' fi') + runner_lines.append(' # If the native build failed, fall back to the Python bindings.') + runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."') + runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true") + runner_lines.append(' fi') + runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."') + runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') + runner_lines.append(' fi') + runner_lines.append('fi') + elif re.search(r"\bollama\s+serve\b", req.cmd): handled_ollama_serve = True _ollama_default_host = "0.0.0.0" if remote else "127.0.0.1" _ollama_host, _ollama_port = _ollama_bind_from_cmd( @@ -1093,23 +1440,13 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_try_port"') runner_lines.append(' break') runner_lines.append(' fi') - runner_lines.append(' echo "[odysseus] Ollama API ready on port ${ODYSSEUS_OLLAMA_PORT}: ${ODYSSEUS_OLLAMA_URL}"') - runner_lines.append(' echo "[odysseus] This task is monitoring an existing Ollama server; stopping it here will not stop an external Docker/system service."') - if local_windows: - # Windows detached process has no TTY; exec bash -i crashes. - # Keep the monitoring task alive with a sleep loop. - runner_lines.append(' while true; do sleep 60; done') - else: - runner_lines.append(' exec bash -i') - runner_lines.append('fi') + runner_lines.append(' exec 3<&-; exec 3>&-') + runner_lines.append('done') runner_lines.append('if ! command -v ollama &>/dev/null; then') runner_lines.append(' echo "ERROR: Ollama not found on this server. Install it from https://ollama.com/download or `curl -fsSL https://ollama.com/install.sh | sh`."') runner_lines.append(' echo') runner_lines.append(' echo "=== Process exited with code 127 ==="') - if local_windows: - runner_lines.append(' exit 127') - else: - runner_lines.append(' exec bash -i') + runner_lines.append(' exec bash -i') runner_lines.append('fi') runner_lines.append('ODYSSEUS_OLLAMA_URL="http://${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}"') if remote and _ollama_host in ("0.0.0.0", "::"): @@ -1117,20 +1454,24 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append('echo "[odysseus] Ollama has no built-in authentication; expose this only on a trusted LAN/VPN or provide an explicit OLLAMA_HOST with your own access controls."') runner_lines.append('echo "Starting ollama server on ${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}..."') runner_lines.append('OLLAMA_HOST="${ODYSSEUS_OLLAMA_HOST}:${ODYSSEUS_OLLAMA_PORT}" ollama serve') - if local_windows: - _append_serve_exit_code_lines(runner_lines, keep_shell_open=False) - else: - runner_lines.append('_ody_exit=$?') - runner_lines.append('echo') - runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="') - runner_lines.append('exec bash -i') + runner_lines.append('_ody_exit=$?') + runner_lines.append('echo') + runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="') + runner_lines.append('exec bash -i') elif "vllm serve" in req.cmd: # vLLM is CUDA/ROCm-only and does not run on macOS at all. runner_lines.append('if [ "$(uname -s)" = "Darwin" ]; then') runner_lines.append(' echo "ERROR: vLLM does not run on macOS. Use Ollama or llama.cpp (Metal) instead."') runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=1') runner_lines.append('fi') - _append_vllm_linux_preflight_lines(runner_lines) + # Put ~/.local/bin on PATH first — without a venv, vllm installs + # there via --user and the non-login serve shell otherwise can't + # find the `vllm` CLI ("command not found"). Mirrors llama.cpp above. + runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') + runner_lines.append('if ! command -v vllm &>/dev/null; then') + runner_lines.append(' echo "ERROR: vLLM is not installed."') + runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') + runner_lines.append('fi') elif "sglang.launch_server" in req.cmd: runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') runner_lines.append('if ! command -v sglang &>/dev/null; then') @@ -1149,7 +1490,25 @@ def setup_cookbook_routes() -> APIRouter: runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') runner_lines.append('fi') - if not handled_ollama_serve: + handled_ollama_sidecar_probe = False + if (not handled_ollama_serve + and re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\s+ollama\s+show\b", req.cmd or "")): + handled_ollama_sidecar_probe = True + _append_serve_preflight_exit_lines( + runner_lines, + keep_shell_open=not local_windows, + ) + runner_lines.append(req.cmd) + runner_lines.append('_ody_exit=$?') + runner_lines.append('echo') + runner_lines.append('echo "=== Process exited with code ${_ody_exit} ==="') + runner_lines.append('if [ "$_ody_exit" -eq 0 ]; then') + runner_lines.append(' echo "[odysseus] Ollama sidecar model is available; keeping Cookbook task attached to the persistent Ollama daemon."') + runner_lines.append(' while true; do sleep 3600; done') + runner_lines.append('fi') + runner_lines.append('exec bash -i') + + if not handled_ollama_serve and not handled_ollama_sidecar_probe: _append_serve_preflight_exit_lines( runner_lines, keep_shell_open=not local_windows, @@ -1236,6 +1595,26 @@ def setup_cookbook_routes() -> APIRouter: elif not is_pip_install: endpoint_id = _auto_register_llm_endpoint(req, remote) + # Crash watchdog: the auto-register above writes the endpoint row + # IMMEDIATELY (before the server has even bound its port) so the + # picker shows the model as it warms up. When the serve process + # crashes right at startup (missing module, bad cmd, port collision, + # ModuleNotFoundError on llama_cpp, etc.), the endpoint is left + # dangling — every subsequent chat returns 503 or an empty response. + # Schedule a background task to read the tmux output for the + # "=== Process exited with code N ===" marker the runner emits; + # if N != 0 within the watch window, delete the endpoint we just + # created. Skipped for diffusion (different image-endpoint cleanup + # path) and pip-install tasks (no endpoint to drop). + if endpoint_id and not is_diffusion and not is_pip_install: + asyncio.create_task(_serve_crash_watchdog( + endpoint_id=endpoint_id, + session_id=session_id, + remote=remote, + ssh_port=req.ssh_port, + is_windows=is_windows, + )) + # Log to assistant try: from src.assistant_log import log_to_assistant @@ -1263,12 +1642,11 @@ def setup_cookbook_routes() -> APIRouter: async def server_setup(request: Request, req: SetupRequest): """Install required dependencies on a remote server via SSH.""" require_admin(request) - host = _validate_remote_host(req.host) + host = validate_remote_host(req.host) if not host: raise HTTPException(400, "host is required") port = req.ssh_port - if port is not None and port != "" and not re.fullmatch(r"\d{1,5}", port): - raise HTTPException(400, "Invalid ssh_port") + port = validate_ssh_port(port) pf = f"-p {port} " if port and port != "22" else "" # Detect platform: Windows first (echo %OS% → Windows_NT), then Termux, then Linux @@ -1512,9 +1890,8 @@ def setup_cookbook_routes() -> APIRouter: `busy` is True when free_mb/total_mb < 0.5. """ require_admin(request) - host = _validate_remote_host(host) - if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(host) + ssh_port = validate_ssh_port(ssh_port) gpu_query = "nvidia-smi --query-gpu=index,name,memory.free,memory.total,memory.used,utilization.gpu,uuid --format=csv,noheader,nounits" nvidia_error = None try: @@ -1671,9 +2048,8 @@ def setup_cookbook_routes() -> APIRouter: sig = (req.signal or "TERM").upper() if sig not in ("TERM", "KILL", "INT"): raise HTTPException(400, "signal must be TERM, KILL, or INT") - host = _validate_remote_host(req.host) - if req.ssh_port and not _SSH_PORT_RE.fullmatch(req.ssh_port): - raise HTTPException(400, "Invalid ssh_port") + host = validate_remote_host(req.host) + req.ssh_port = validate_ssh_port(req.ssh_port) kill_cmd = f"kill -{sig} {req.pid}" try: if host: @@ -1937,30 +2313,58 @@ def setup_cookbook_routes() -> APIRouter: return {"models": out} - # Rate-limit for the orphan-tmux adoption sweep. The UI polls - # tasks/status every ~3s; we don't want to SSH every host on every - # poll. 20s is fast enough that a model the agent launched in the - # background shows up "almost immediately" in the UI without being - # wasteful. + # Rate-limit for the orphan-tmux adoption sweep. 60s interval so SSH + # work is genuinely sparse even on an actively-polled cookbook page. _last_orphan_sweep_ts = [0.0] - _ORPHAN_SWEEP_MIN_INTERVAL_S = 20.0 + _ORPHAN_SWEEP_MIN_INTERVAL_S = 60.0 + # Concurrency guard so two requests racing don't both spawn a sweep. + _orphan_sweep_inflight = [False] def _maybe_sweep_orphans(tasks: list, state: dict) -> None: """Scan each configured cookbook server for `serve-*` tmux sessions the cookbook doesn't know about and adopt them into state.tasks. - Writes are conditional: if no orphans are found, nothing is touched. - Rate-limited so polling UIs don't trigger SSH on every refresh. + Heavy SSH work runs in a background thread via asyncio.to_thread so + it never blocks the request that triggered it. Was previously + disabled because the sync implementation pegged uvicorn CPU during + active cookbook polling — re-enabled now with the work pushed off + the event loop and a slower (60s) cadence. """ import time as _time - import subprocess - logger.info(f"_maybe_sweep_orphans: entered, last_ts={_last_orphan_sweep_ts[0]}") now = _time.monotonic() + if _orphan_sweep_inflight[0]: + return if now - _last_orphan_sweep_ts[0] < _ORPHAN_SWEEP_MIN_INTERVAL_S: - logger.info(f"_maybe_sweep_orphans: rate-limited, {now - _last_orphan_sweep_ts[0]:.1f}s since last") return _last_orphan_sweep_ts[0] = now + _orphan_sweep_inflight[0] = True + # Snapshot inputs so the worker doesn't race with state mutations. + try: + tasks_snap = list(tasks or []) + except Exception: + tasks_snap = [] + state_snap = state if isinstance(state, dict) else {} + # Caller is _cookbook_tasks_status_sync (sync context, no event + # loop). Use a plain background thread — no asyncio needed. + import threading + def _run_sweep() -> None: + try: + _sync_sweep_orphans(tasks_snap, state_snap) + except Exception as _e: + logger.warning(f"orphan sweep thread failed: {_e!r}") + finally: + _orphan_sweep_inflight[0] = False + try: + threading.Thread(target=_run_sweep, daemon=True, name="orphan-sweep").start() + except Exception as _e: + logger.warning(f"orphan sweep thread spawn failed: {_e!r}") + _orphan_sweep_inflight[0] = False + return + + def _sync_sweep_orphans(tasks: list, state: dict) -> None: + """The actual sync sweep — never call this on the event loop.""" + import subprocess env = state.get("env") if isinstance(state, dict) else {} servers = env.get("servers") if isinstance(env, dict) else [] logger.info(f"orphan sweep starting: {len(servers) if isinstance(servers, list) else 0} server(s), known_sids={len([t for t in tasks if isinstance(t, dict) and t.get('sessionId')])}") @@ -1979,14 +2383,19 @@ def setup_cookbook_routes() -> APIRouter: host = (srv.get("host") or "").strip() if not host: continue # local-only entry; the /proc scan handles it - if not _REMOTE_HOST_RE.match(host): + try: + host = validate_remote_host(host) + except HTTPException: continue sport = str(srv.get("port") or "").strip() ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"] if sport and sport != "22": - if not _SSH_PORT_RE.match(sport): + try: + sport = validate_ssh_port(sport) + except HTTPException: continue - ssh_base.extend(["-p", sport]) + if sport != "22": + ssh_base.extend(["-p", sport]) try: ls = subprocess.run( @@ -2084,6 +2493,121 @@ def setup_cookbook_routes() -> APIRouter: except Exception as e: logger.warning(f"orphan sweep: state write failed: {e}") + # In-memory cache for the Ollama library scrape. ollama.com is a public + # site, but it doesn't expose a stable JSON listing — we fetch the HTML + # search page and regex out the model cards. Cached for 1 h so a busy + # cookbook view doesn't hammer the site on every render. + _ollama_library_cache: dict = {"models": [], "fetched_at": 0.0, "error": None} + + _OLLAMA_FALLBACK_LIBRARY = [ + {"name": "qwen2.5", "description": "Qwen2.5 series — strong general/coding model from Alibaba.", "sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b", "72b"]}, + {"name": "qwen2.5-coder", "description": "Code-specialized Qwen2.5 family.", "sizes": ["0.5b", "1.5b", "3b", "7b", "14b", "32b"]}, + {"name": "qwen3", "description": "Qwen3 — newer Alibaba family with hybrid reasoning.", "sizes": ["0.6b", "1.7b", "4b", "8b", "14b", "32b"]}, + {"name": "llama3.2", "description": "Meta Llama 3.2 instruct (and tiny / vision variants).", "sizes": ["1b", "3b", "11b", "90b"]}, + {"name": "llama3.1", "description": "Meta Llama 3.1 instruct.", "sizes": ["8b", "70b", "405b"]}, + {"name": "llama3.3", "description": "Meta Llama 3.3 70B instruct.", "sizes": ["70b"]}, + {"name": "gemma3", "description": "Google Gemma 3 — multimodal capable open-weights.", "sizes": ["1b", "4b", "12b", "27b"]}, + {"name": "gemma2", "description": "Google Gemma 2 instruct.", "sizes": ["2b", "9b", "27b"]}, + {"name": "mistral", "description": "Mistral 7B instruct — small, fast generalist.", "sizes": ["7b"]}, + {"name": "mistral-nemo", "description": "Mistral NeMo 12B instruct.", "sizes": ["12b"]}, + {"name": "mistral-small", "description": "Mistral Small 22B / 24B instruct.", "sizes": ["22b", "24b"]}, + {"name": "mixtral", "description": "Mistral MoE 8x7B / 8x22B.", "sizes": ["8x7b", "8x22b"]}, + {"name": "phi3", "description": "Microsoft Phi-3 small / medium.", "sizes": ["mini", "medium"]}, + {"name": "phi4", "description": "Microsoft Phi-4 14B.", "sizes": ["14b"]}, + {"name": "deepseek-r1", "description": "DeepSeek R1 reasoning model (distilled variants).", "sizes": ["1.5b", "7b", "8b", "14b", "32b", "70b"]}, + {"name": "deepseek-v3", "description": "DeepSeek V3 MoE 671B (huge — needs serious VRAM).", "sizes": ["671b"]}, + {"name": "codellama", "description": "Meta Code Llama instruct family.", "sizes": ["7b", "13b", "34b", "70b"]}, + {"name": "starcoder2", "description": "BigCode StarCoder2 — code completion.", "sizes": ["3b", "7b", "15b"]}, + {"name": "deepseek-coder-v2", "description": "DeepSeek Coder V2 — code MoE.", "sizes": ["16b", "236b"]}, + {"name": "nomic-embed-text", "description": "Embedding model — text vector encoder.", "sizes": ["latest"]}, + {"name": "mxbai-embed-large", "description": "Embedding model — Mixedbread large.", "sizes": ["latest"]}, + {"name": "llava", "description": "LLaVA multimodal vision-language model.", "sizes": ["7b", "13b", "34b"]}, + {"name": "minicpm-v", "description": "MiniCPM-V multimodal.", "sizes": ["8b"]}, + {"name": "command-r", "description": "Cohere Command R — RAG-oriented.", "sizes": ["35b"]}, + {"name": "command-r-plus", "description": "Cohere Command R+ — larger RAG model.", "sizes": ["104b"]}, + {"name": "qwq", "description": "Qwen QwQ reasoning preview.", "sizes": ["32b"]}, + {"name": "smollm2", "description": "HuggingFaceTB SmolLM2 — tiny capable models.", "sizes": ["135m", "360m", "1.7b"]}, + {"name": "granite3.1-dense", "description": "IBM Granite 3.1 dense instruct.", "sizes": ["2b", "8b"]}, + {"name": "nemotron", "description": "NVIDIA Nemotron 70B.", "sizes": ["70b"]}, + {"name": "olmo2", "description": "AI2 OLMo 2 open-weights.", "sizes": ["7b", "13b"]}, + ] + + @router.get("/api/cookbook/ollama/library") + async def ollama_library(refresh: int = 0, request: Request = None, owner: str = Depends(require_user)): + """List popular Ollama library models for the Browse picker. + + Tries a 1-hour-cached fetch of ollama.com/library, falls back to a + curated hard-coded list so the picker always renders something.""" + import time as _time + import httpx as _httpx + TTL = 3600.0 + now = _time.time() + if refresh or (now - _ollama_library_cache["fetched_at"]) > TTL or not _ollama_library_cache["models"]: + models: list[dict] = [] + err = None + try: + async with _httpx.AsyncClient(timeout=8, follow_redirects=True) as client: + resp = await client.get( + "https://ollama.com/search?sort=popular", + headers={"User-Agent": "odysseus-cookbook/1.0"}, + ) + if resp.status_code == 200: + html = resp.text + # ollama.com renders each model card as a single anchor: + # + # The description + sizes live inside that anchor. Pull + # the whole block then extract pieces individually. + block_re = re.compile( + r']*href="/library/([A-Za-z0-9._-]+)"[^>]*>(.*?)', + re.DOTALL, + ) + desc_re = re.compile(r']*>([^<]{4,400})

', re.DOTALL) + # Size tags on ollama.com cards look like "0.5b", "14b", + # "8x7b", "27b". Pulled from short -wrapped chips. + size_re = re.compile(r'>\s*(\d+(?:\.\d+)?(?:x\d+)?[bBmM])\s*<') + seen: set[str] = set() + for bm in block_re.finditer(html): + name = bm.group(1).strip() + if name in seen: + continue + seen.add(name) + body = bm.group(2) + dm = desc_re.search(body) + desc = (dm.group(1).strip() if dm else "").replace("\n", " ") + sizes_raw = size_re.findall(body) + # Dedup sizes preserving order + sizes: list[str] = [] + for s in sizes_raw: + s_low = s.lower() + if s_low not in sizes: + sizes.append(s_low) + models.append({"name": name, "description": desc, "sizes": sizes}) + if len(models) >= 80: + break + else: + err = f"HTTP {resp.status_code}" + except Exception as e: + err = str(e)[:160] + # Merge curated fallback so classics (qwen2.5, llama3, deepseek-r1, + # …) stay reachable even when ollama.com's front page is dominated + # by brand-new releases the user might not be looking for. + live_names = {m["name"] for m in models} + for fb in _OLLAMA_FALLBACK_LIBRARY: + if fb["name"] not in live_names: + models.append(fb) + if not models: + models = list(_OLLAMA_FALLBACK_LIBRARY) + if err is None: + err = "parsed 0 results — using fallback list" + _ollama_library_cache["models"] = models + _ollama_library_cache["fetched_at"] = now + _ollama_library_cache["error"] = err + return { + "models": _ollama_library_cache["models"], + "fetched_at": _ollama_library_cache["fetched_at"], + "error": _ollama_library_cache["error"], + } + @router.get("/api/cookbook/tasks/status") async def cookbook_tasks_status(request: Request): """Check status of all active cookbook tmux sessions. @@ -2121,13 +2645,39 @@ def setup_cookbook_routes() -> APIRouter: "inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));" "sys.exit(0 if ok and not inc else 1)" ) - if remote_host: - cmd = ["python3", "-c", py, repo_id] - else: - # Local Windows: python3 can hit the Microsoft Store stub. Use the - # real Python Odysseus is running under (guaranteed to exist). - import sys as _sys_local - cmd = [_sys_local.executable, "-c", py, repo_id] + cmd = ["python3", "-c", py, repo_id] + try: + if remote_host: + ssh_base = ["ssh"] + if ssh_port and ssh_port != "22": + ssh_base.extend(["-p", str(ssh_port)]) + shell_cmd = " ".join(shlex.quote(x) for x in cmd) + proc = subprocess.run(ssh_base + [remote_host, shell_cmd], timeout=12, capture_output=True) + else: + proc = subprocess.run(cmd, timeout=12, capture_output=True) + return proc.returncode == 0 + except Exception: + return False + + def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool: + """Best-effort check for resumable HF partial blobs. + + A lost SSH/tmux session can leave a real download still incomplete. + Treat any *.incomplete blob as stronger evidence than stale + "100%" lines in the captured pane output. + """ + if not repo_id or "/" not in repo_id: + return False + py = ( + "import os,sys;" + "repo=sys.argv[1];" + "base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');" + "d=os.path.join(base,'models--'+repo.replace('/','--'));" + "blobs=os.path.join(d,'blobs');" + "inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));" + "sys.exit(0 if inc else 1)" + ) + cmd = ["python3", "-c", py, repo_id] try: if remote_host: ssh_base = ["ssh"] @@ -2199,12 +2749,18 @@ def setup_cookbook_routes() -> APIRouter: if not _SESSION_ID_RE.match(session_id): logger.warning(f"Skipping task with unsafe session_id: {session_id!r}") continue - if remote and not _REMOTE_HOST_RE.match(remote): - logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}") - continue - if _tport and not _SSH_PORT_RE.match(str(_tport)): - logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}") - continue + if remote: + try: + remote = validate_remote_host(remote) + except HTTPException: + logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}") + continue + if _tport: + try: + _tport = validate_ssh_port(str(_tport)) + except HTTPException: + logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}") + continue if task_platform == "windows" and remote: # Windows: check PID file + Get-Process, read log tail sd = "$env:TEMP\\odysseus-sessions" @@ -2274,28 +2830,43 @@ def setup_cookbook_routes() -> APIRouter: except Exception: pass else: - try: - alive = subprocess.run(check_cmd, timeout=10, capture_output=True) - is_alive = alive.returncode == 0 - except Exception: + # Skip the live SSH check entirely for tasks already in a + # terminal state — they won't change, and 10s timeouts + # stacked per task were the dominant cost of this whole + # status endpoint (3+ minute stalls with ~8 accumulated + # stopped tasks). The agent's `list_served_models` call + # was blocking the chat stream every time. + _task_status = (task.get("status") or "").lower() + if _task_status in {"stopped", "done", "completed", + "crashed", "error", "failed", + "ended", "killed"}: is_alive = False - - # Capture last lines for progress. Prefer the "Downloading" line - # (real aggregate bytes) over "Fetching N files" (whole-file count that - # lags with hf_transfer). Falls back to the true last line otherwise. - if is_alive: + # Keep the persisted output_tail for the UI — it's + # what the agent uses to diagnose past failures. + full_snapshot = (task.get("output") or "")[-12000:] + else: try: - cap = subprocess.run(capture_cmd, timeout=10, capture_output=True, text=True) - if cap.returncode == 0: - full_snapshot = cap.stdout.strip() - lines = [l.strip() for l in full_snapshot.split('\n') if l.strip()] - downloading_lines = [l for l in lines if l.startswith("Downloading")] - if downloading_lines: - progress_text = downloading_lines[-1] - elif lines: - progress_text = lines[-1] + alive = subprocess.run(check_cmd, timeout=4, capture_output=True) + is_alive = alive.returncode == 0 except Exception: - pass + is_alive = False + + # Capture last lines for progress. Prefer the "Downloading" line + # (real aggregate bytes) over "Fetching N files" (whole-file count that + # lags with hf_transfer). Falls back to the true last line otherwise. + if is_alive: + try: + cap = subprocess.run(capture_cmd, timeout=4, capture_output=True, text=True) + if cap.returncode == 0: + full_snapshot = cap.stdout.strip() + lines = [l.strip() for l in full_snapshot.split('\n') if l.strip()] + downloading_lines = [l for l in lines if l.startswith("Downloading")] + if downloading_lines: + progress_text = downloading_lines[-1] + elif lines: + progress_text = lines[-1] + except Exception: + pass # Determine status. For the local-Windows detached model the log file # persists after the process exits, so a finished download still has a @@ -2303,6 +2874,16 @@ def setup_cookbook_routes() -> APIRouter: # when the PID is gone instead of blindly reporting "stopped". download_zero_files = False status = "unknown" + download_has_ok = task_type == "download" and "DOWNLOAD_OK" in full_snapshot + download_has_failed = task_type == "download" and "DOWNLOAD_FAILED" in full_snapshot + download_has_incomplete_evidence = ( + task_type == "download" + and ( + ".incomplete" in full_snapshot + or bool(re.search(r'model-\d+-of-\d+\.[A-Za-z0-9_.-]+:\s+(?:[0-9]|[1-8][0-9])%', full_snapshot)) + or _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or "")) + ) + ) if is_alive or (local_win_task and full_snapshot): lower = full_snapshot.lower() exit_match = re.search(r"=== process exited with code\s+(-?\d+)", full_snapshot, re.I) @@ -2315,20 +2896,24 @@ def setup_cookbook_routes() -> APIRouter: elif has_exit and task_type == "download": # Dependency installs are tracked as download tasks but only # emit the generic runner exit marker, not HF download markers. - status = "completed" if exit_code == 0 else "error" + if download_has_incomplete_evidence and not download_has_ok: + status = "running" if is_alive else "stopped" + else: + status = "completed" if exit_code == 0 else "error" elif has_exit and "unrecognized arguments" in lower: status = "error" elif has_error and not ("application startup complete" in lower): status = "error" - elif task_type == "download" and ("100%" in full_snapshot or "DOWNLOAD_OK" in full_snapshot): - # Only download tasks treat 100% as "completed". - # Serve tasks log 100%|██████| during inference progress - # (diffusion sampling, etc.) — that's "running", not done. + elif task_type == "download" and download_has_ok: if re.search(r"Fetching\s+0\s+files", full_snapshot, re.IGNORECASE): status = "error" download_zero_files = True else: status = "completed" + elif task_type == "download" and download_has_failed: + status = "error" + elif task_type == "download" and download_has_incomplete_evidence: + status = "running" if is_alive else "stopped" elif "application startup complete" in lower: status = "ready" elif not is_alive: @@ -2338,7 +2923,11 @@ def setup_cookbook_routes() -> APIRouter: status = "running" else: # Session is dead — check if it completed or crashed - if task_type == "download" and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or "")): + if ( + task_type == "download" + and not download_has_incomplete_evidence + and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or "")) + ): status = "completed" if not progress_text: progress_text = "Download complete" @@ -2348,12 +2937,12 @@ def setup_cookbook_routes() -> APIRouter: status = "stopped" # Parse structured phase info — single source of truth for the UI - phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and status == "running" and full_snapshot) else {} + phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and full_snapshot) else {} if phase_info.get("status") == "ready": status = "ready" serve_phase = phase_info.get("phase", "") diagnosis = _diagnose_serve_output(full_snapshot) if task_type == "serve" and full_snapshot else None - if diagnosis and status in {"running", "unknown", "stopped"}: + if diagnosis and status in {"running", "unknown", "stopped"} and phase_info.get("status") != "ready": status = "error" if download_zero_files: diagnosis = {"message": "No matching files were downloaded. The model repo or filename/quant pattern may be wrong (for example a ':Q4_K_M' tag that does not exist in the repo). Check the repo and the include/quant pattern."} diff --git a/routes/copilot_routes.py b/routes/copilot_routes.py index bb2b1d21f..1d8be52ce 100644 --- a/routes/copilot_routes.py +++ b/routes/copilot_routes.py @@ -20,39 +20,26 @@ All routes are admin-gated (endpoint/provider management is an admin action). """ import json -import time import uuid import logging -import threading from typing import Dict, Optional import httpx -from fastapi import APIRouter, Request, Form, HTTPException +from fastapi import HTTPException, Request from core.database import SessionLocal, ModelEndpoint -from core.middleware import require_admin +from routes.device_flow import ( + DeviceFlowPoll, + DeviceFlowStart, + PendingDeviceFlowStore, + create_device_flow_router, +) from src.auth_helpers import get_current_user from src import copilot logger = logging.getLogger(__name__) -# Pending device-flow logins, keyed by an opaque poll_id. The device_code is a -# bearer-like secret, so it lives here (server memory) rather than in the -# browser. Entries expire with the GitHub device code. -# -# NOTE: this is per-process state. The device flow assumes a single worker -# (Odysseus' default): with multiple uvicorn workers, the poll request can land -# on a worker that never saw the start, returning "Unknown or expired login -# session". Move this to a shared store (DB/Redis) if running multi-worker. -_PENDING: Dict[str, Dict] = {} -_PENDING_LOCK = threading.Lock() - - -def _prune_expired() -> None: - now = time.time() - with _PENDING_LOCK: - for k in [k for k, v in _PENDING.items() if v.get("expires_at", 0) < now]: - _PENDING.pop(k, None) +_DEVICE_FLOW_STORE = PendingDeviceFlowStore() def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict: @@ -112,112 +99,75 @@ def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict: return result -def setup_copilot_routes() -> APIRouter: - router = APIRouter(prefix="/api/copilot", tags=["copilot"]) +def _start_device_flow(request: Request, form) -> DeviceFlowStart: + host = copilot.GITHUB_HOST + ent = str(form.get("enterprise_url") or "").strip() + if ent: + host = copilot.normalize_domain(ent) + try: + data = copilot.request_device_code(host) + except httpx.HTTPStatusError as e: + status = e.response.status_code if e.response is not None else "unknown" + raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})") + except Exception as e: + raise HTTPException(502, f"GitHub device-code request failed: {e}") - @router.post("/device/start") - def device_start(request: Request, enterprise_url: str = Form("")): - require_admin(request) - _prune_expired() - host = copilot.GITHUB_HOST - ent = (enterprise_url or "").strip() - if ent: - host = copilot.normalize_domain(ent) - try: - data = copilot.request_device_code(host) - except httpx.HTTPStatusError as e: - status = e.response.status_code if e.response is not None else "unknown" - raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})") - except Exception as e: - raise HTTPException(502, f"GitHub device-code request failed: {e}") + device_code = data.get("device_code") + if not device_code: + raise HTTPException(502, "GitHub did not return a device code") - device_code = data.get("device_code") - if not device_code: - raise HTTPException(502, "GitHub did not return a device code") - interval = int(data.get("interval") or 5) - expires_in = int(data.get("expires_in") or 900) - poll_id = uuid.uuid4().hex - with _PENDING_LOCK: - _PENDING[poll_id] = { - "device_code": device_code, - "host": host, - "enterprise_url": ent, - "interval": interval, - "owner": get_current_user(request) or None, - "expires_at": time.time() + expires_in, - "next_poll_at": 0.0, - } - # verification_uri_complete embeds the user code, so the browser tab we - # open lands the user straight on GitHub's "Authorize" screen with the - # code pre-filled — one click, no manual code entry. - return { - "poll_id": poll_id, + # verification_uri_complete embeds the user code, so the browser tab we + # open lands the user straight on GitHub's "Authorize" screen with the + # code pre-filled — one click, no manual code entry. + return DeviceFlowStart( + pending={ + "device_code": device_code, + "host": host, + "enterprise_url": ent, + "owner": get_current_user(request) or None, + }, + response={ "user_code": data.get("user_code"), "verification_uri": data.get("verification_uri"), "verification_uri_complete": data.get("verification_uri_complete"), - "interval": interval, - "expires_in": expires_in, - } + }, + interval=int(data.get("interval") or 5), + expires_in=int(data.get("expires_in") or 900), + ) - @router.post("/device/poll") - def device_poll(request: Request, poll_id: str = Form(...)): - require_admin(request) - _prune_expired() - with _PENDING_LOCK: - pending = _PENDING.get(poll_id) - if not pending: - raise HTTPException(404, "Unknown or expired login session") - # Enforce GitHub's polling interval server-side so a chatty client - # can't trip slow_down. - now = time.time() - if now < pending.get("next_poll_at", 0): - return {"status": "pending"} +def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll: + try: + data = copilot.poll_access_token(pending["host"], pending["device_code"]) + except Exception as e: + return DeviceFlowPoll.pending(f"poll error: {e}") + token = data.get("access_token") + if token: + base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE try: - data = copilot.poll_access_token(pending["host"], pending["device_code"]) + result = _provision_endpoint(token, base, pending["owner"]) except Exception as e: - return {"status": "pending", "detail": f"poll error: {e}"} + logger.exception("Copilot endpoint provisioning failed") + raise HTTPException(500, f"Login succeeded but provisioning failed: {e}") + return DeviceFlowPoll.authorized(result) - token = data.get("access_token") - if token: - base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE - try: - result = _provision_endpoint(token, base, pending["owner"]) - except Exception as e: - logger.exception("Copilot endpoint provisioning failed") - with _PENDING_LOCK: - _PENDING.pop(poll_id, None) - raise HTTPException(500, f"Login succeeded but provisioning failed: {e}") - with _PENDING_LOCK: - _PENDING.pop(poll_id, None) - return {"status": "authorized", "endpoint": result} + err = data.get("error") + if err == "authorization_pending": + return DeviceFlowPoll.pending() + if err == "slow_down": + return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None) + if err in ("expired_token", "access_denied"): + return DeviceFlowPoll.failed(err) + # Unknown error — surface but keep the session for another try. + return DeviceFlowPoll.pending(err or "unknown") - err = data.get("error") - if err == "authorization_pending": - with _PENDING_LOCK: - if poll_id in _PENDING: - _PENDING[poll_id]["next_poll_at"] = now + pending["interval"] - return {"status": "pending"} - if err == "slow_down": - new_interval = int(data.get("interval") or (pending["interval"] + 5)) - with _PENDING_LOCK: - if poll_id in _PENDING: - _PENDING[poll_id]["interval"] = new_interval - _PENDING[poll_id]["next_poll_at"] = now + new_interval - return {"status": "pending"} - if err in ("expired_token", "access_denied"): - with _PENDING_LOCK: - _PENDING.pop(poll_id, None) - return {"status": "failed", "error": err} - # Unknown error — surface but keep the session for another try. - return {"status": "pending", "detail": err or "unknown"} - @router.post("/device/cancel") - def device_cancel(request: Request, poll_id: str = Form(...)): - require_admin(request) - with _PENDING_LOCK: - _PENDING.pop(poll_id, None) - return {"status": "cancelled"} - - return router +def setup_copilot_routes(): + return create_device_flow_router( + prefix="/api/copilot", + tags=["copilot"], + store=_DEVICE_FLOW_STORE, + start_flow=_start_device_flow, + poll_flow=_poll_device_flow, + ) diff --git a/routes/device_flow.py b/routes/device_flow.py new file mode 100644 index 000000000..8b8ab4ac8 --- /dev/null +++ b/routes/device_flow.py @@ -0,0 +1,193 @@ +"""Shared OAuth/device-flow route scaffolding for provider setup.""" + +from __future__ import annotations + +import inspect +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Any, Callable, Iterable, Mapping, Optional + +from fastapi import APIRouter, Form, HTTPException, Request + +from core.middleware import require_admin + + +@dataclass(frozen=True) +class DeviceFlowStart: + """Provider-specific start result consumed by the shared route wrapper.""" + + pending: Mapping[str, Any] + response: Mapping[str, Any] + interval: int = 5 + expires_in: int = 900 + + +@dataclass(frozen=True) +class DeviceFlowPoll: + """Normalized provider poll outcome.""" + + status: str + endpoint: Optional[Mapping[str, Any]] = None + error: Optional[str] = None + detail: Optional[str] = None + interval: Optional[int] = None + + @classmethod + def pending(cls, detail: Optional[str] = None) -> "DeviceFlowPoll": + return cls(status="pending", detail=detail) + + @classmethod + def slow_down(cls, interval: Optional[int] = None, detail: Optional[str] = None) -> "DeviceFlowPoll": + return cls(status="slow_down", interval=interval, detail=detail) + + @classmethod + def authorized(cls, endpoint: Mapping[str, Any]) -> "DeviceFlowPoll": + return cls(status="authorized", endpoint=endpoint) + + @classmethod + def failed(cls, error: str) -> "DeviceFlowPoll": + return cls(status="failed", error=error) + + +class PendingDeviceFlowStore: + """Thread-safe in-memory pending device-flow store. + + Device codes and provider-side secrets stay inside this process. Each entry + stores provider payload separately from poll metadata so provider callbacks + only receive the fields they created. + """ + + def __init__(self, *, time_func: Callable[[], float] = time.time): + self._pending: dict[str, dict[str, Any]] = {} + self._lock = threading.Lock() + self._time = time_func + + def _now(self) -> float: + return float(self._time()) + + def prune_expired(self) -> None: + now = self._now() + with self._lock: + for key in [k for k, v in self._pending.items() if v.get("expires_at", 0) < now]: + self._pending.pop(key, None) + + def add(self, payload: Mapping[str, Any], *, interval: int, expires_in: int) -> str: + self.prune_expired() + poll_id = uuid.uuid4().hex + with self._lock: + self._pending[poll_id] = { + "payload": dict(payload), + "interval": max(int(interval or 5), 1), + "expires_at": self._now() + max(int(expires_in or 900), 1), + "next_poll_at": 0.0, + } + return poll_id + + def get_payload(self, poll_id: str) -> Optional[dict[str, Any]]: + self.prune_expired() + with self._lock: + entry = self._pending.get(poll_id) + if entry is None: + return None + return dict(entry.get("payload") or {}) + + def is_throttled(self, poll_id: str) -> bool: + with self._lock: + entry = self._pending.get(poll_id) + return bool(entry and self._now() < float(entry.get("next_poll_at") or 0)) + + def schedule_next(self, poll_id: str) -> None: + now = self._now() + with self._lock: + entry = self._pending.get(poll_id) + if entry is not None: + entry["next_poll_at"] = now + int(entry.get("interval") or 5) + + def slow_down(self, poll_id: str, interval: Optional[int] = None) -> None: + now = self._now() + with self._lock: + entry = self._pending.get(poll_id) + if entry is not None: + new_interval = int(interval or (int(entry.get("interval") or 5) + 5)) + entry["interval"] = max(new_interval, 1) + entry["next_poll_at"] = now + entry["interval"] + + def pop(self, poll_id: str) -> None: + with self._lock: + self._pending.pop(poll_id, None) + + +async def _maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +def _pending_response(detail: Optional[str] = None) -> dict[str, Any]: + response: dict[str, Any] = {"status": "pending"} + if detail: + response["detail"] = detail + return response + + +def create_device_flow_router( + *, + prefix: str, + tags: Iterable[str], + store: PendingDeviceFlowStore, + start_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowStart], + poll_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowPoll], +) -> APIRouter: + """Create standard `/device/start|poll|cancel` routes for a provider.""" + + router = APIRouter(prefix=prefix, tags=list(tags)) + + @router.post("/device/start") + async def device_start(request: Request): + require_admin(request) + form = await request.form() + start = await _maybe_await(start_flow(request, form)) + interval = int(start.interval or 5) + expires_in = int(start.expires_in or 900) + poll_id = store.add(start.pending, interval=interval, expires_in=expires_in) + response = dict(start.response) + response.update({"poll_id": poll_id, "interval": interval, "expires_in": expires_in}) + return response + + @router.post("/device/poll") + async def device_poll(request: Request, poll_id: str = Form(...)): + require_admin(request) + payload = store.get_payload(poll_id) + if payload is None: + raise HTTPException(404, "Unknown or expired login session") + if store.is_throttled(poll_id): + return {"status": "pending"} + + try: + outcome = await _maybe_await(poll_flow(request, payload)) + except Exception: + store.pop(poll_id) + raise + + if outcome.status == "authorized": + store.pop(poll_id) + return {"status": "authorized", "endpoint": dict(outcome.endpoint or {})} + if outcome.status == "failed": + store.pop(poll_id) + return {"status": "failed", "error": outcome.error or "denied"} + if outcome.status == "slow_down": + store.slow_down(poll_id, outcome.interval) + return _pending_response(outcome.detail) + + store.schedule_next(poll_id) + return _pending_response(outcome.detail) + + @router.post("/device/cancel") + def device_cancel(request: Request, poll_id: str = Form(...)): + require_admin(request) + store.pop(poll_id) + return {"status": "cancelled"} + + return router diff --git a/routes/diagnostics_routes.py b/routes/diagnostics_routes.py index daebef8d2..d6763798d 100644 --- a/routes/diagnostics_routes.py +++ b/routes/diagnostics_routes.py @@ -16,9 +16,18 @@ def setup_diagnostics_routes( rag_manager, rag_available: bool, research_handler, + memory_vector=None, ) -> APIRouter: router = APIRouter(tags=["diagnostics"]) + @router.get("/api/diagnostics/services") + async def get_service_health(request: Request) -> Dict[str, Any]: + """Consolidated degraded-state report for ChromaDB, SearXNG, email, + ntfy, and provider endpoints. Non-intrusive probes — safe to poll.""" + require_admin(request) + from src.service_health import collect_service_health + return await collect_service_health(rag_manager, memory_vector) + @router.get("/api/db/stats") async def get_database_stats(request: Request) -> Dict[str, Any]: require_admin(request) diff --git a/routes/document_routes.py b/routes/document_routes.py index e2b562159..e4598d925 100644 --- a/routes/document_routes.py +++ b/routes/document_routes.py @@ -7,14 +7,24 @@ from typing import Dict, Any, List, Optional from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form -from sqlalchemy import func +from sqlalchemy import case, func, or_ from core.database import SessionLocal, Document, DocumentVersion from core.database import Session as DbSession from src.auth_helpers import get_current_user +from src.constants import MAIL_ATTACHMENTS_DIR logger = logging.getLogger(__name__) +def _get_session_or_404(db, session_id: str, user: Optional[str]): + session = db.query(DbSession).filter(DbSession.id == session_id).first() + if not session: + raise HTTPException(404, "Session not found") + if user and session.owner != user: + raise HTTPException(404, "Session not found") + return session + + def _aggregate_language_facets(lang_rows): """Sum document counts per display language for the library facet. @@ -30,6 +40,19 @@ def _aggregate_language_facets(lang_rows): return out +def _library_language_for_document(doc: Document) -> str: + """Return the display language used by the document library. + + PDF documents are stored as markdown wrappers so the editor can preserve + extracted text, form fields, and annotations. The library should still + identify them as PDFs instead of exposing that internal wrapper format. + """ + from src.pdf_form_doc import find_source_upload_id + + if find_source_upload_id(doc.current_content or ""): + return "pdf" + return doc.language or "text" + from routes.document_helpers import ( DocumentCreate, DocumentUpdate, DocumentPatch, @@ -69,17 +92,12 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: # the doc is owner-stamped, so it lives in the library on its own. session = None if req.session_id: - session = db.query(DbSession).filter(DbSession.id == req.session_id).first() - if not session: - raise HTTPException(404, "Session not found") # Match the lenient ownership model the rest of the app uses # (see _owner_filter): only block when an AUTHENTICATED user is # writing into a DIFFERENT user's session. In single-user / - # unconfigured / localhost-bypass mode the middleware leaves - # current_user unset (None), and those sessions are already - # served freely everywhere else. - if user and session.owner and session.owner != user: - raise HTTPException(403, "Cannot create document in another user's session") + # unconfigured / localhost-bypass mode, falsey users preserve + # the existing lenient path. + session = _get_session_or_404(db, req.session_id, user) doc_id = str(uuid.uuid4()) ver_id = str(uuid.uuid4()) @@ -90,10 +108,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: # to markdown for prose. language = req.language if not language: - from src.tool_implementations import _looks_like_email_document, _sniff_doc_language + from src.agent_tools.document_tools import _looks_like_email_document, _sniff_doc_language language = _sniff_doc_language(req.content) else: - from src.tool_implementations import _looks_like_email_document + from src.agent_tools.document_tools import _looks_like_email_document if _looks_like_email_document(req.content, req.title): language = "email" @@ -171,11 +189,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: if session_id: db = SessionLocal() try: - sess = db.query(DbSession).filter(DbSession.id == session_id).first() - if not sess: - raise HTTPException(404, "Session not found") - if user and sess.owner and sess.owner != user: - raise HTTPException(403, "Cannot import into another user's session") + _get_session_or_404(db, session_id, user) finally: db.close() @@ -198,7 +212,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: title = os.path.splitext(meta.get("original_name") or meta.get("name") or upload_id)[0] try: - body_text = strip_pdf_content_marker(_process_pdf(pdf_path)) + body_text = strip_pdf_content_marker(_process_pdf(pdf_path, owner=user)) except Exception: body_text = None @@ -260,18 +274,29 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: db = SessionLocal() try: from sqlalchemy import or_ + pdf_marker_cond = or_( + Document.current_content.like('%" + return {"output": output, "exit_code": 0} + +class WebFetchTool: + async def execute(self, content: str, ctx: dict) -> dict: + from src.search.content import fetch_webpage_content + raw = content.strip() + url = "" + if raw.startswith("{"): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + url = str(parsed.get("url") or "").strip() + except json.JSONDecodeError: + url = "" + if not url: + url = raw.split("\n")[0].strip() + if not url or url.startswith("{") or any(c in url for c in (" ", "\t", "\n")): + return {"error": "web_fetch: provide a single URL or domain, e.g. example.com", "exit_code": 1} + low = url.lower() + if "://" in low and not low.startswith(("http://", "https://")): + return {"error": f"web_fetch: unsupported URL scheme (only http/https): {url[:80]}", "exit_code": 1} + if not low.startswith(("http://", "https://")): + url = "https://" + url + loop = asyncio.get_running_loop() + try: + result = await asyncio.wait_for( + loop.run_in_executor(None, lambda: fetch_webpage_content(url, timeout=10)), + timeout=30, + ) + except asyncio.TimeoutError: + return {"error": f"web_fetch: timed out fetching {url}", "exit_code": 1} + except Exception as e: + return {"error": f"web_fetch: {url}: {e}", "exit_code": 1} + err = result.get("error") + text = (result.get("content") or "").strip() + title = result.get("title") or "" + + if not text: + if err: + return {"error": f"web_fetch: {url}: {err}", "exit_code": 1} + return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1} + + header = (f"# {title}\n" if title else "") + f"Source: {url}\n\n" + output = header + text + if len(output) > MAX_OUTPUT_CHARS: + output = output[:MAX_OUTPUT_CHARS] + "\n\n[...truncated]" + return {"output": output, "exit_code": 0} diff --git a/src/ai_interaction.py b/src/ai_interaction.py index 383560eed..20294b61b 100644 --- a/src/ai_interaction.py +++ b/src/ai_interaction.py @@ -14,6 +14,8 @@ import uuid import time from typing import Dict, Optional, Tuple +from src.constants import GENERATED_IMAGES_DIR + logger = logging.getLogger(__name__) AI_CHAT_TIMEOUT = 120 # seconds for a single LLM call @@ -22,7 +24,9 @@ MAX_PIPELINE_STEPS = 10 # --------------------------------------------------------------------------- # Global managers (set from app.py, same pattern as _mcp_manager) -# --------------------------------------------------------------------------- +# _session_manager is kept as a local cache for performance (avoiding +# repeated get_session_manager_instance() calls). It's synced with +# the authoritative singleton in core.models. _session_manager = None _memory_manager = None _memory_vector = None @@ -31,11 +35,15 @@ _personal_docs_manager = None def set_session_manager(mgr): + """Set the global session manager. Syncs local cache + core singleton.""" global _session_manager _session_manager = mgr + from core.models import set_session_manager_instance + set_session_manager_instance(mgr) def get_session_manager(): + """Get the global session manager.""" return _session_manager @@ -55,7 +63,7 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None): # Model resolution # --------------------------------------------------------------------------- -from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url, build_headers, build_models_url +from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, resolve_endpoint_runtime def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]: @@ -96,9 +104,12 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di (f" matching '{target_endpoint_name}'" if target_endpoint_name else "")) for ep in endpoints: - base = _normalize_base(ep.base_url) + try: + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + except Exception: + continue provider = _detect_provider(base) - headers = build_headers(ep.api_key, base) + headers = build_headers(api_key, base) if provider == "anthropic": # Anthropic: match against hardcoded model list @@ -112,16 +123,20 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di else: # OpenAI-compatible and native Ollama: probe the provider's model list. try: - r = httpx.get(build_models_url(base), headers=headers, timeout=5) - r.raise_for_status() - data = r.json() - model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")] - if not model_ids: - model_ids = [ - m.get("name") or m.get("model") - for m in (data.get("models") or []) - if m.get("name") or m.get("model") - ] + models_url = build_models_url(base) + if models_url: + r = httpx.get(models_url, headers=headers, timeout=5) + r.raise_for_status() + data = r.json() + model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")] + if not model_ids: + model_ids = [ + m.get("name") or m.get("model") + for m in (data.get("models") or []) + if m.get("name") or m.get("model") + ] + else: + model_ids = json.loads(ep.cached_models or "[]") except Exception: model_ids = [] @@ -1119,25 +1134,32 @@ async def do_list_models(content: str, session_id: Optional[str] = None, owner: total_models = 0 for ep in endpoints: - base = _normalize_base(ep.base_url) + try: + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + except Exception: + continue provider = _detect_provider(base) - headers = build_headers(ep.api_key, base) + headers = build_headers(api_key, base) model_ids = [] if provider == "anthropic": model_ids = list(ANTHROPIC_MODELS) else: try: - r = httpx.get(build_models_url(base), headers=headers, timeout=5) - r.raise_for_status() - data = r.json() - model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")] - if not model_ids: - model_ids = [ - m.get("name") or m.get("model") - for m in (data.get("models") or []) - if m.get("name") or m.get("model") - ] + models_url = build_models_url(base) + if models_url: + r = httpx.get(models_url, headers=headers, timeout=5) + r.raise_for_status() + data = r.json() + model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")] + if not model_ids: + model_ids = [ + m.get("name") or m.get("model") + for m in (data.get("models") or []) + if m.get("name") or m.get("model") + ] + else: + model_ids = json.loads(ep.cached_models or "[]") except Exception: model_ids = ["(endpoint offline)"] @@ -1268,7 +1290,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O toggle — Toggle a setting (web, bash, rag, research, incognito, document_editor) set_mode — Switch between agent and chat mode switch_model — Change the model for the current session - set_theme — Apply a theme preset (dark, light, paper, nord, dracula, gruvbox, gpt, claude, lavender, etc.) + set_theme — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute) create_theme [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=, bgEffectColor=#RRGGBB, bgEffectIntensity=, bgEffectSize=, frosted=true|false open_panel — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook) open_email_reply [folder] [reply|reply-all|ai-reply] — Open a reply draft document for an email; does not send @@ -1715,7 +1737,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne # GPT image models always return b64_json; DALL-E may return url if img.get("b64_json"): - img_dir = Path("data/generated_images") + img_dir = Path(GENERATED_IMAGES_DIR) img_dir.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex[:12]}.png" img_path = img_dir / filename @@ -1728,7 +1750,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne try: dl_resp = httpx.get(img["url"], timeout=60) if dl_resp.status_code == 200: - img_dir = Path("data/generated_images") + img_dir = Path(GENERATED_IMAGES_DIR) img_dir.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex[:12]}.png" img_path = img_dir / filename diff --git a/src/auth_helpers.py b/src/auth_helpers.py index afe46c74e..49f3f01be 100644 --- a/src/auth_helpers.py +++ b/src/auth_helpers.py @@ -34,6 +34,24 @@ def effective_user(request: Request) -> Optional[str]: return get_current_user(request) +def _is_api_token_request(request: Request) -> bool: + """Return True when middleware authenticated a bearer API token.""" + return bool(getattr(request.state, "api_token", False)) + + +def require_authenticated_request(request: Request) -> str: + """Allow either a browser session or a valid bearer API token. + + This is intentionally narrower than :func:`require_user`: use it only for + routes that need authentication but do not read or mutate owner-scoped + user data. Owner-scoped routes should use ``require_user`` for browser + sessions or their own API-token scope/owner gate. + """ + if _is_api_token_request(request): + return effective_user(request) or "" + return require_user(request) + + def _auth_disabled() -> bool: """True when the operator has explicitly turned off auth via .env. Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the @@ -60,6 +78,9 @@ def require_user(request: Request) -> str: Use this on routes that touch user data so middleware misconfig can't open them up. """ + if _is_api_token_request(request): + raise HTTPException(403, "API tokens must use a scope-aware API route") + u = get_current_user(request) if u: return u diff --git a/src/bg_jobs.py b/src/bg_jobs.py index c103dfdfc..8e452106b 100644 --- a/src/bg_jobs.py +++ b/src/bg_jobs.py @@ -38,9 +38,10 @@ from core.platform_compat import ( pid_alive, ) -_DATA_DIR = Path(os.environ.get("DATA_DIR", "data")) -_JOBS_DIR = _DATA_DIR / "bg_jobs" -_STORE = _DATA_DIR / "bg_jobs.json" +from src.constants import BG_JOBS_DIR, BG_JOBS_FILE + +_JOBS_DIR = Path(BG_JOBS_DIR) +_STORE = Path(BG_JOBS_FILE) # A job that runs longer than this is presumed stuck and reaped (the agent # still gets a "timed out" follow-up so nothing hangs forever). diff --git a/src/builtin_actions.py b/src/builtin_actions.py index 21975f910..1ea7cd8a4 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -12,6 +12,8 @@ from typing import Tuple from src.auth_helpers import owner_filter from core.platform_compat import IS_WINDOWS, find_bash +from core.constants import internal_api_base +from src.constants import DATA_DIR, DEEP_RESEARCH_DIR, TIDY_CALENDAR_STATE_FILE, EMAIL_URGENCY_CACHE_DIR, COOKBOOK_STATE_FILE logger = logging.getLogger(__name__) @@ -166,7 +168,6 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: drop_items = decision.get("drop") if isinstance(decision, dict) else None if isinstance(keep_items, list) and isinstance(drop_items, list): by_id = {m.get("id"): m for m in group_memories if m.get("id")} - keep_ids = set() cleaned_by_id = {} for item in keep_items: if not isinstance(item, dict): @@ -177,7 +178,6 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: text = (item.get("text") or "").strip() if not text: continue - keep_ids.add(mid) cleaned = { "category": (item.get("category") or by_id[mid].get("category") or "fact").strip(), } @@ -186,11 +186,20 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: cleaned["text"] = text cleaned_by_id[mid] = cleaned - # If the model only saw a truncated memory, do not let - # that partial view delete or rewrite the full memory. - keep_ids.update(mid for mid in truncated_ids if mid in by_id) + # Delete only memories the model EXPLICITLY dropped, never + # ones it merely omitted from `keep`. Treating the + # complement of `keep` as deletions meant a model that + # forgot to re-list an id (common) silently destroyed that + # memory. Honor the explicit `drop` set instead. + drop_ids = { + d.get("id") + for d in drop_items + if isinstance(d, dict) and d.get("id") in by_id + } + # Never delete a memory the model only saw truncated. + drop_ids -= truncated_ids - if keep_ids: + if drop_ids or cleaned_by_id: changed_text = 0 group_ref_ids = {id(m) for m in group_memories} kept_all = [] @@ -199,7 +208,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: kept_all.append(mem) continue mid = mem.get("id") - if mid not in keep_ids: + if mid in drop_ids: continue cleaned = cleaned_by_id.get(mid) or {} if mid in truncated_ids: @@ -211,7 +220,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]: mem["category"] = cleaned["category"] kept_all.append(mem) - removed = len(group_memories) - len(keep_ids) + removed = sum(1 for m in group_memories if m.get("id") in drop_ids) total_scanned += len(group_memories) if removed or changed_text: all_memories = kept_all @@ -348,7 +357,7 @@ async def action_tidy_research(owner: str, **kwargs) -> Tuple[str, bool]: try: from pathlib import Path import json as _json - research_dir = Path("data/deep_research") + research_dir = Path(DEEP_RESEARCH_DIR) if not research_dir.exists(): raise TaskNoop("no research directory") files = list(research_dir.glob("*.json")) @@ -386,7 +395,7 @@ async def action_tidy_calendar(owner: str, **kwargs) -> Tuple[str, bool]: from core.database import SessionLocal, CalendarEvent from sqlalchemy import func - STATE_FILE = Path("data/tidy_calendar_state.json") + STATE_FILE = Path(TIDY_CALENDAR_STATE_FILE) last_watermark = None try: if STATE_FILE.exists(): @@ -570,6 +579,24 @@ def _classify_event_heuristic(summary: str) -> tuple: return etype, None +def _memory_context_lines(mems, limit: int = 40) -> list: + """Render Memory rows into short personal-context bullets for event classify. + + Reads the Memory ORM `text` column. The previous inline code read a + non-existent `content` attribute, so it raised AttributeError on the first + row, the surrounding except swallowed it, and the classifier ran with no + personal context at all. getattr keeps it robust to future schema drift. + """ + lines: list = [] + for m in mems: + c = (getattr(m, "text", "") or "").strip() + if c: + lines.append(f"- {c[:200]}") + if len(lines) >= limit: + break + return lines + + async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: """Hybrid classification of upcoming calendar events: fast heuristic for obvious cases, LLM fallback for ambiguous ones. Assigns event_type + @@ -605,16 +632,11 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: try: from core.database import Memory as _Mem _mems = db.query(_Mem).filter(_Mem.owner == owner).limit(60).all() if owner else [] - if _mems: - _lines = [] - for m in _mems: - c = (m.content or "").strip() - if c: - _lines.append(f"- {c[:200]}") - if _lines: - _memory_context = "USER CONTEXT (relationships, work, life):\n" + "\n".join(_lines[:40]) + "\n\n" + _lines = _memory_context_lines(_mems) + if _lines: + _memory_context = "USER CONTEXT (relationships, work, life):\n" + "\n".join(_lines) + "\n\n" except Exception as _me: - logger.debug(f"Could not load memory for classify: {_me}") + logger.warning(f"Could not load memory for classify: {_me}") classified_h = 0 classified_llm = 0 @@ -1303,12 +1325,12 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]: # users' entries (review C4). Legacy path kept as fallback so a # single-user install (empty owner) doesn't lose its history. _owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default")) - STATE = _P(f"data/note_pings_{_owner_slug}.json") + STATE = _P(DATA_DIR) / f"note_pings_{_owner_slug}.json" STATE.parent.mkdir(parents=True, exist_ok=True) # One-time migration: if legacy global file exists and per-owner file # doesn't, seed from global (entries for OTHER owners still get pruned # on their first run — acceptable, prevents silent loss). - _legacy = _P("data/note_pings.json") + _legacy = _P(DATA_DIR) / "note_pings.json" if _legacy.exists() and not STATE.exists(): try: STATE.write_text(_legacy.read_text(encoding="utf-8"), encoding="utf-8") @@ -1465,8 +1487,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]: # notified_uids / urgency counts. Empty owner falls back to a generic # filename for single-user installs (matches prior behaviour). _owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default")) - STATE_PATH = _P(f"data/email_urgency_state_{_owner_slug}.json") - CACHE_DIR = _P("data/email_urgency_cache") + STATE_PATH = _P(DATA_DIR) / f"email_urgency_state_{_owner_slug}.json" + CACHE_DIR = _P(EMAIL_URGENCY_CACHE_DIR) CACHE_DIR.mkdir(parents=True, exist_ok=True) STATE_PATH.parent.mkdir(parents=True, exist_ok=True) AGE_CUTOFF = _dt.utcnow() - _td(days=7) @@ -2042,7 +2064,7 @@ async def action_cookbook_serve( except Exception: end_after_min = 0 - state_path = Path("/app/data/cookbook_state.json") + state_path = Path(COOKBOOK_STATE_FILE) try: state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {} except Exception: @@ -2118,7 +2140,7 @@ async def action_cookbook_serve( try: async with httpx.AsyncClient(timeout=30) as client: - r = await client.post("http://localhost:7000/api/model/serve", + r = await client.post(f"{internal_api_base()}/api/model/serve", json=body, headers=headers) data = r.json() if r.content else {} except Exception as e: diff --git a/src/builtin_mcp.py b/src/builtin_mcp.py index fb9a878fe..cf528c10d 100644 --- a/src/builtin_mcp.py +++ b/src/builtin_mcp.py @@ -8,6 +8,7 @@ Each server runs as a stdio subprocess managed by McpManager. import logging import os import shutil +import subprocess import sys import asyncio @@ -208,6 +209,16 @@ async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5): stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) + except NotImplementedError: + try: + result = subprocess.run( + [npx_path, "--no-install", package_spec, "--version"], + capture_output=True, + timeout=timeout_s, + ) + except (subprocess.TimeoutExpired, OSError, ValueError): + return False + return result.returncode == 0 and bool(result.stdout.strip()) except (OSError, ValueError): return False try: diff --git a/src/caldav_sync.py b/src/caldav_sync.py index a2ce22acf..e4afb89fd 100644 --- a/src/caldav_sync.py +++ b/src/caldav_sync.py @@ -216,18 +216,57 @@ def _open_url_as_calendar(client, url: str): return client.calendar(url=target) +def _build_dav_client(url: str, username: str, password: str): + """Construct a CalDAV client with automatic redirects disabled. + + ``validate_caldav_url`` resolves and vets the *initial* host, but caldav's + underlying HTTP session follows 3xx redirects by default. So a URL that + passes validation can still be redirected — at request time — to + loopback / link-local / private space, re-opening the SSRF the host check + closes. Pin the session to zero redirects: any 3xx then raises instead of + silently following an attacker-chosen ``Location``. This mirrors the + test-connection path in ``routes/calendar_routes.py``, which already sets + ``follow_redirects=False``. + + DAVClient exposes no per-request redirect flag, so we set it on the session + after construction (the session is created in ``__init__``). + """ + import caldav + + client = caldav.DAVClient(url=url, username=username, password=password) + # Unconditional: a redirect-disable that only sometimes applies is not a + # control. The session exists right after __init__ on every real client; + # test_build_dav_client_disables_redirects asserts it against installed + # caldav in CI. + client.session.max_redirects = 0 + return client + + +def _should_prune_window(seen_uids: set, parse_failed: bool) -> bool: + """Whether the post-sync prune of vanished CalDAV events is safe to run. + + The prune deletes local ``origin=="caldav"`` rows in the window whose UID the + server did not just return. Any parse failure (total or partial) makes + ``seen_uids`` an incomplete view of the server, so pruning against it can + delete events that still exist upstream but could not be read: a total + failure wipes the whole window, a partial failure deletes just the + unreadable ones. Only prune on a clean read. An empty ``seen_uids`` after a + clean read is a genuinely empty window, which is safe to prune. + """ + return not parse_failed + + def _sync_blocking(owner: str, url: str, username: str, password: str, account_id: str = "") -> dict: """The actual sync — synchronous, intended to run in a threadpool. Returns counts: {calendars, events, deleted, errors}.""" # Lazy imports so a missing `caldav` dep doesn't break app startup — # the integrations form still works, sync just no-ops with an error. - import caldav from caldav.lib.error import AuthorizationError, NotFoundError from core.database import CalendarCal, CalendarEvent, SessionLocal result = {"calendars": 0, "events": 0, "deleted": 0, "errors": []} - client = caldav.DAVClient(url=url, username=username, password=password) + client = _build_dav_client(url, username, password) # Discovery: try principal → calendars first; if the server doesn't # support discovery (or the URL points directly at a calendar), fall @@ -303,6 +342,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i # duplicate UIDs within the same batch are updated, not re-inserted # (which would violate the UNIQUE constraint on commit). pending: dict = {} + parse_failed = False try: objs = remote_cal.date_search(start=start, end=end, expand=False) except Exception as e: @@ -314,6 +354,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i ical = iCal.from_ical(obj.data) except Exception as e: result["errors"].append(f"{display_name}: parse failed ({e})") + parse_failed = True continue for comp in ical.walk(): @@ -390,17 +431,23 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i # are prunable; locally-created events (agent / email triage / a # UI event whose write-back failed) carry origin NULL and must # never be deleted just because the server didn't return them. - stale = db.query(CalendarEvent).filter( - CalendarEvent.calendar_id == local_cal.id, - CalendarEvent.origin == "caldav", - CalendarEvent.dtstart >= start, - CalendarEvent.dtstart <= end, - ~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None), - ).all() - for ev in stale: - db.delete(ev) - result["deleted"] += len(stale) - db.commit() + # Skip the prune on any parse failure: seen_uids is then an + # incomplete view of the server, so pruning against it would + # delete events that still exist upstream but could not be read + # (the empty-seen_uids case wipes the whole window; a partial + # failure deletes just the unreadable rows). + if _should_prune_window(seen_uids, parse_failed): + stale = db.query(CalendarEvent).filter( + CalendarEvent.calendar_id == local_cal.id, + CalendarEvent.origin == "caldav", + CalendarEvent.dtstart >= start, + CalendarEvent.dtstart <= end, + ~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None), + ).all() + for ev in stale: + db.delete(ev) + result["deleted"] += len(stale) + db.commit() except Exception as e: logger.exception("CalDAV sync failed for one calendar") result["errors"].append(str(e)[:200]) diff --git a/src/caldav_writeback.py b/src/caldav_writeback.py index b1b92c05f..0866e1467 100644 --- a/src/caldav_writeback.py +++ b/src/caldav_writeback.py @@ -143,8 +143,10 @@ def _discover_calendars(client): def _writeback_blocking(local_cal_id, ev, delete, url, username, password, owner="", account_id="") -> dict: - import caldav - client = caldav.DAVClient(url=url, username=username, password=password) + from src.caldav_sync import _build_dav_client + # Redirects disabled here too: the write-back path opens its own DAVClient, + # so it needs the same SSRF-via-redirect protection as the pull path. + client = _build_dav_client(url, username, password) calendars = _discover_calendars(client) if not calendars: return {"ok": False, "error": "no remote calendars discovered"} diff --git a/src/chat_handler.py b/src/chat_handler.py index a648d5394..45666dd8d 100644 --- a/src/chat_handler.py +++ b/src/chat_handler.py @@ -98,6 +98,7 @@ class ChatHandler: att_ids: List[str], sess, auto_opened_docs: Optional[List[Dict[str, Any]]] = None, + allow_tool_preprocessing: bool = True, ) -> tuple: """ Common preprocessing for both chat endpoints. @@ -112,7 +113,7 @@ class ChatHandler: attachment_meta: List[Dict[str, Any]] = [] # Extract URLs and process YouTube transcripts - urls = extract_urls(enhanced_message) + urls = extract_urls(enhanced_message) if allow_tool_preprocessing else [] youtube_transcripts: List[str] = [] has_youtube = False @@ -143,24 +144,18 @@ class ChatHandler: if has_youtube: youtube_transcripts.insert(0, YOUTUBE_INSTRUCTION_PROMPT) - # Analyze images — skip if vision disabled, or if main model is vision-capable - from src.settings import get_setting - vision_enabled = get_setting("vision_enabled", True) - main_is_vision = await asyncio.to_thread( - model_supports_vision, sess.model or "", getattr(sess, "endpoint_url", "") or "" - ) - # Resolve uploads once with the session owner. Attachment IDs are # bearer-like references; never trust them without an owner check. files_by_id: Dict[str, Dict] = {} owner = getattr(sess, "owner", None) - if att_ids: - for att_id in att_ids: + effective_att_ids = att_ids if allow_tool_preprocessing else [] + if effective_att_ids: + for att_id in effective_att_ids: fi = self.upload_handler.resolve_upload(att_id, owner=owner) if fi: files_by_id[att_id] = fi - for att_id in att_ids: + for att_id in effective_att_ids: fi = files_by_id.get(att_id) if fi: attachment_meta.append({ @@ -172,9 +167,24 @@ class ChatHandler: "height": fi.get("height"), }) - if att_ids and vision_enabled: + # Analyze images only when attachment preprocessing is actually + # allowed. The vision capability check can probe local model endpoints, + # so guide-only/no-tools turns must not reach it. + vision_enabled = False + main_is_vision = False + if effective_att_ids: + from src.settings import get_setting + vision_enabled = get_setting("vision_enabled", True) + if vision_enabled: + main_is_vision = await asyncio.to_thread( + model_supports_vision, + sess.model or "", + getattr(sess, "endpoint_url", "") or "", + ) + + if effective_att_ids and vision_enabled: meta_by_id = {m["id"]: m for m in attachment_meta} - for att_id in att_ids: + for att_id in effective_att_ids: file_info = files_by_id.get(att_id) if file_info and self.upload_handler.is_image_file( file_info["name"], file_info.get("mime", "") @@ -219,7 +229,7 @@ class ChatHandler: except Exception: vl_desc = None if not vl_desc: - vl_result = analyze_image_with_vl_result(file_info["path"]) + vl_result = analyze_image_with_vl_result(file_info["path"], owner=owner) vl_desc = vl_result.get("text", "") vl_model = vl_result.get("model", "") if vl_desc and not vl_desc.startswith("["): @@ -239,7 +249,7 @@ class ChatHandler: _m["vision_model"] = vl_model user_content = build_user_content( - enhanced_message, att_ids, UPLOAD_DIR, self.upload_handler, + enhanced_message, effective_att_ids, UPLOAD_DIR, self.upload_handler, session_id=getattr(sess, "id", None), auto_opened_docs=auto_opened_docs, owner=owner, diff --git a/src/chat_helpers.py b/src/chat_helpers.py index 1c8d1c9f7..a8f5f54a8 100644 --- a/src/chat_helpers.py +++ b/src/chat_helpers.py @@ -13,6 +13,8 @@ from fastapi import HTTPException from fastapi import UploadFile from typing import List, Optional +from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes + logger = logging.getLogger(__name__) @@ -22,7 +24,14 @@ def extract_urls(text: str) -> List[str]: urls = re.findall(url_pattern, text) cleaned_urls = [] for url in urls: - url = re.sub(r'[.,;:!?\)]+$', '', url) + # Strip trailing sentence punctuation, but keep a balanced ')' so URLs + # that legitimately end in one are preserved, e.g. the Wikipedia link + # ".../Python_(programming_language)". A ')' is only dropped when it is + # unbalanced (more ')' than '('), which is the prose-glued case such as + # "(see https://example.com)". + url = re.sub(r'[.,;:!?]+$', '', url) + while url.endswith(')') and url.count(')') > url.count('('): + url = re.sub(r'[.,;:!?]+$', '', url[:-1]) cleaned_urls.append(url) return cleaned_urls @@ -201,12 +210,13 @@ def validate_file_upload(file: UploadFile) -> UploadFile: } ) - if file_size > 10 * 1024 * 1024: + upload_limit = get_chat_upload_max_bytes() + if file_size > upload_limit: raise HTTPException( status_code=400, detail={ "error": "FILE_TOO_LARGE", - "message": "File size exceeds 10MB limit" + "message": f"File size exceeds {format_byte_limit(upload_limit)} limit" } ) except IOError as e: diff --git a/src/chat_processor.py b/src/chat_processor.py index 02062ae74..75e4c698c 100644 --- a/src/chat_processor.py +++ b/src/chat_processor.py @@ -175,6 +175,19 @@ class ChatProcessor: Returns: Tuple of (preface messages, rag_sources list) + + Note on KV-cache friendliness: the ``system``-role messages assembled + here are later concatenated into a single system message and sent as + the very first thing in the payload (see ``llm_core``'s "consolidate + system messages" step). Local OpenAI-compatible backends (llama.cpp / + LM Studio) key their KV cache off the byte-identical token prefix, so + *anything* that changes turn-to-turn — timestamps, retrieved snippets, + per-turn counts — must NOT be folded into a system message here. Such + content belongs in a separate ``user``/context message appended near + the end of the array (see ``current_datetime_context_message`` and + ``untrusted_context_message`` callers in ``build_chat_context``), + which keeps the static system prefix byte-identical across turns of + the same session and lets the backend reuse its cached prefix. """ preface = [] rag_sources = [] @@ -185,15 +198,6 @@ class ChatProcessor: "role": "system", "content": preset_system_prompt }) - if not agent_mode: - try: - from src.user_time import current_datetime_prompt - preface.append({ - "role": "system", - "content": current_datetime_prompt(), - }) - except Exception: - logger.debug("Failed to add current date/time context", exc_info=True) preface.append({ "role": "system", "content": UNTRUSTED_CONTEXT_POLICY, diff --git a/src/chatgpt_subscription.py b/src/chatgpt_subscription.py new file mode 100644 index 000000000..e65ccbc8d --- /dev/null +++ b/src/chatgpt_subscription.py @@ -0,0 +1,315 @@ +"""ChatGPT subscription / Codex backend OAuth helpers. + +This provider is intentionally separate from OpenAI API-key endpoints. It uses +OpenAI account OAuth device authorization, stores refresh tokens server-side, +and resolves a fresh bearer token at request time. +""" + +from __future__ import annotations + +import base64 +import json +import os +import threading +import time +from typing import Any, Dict, Optional + +import httpx +from fastapi import HTTPException + +DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = ( + os.getenv("CHATGPT_SUBSCRIPTION_BASE_URL", "").strip().rstrip("/") + or "https://chatgpt.com/backend-api/codex" +) +CHATGPT_SUBSCRIPTION_PROVIDER = "chatgpt-subscription" +CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +CHATGPT_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" +CHATGPT_OAUTH_ISSUER = "https://auth.openai.com" +CHATGPT_OAUTH_REDIRECT_URI = f"{CHATGPT_OAUTH_ISSUER}/deviceauth/callback" +CHATGPT_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 +_AUTH_REFRESH_LOCKS: dict[str, threading.Lock] = {} +_AUTH_REFRESH_LOCKS_GUARD = threading.Lock() + + +def _database_handles(): + from core.database import ProviderAuthSession, SessionLocal, utcnow_naive + return ProviderAuthSession, SessionLocal, utcnow_naive + + +def _refresh_lock_for(auth_id: str) -> threading.Lock: + with _AUTH_REFRESH_LOCKS_GUARD: + lock = _AUTH_REFRESH_LOCKS.get(auth_id) + if lock is None: + lock = threading.Lock() + _AUTH_REFRESH_LOCKS[auth_id] = lock + return lock + + +class ChatGPTSubscriptionError(RuntimeError): + """Base error for ChatGPT subscription provider failures.""" + + +class ChatGPTSubscriptionReauthRequired(ChatGPTSubscriptionError): + """Stored OAuth credentials are invalid or expired beyond refresh.""" + + +class ChatGPTSubscriptionRateLimited(ChatGPTSubscriptionError): + """Upstream quota/rate limit; reconnecting will not fix it.""" + + +class ChatGPTSubscriptionAuthNotFound(ChatGPTSubscriptionError): + """No matching owner-scoped auth session exists.""" + + +def is_chatgpt_subscription_base(url: str) -> bool: + try: + from urllib.parse import urlparse + + parsed = urlparse(url or "") + host = (parsed.hostname or "").lower().rstrip(".") + path = (parsed.path or "").rstrip("/") + except Exception: + return False + return host == "chatgpt.com" and ( + path == "/backend-api/codex" or path.startswith("/backend-api/codex/") + ) + + +def chatgpt_headers(access_token: Optional[str]) -> Dict[str, str]: + headers = { + "Accept": "application/json, text/event-stream", + "Origin": "https://chatgpt.com", + "Referer": "https://chatgpt.com/codex", + "User-Agent": "Odysseus ChatGPT Subscription", + } + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +def fetch_available_models(access_token: str, timeout: float = 10.0) -> list[str]: + if not access_token: + return [] + try: + response = httpx.get( + "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", + headers=chatgpt_headers(access_token), + timeout=timeout, + ) + if response.status_code != 200: + return [] + data = response.json() + except Exception: + return [] + entries = data.get("models", []) if isinstance(data, dict) else [] + sortable: list[tuple[int, str]] = [] + for item in entries: + if not isinstance(item, dict): + continue + slug = item.get("slug") + if not isinstance(slug, str) or not slug.strip(): + continue + visibility = item.get("visibility", "") + if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: + continue + priority = item.get("priority") + rank = int(priority) if isinstance(priority, (int, float)) else 10_000 + sortable.append((rank, slug.strip())) + sortable.sort(key=lambda item: (item[0], item[1])) + ordered: list[str] = [] + seen: set[str] = set() + for _, slug in sortable: + if slug not in seen: + ordered.append(slug) + seen.add(slug) + return ordered + + +def _raise_for_oauth_response(response: httpx.Response, action: str) -> None: + if response.status_code < 400: + return + code = "" + message = f"ChatGPT Subscription {action} failed with HTTP {response.status_code}." + try: + payload = response.json() + err = payload.get("error") if isinstance(payload, dict) else None + if isinstance(err, dict): + code = str(err.get("code") or err.get("type") or "").strip() + msg = err.get("message") + if msg: + message = f"ChatGPT Subscription {action} failed: {msg}" + elif isinstance(err, str): + code = err.strip() + desc = payload.get("error_description") or payload.get("message") + if desc: + message = f"ChatGPT Subscription {action} failed: {desc}" + except Exception: + pass + if response.status_code == 429: + raise ChatGPTSubscriptionRateLimited( + "ChatGPT Subscription quota or rate limit was reached. Credentials are still valid." + ) + if response.status_code in (401, 403) or code in {"invalid_grant", "invalid_token", "invalid_request", "refresh_token_reused"}: + raise ChatGPTSubscriptionReauthRequired(message) + raise ChatGPTSubscriptionError(message) + + +def _json_or_error(response: httpx.Response, action: str) -> Dict[str, Any]: + _raise_for_oauth_response(response, action) + try: + data = response.json() + except Exception as exc: + raise ChatGPTSubscriptionError(f"ChatGPT Subscription {action} returned invalid JSON.") from exc + if not isinstance(data, dict): + raise ChatGPTSubscriptionError(f"ChatGPT Subscription {action} returned an unexpected response.") + return data + + +def request_device_code(timeout: float = 15.0) -> Dict[str, Any]: + response = httpx.post( + f"{CHATGPT_OAUTH_ISSUER}/api/accounts/deviceauth/usercode", + json={"client_id": CHATGPT_OAUTH_CLIENT_ID}, + headers={"Content-Type": "application/json"}, + timeout=timeout, + ) + data = _json_or_error(response, "device-code request") + if not data.get("device_auth_id") or not data.get("user_code"): + raise ChatGPTSubscriptionError("ChatGPT device-code response was missing required fields.") + data.setdefault("verification_uri", f"{CHATGPT_OAUTH_ISSUER}/codex/device") + data.setdefault("interval", 5) + data.setdefault("expires_in", 900) + return data + + +def poll_device_auth(device_auth_id: str, user_code: str, timeout: float = 15.0) -> Dict[str, Any]: + response = httpx.post( + f"{CHATGPT_OAUTH_ISSUER}/api/accounts/deviceauth/token", + json={"device_auth_id": device_auth_id, "user_code": user_code}, + headers={"Content-Type": "application/json"}, + timeout=timeout, + ) + if response.status_code in (403, 404): + return {"status": "pending", "error": "authorization_pending"} + return _json_or_error(response, "device-code poll") + + +def exchange_authorization_code(authorization_code: str, code_verifier: str, timeout: float = 15.0) -> Dict[str, Any]: + response = httpx.post( + CHATGPT_OAUTH_TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CHATGPT_OAUTH_REDIRECT_URI, + "client_id": CHATGPT_OAUTH_CLIENT_ID, + "code_verifier": code_verifier, + }, + timeout=timeout, + ) + data = _json_or_error(response, "token exchange") + if not data.get("access_token"): + raise ChatGPTSubscriptionReauthRequired("ChatGPT token exchange did not return an access token.") + return data + + +def refresh_oauth_tokens(access_token: str, refresh_token: str, timeout: float = 20.0) -> Dict[str, Any]: + del access_token + if not refresh_token: + raise ChatGPTSubscriptionReauthRequired("ChatGPT Subscription is missing a refresh token. Reconnect the provider.") + response = httpx.post( + CHATGPT_OAUTH_TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CHATGPT_OAUTH_CLIENT_ID, + }, + timeout=timeout, + ) + data = _json_or_error(response, "token refresh") + if not data.get("access_token"): + raise ChatGPTSubscriptionReauthRequired("ChatGPT token refresh did not return an access token.") + return data + + +def _decode_jwt_payload(token: str) -> Dict[str, Any]: + parts = (token or "").split(".") + if len(parts) < 2: + raise ValueError("not a JWT") + segment = parts[1] + segment += "=" * (-len(segment) % 4) + raw = base64.urlsafe_b64decode(segment.encode("ascii")) + payload = json.loads(raw.decode("utf-8")) + return payload if isinstance(payload, dict) else {} + + +def access_token_is_expiring(access_token: str, skew_seconds: int = CHATGPT_ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> bool: + try: + exp = int(_decode_jwt_payload(access_token).get("exp") or 0) + except Exception: + return True + return exp <= int(time.time()) + int(skew_seconds) + + +def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, force_refresh: bool = False) -> Dict[str, Any]: + ProviderAuthSession, SessionLocal, utcnow_naive = _database_handles() + db = SessionLocal() + try: + q = db.query(ProviderAuthSession).filter( + ProviderAuthSession.id == auth_id, + ProviderAuthSession.provider == CHATGPT_SUBSCRIPTION_PROVIDER, + ) + if owner: + q = q.filter(ProviderAuthSession.owner == owner) + row = q.first() + if row is None: + raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription credentials were not found for this user.") + + access_token = row.access_token or "" + if force_refresh or access_token_is_expiring(access_token): + with _refresh_lock_for(auth_id): + db.refresh(row) + access_token = row.access_token or "" + refresh_token = row.refresh_token or "" + if force_refresh or access_token_is_expiring(access_token): + refreshed = refresh_oauth_tokens(access_token, refresh_token) + row.access_token = refreshed["access_token"] + if refreshed.get("refresh_token"): + row.refresh_token = refreshed["refresh_token"] + row.last_refresh = utcnow_naive() + db.commit() + db.refresh(row) + access_token = row.access_token or "" + + return { + "provider": CHATGPT_SUBSCRIPTION_PROVIDER, + "base_url": (row.base_url or DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL).rstrip("/"), + "api_key": access_token, + "auth_mode": row.auth_mode or "chatgpt", + } + finally: + db.close() + + +def to_http_exception(exc: Exception) -> HTTPException: + if isinstance(exc, ChatGPTSubscriptionRateLimited): + return HTTPException(429, str(exc)) + if isinstance(exc, (ChatGPTSubscriptionReauthRequired, ChatGPTSubscriptionAuthNotFound)): + return HTTPException(401, f"{exc} Reconnect the provider.") + return HTTPException(502, str(exc)) + + +def build_responses_input(messages: list[dict]) -> list[dict]: + input_items: list[dict] = [] + for msg in messages or []: + role = msg.get("role") or "user" + if role == "tool": + role = "user" + content = msg.get("content") + if isinstance(content, list): + text = "\n".join(str(part.get("text") or part.get("content") or "") for part in content if isinstance(part, dict)) + else: + text = "" if content is None else str(content) + input_type = "output_text" if role == "assistant" else "input_text" + input_items.append({"role": role, "content": [{"type": input_type, "text": text}]}) + return input_items diff --git a/src/config.py b/src/config.py index 58a5c466e..8b9bd5148 100644 --- a/src/config.py +++ b/src/config.py @@ -4,6 +4,8 @@ from typing import List, Optional from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import Field, field_validator +from src.constants import DATA_DIR as _DATA_DIR_CONST + # Cross-platform OS flag, exposed here so callers can `from src.config import # IS_WINDOWS`. Defined locally (a trivial `os.name == "nt"`) rather than imported # from core.platform_compat, to keep this dependency-light config module from @@ -20,13 +22,13 @@ class DataConfig(BaseSettings): base_dir: Path = Field(default=Path(__file__).parent.parent, description="Base directory for the application") # Data paths - data_dir: Path = Field(default=Path("data"), description="Main data directory") - uploads_dir: Path = Field(default=Path("data/uploads"), description="Directory for uploaded files") - sessions_file: Path = Field(default=Path("data/sessions.json"), description="Sessions storage file") - memory_file: Path = Field(default=Path("data/memory.json"), description="Memory storage file") - memory_doc: Path = Field(default=Path("data/memory_doc.md"), description="Memory document file") - personal_dir: Path = Field(default=Path("data/personal_docs"), description="Personal documents directory") - runbook_dir: Path = Field(default=Path("data/personal_docs/runbook"), description="Runbook directory") + data_dir: Path = Field(default=Path(_DATA_DIR_CONST), description="Main data directory") + uploads_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "uploads", description="Directory for uploaded files") + sessions_file: Path = Field(default=Path(_DATA_DIR_CONST) / "sessions.json", description="Sessions storage file") + memory_file: Path = Field(default=Path(_DATA_DIR_CONST) / "memory.json", description="Memory storage file") + memory_doc: Path = Field(default=Path(_DATA_DIR_CONST) / "memory_doc.md", description="Memory document file") + personal_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "personal_docs", description="Personal documents directory") + runbook_dir: Path = Field(default=Path(_DATA_DIR_CONST) / "personal_docs" / "runbook", description="Runbook directory") # Upload settings max_upload_size: int = Field(default=10 * 1024 * 1024, description="Maximum upload size in bytes (10MB)") @@ -139,7 +141,7 @@ class AppConfig(BaseSettings): base_dir = Path(__file__).parent.parent # Convert string paths to Path objects relative to base_dir - data_dir = base_dir / "data" + data_dir = Path(_DATA_DIR_CONST) # Get values from the input dict or use defaults max_upload_size = v.get("max_upload_size", 10 * 1024 * 1024) if isinstance(v, dict) else 10 * 1024 * 1024 diff --git a/src/constants.py b/src/constants.py index afe9db88a..3f58eba26 100644 --- a/src/constants.py +++ b/src/constants.py @@ -7,9 +7,12 @@ APP_VERSION = "1.0.0" # Base paths BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/" STATIC_DIR = os.path.join(BASE_DIR, "static") -DATA_DIR = os.path.join(BASE_DIR, "data") +DATA_DIR = os.getenv("ODYSSEUS_DATA_DIR", os.path.join(BASE_DIR, "data")) # Data file paths +# Single source of truth: every persisted file/dir lives under DATA_DIR, which +# is the ONLY place ODYSSEUS_DATA_DIR is read. Import these constants instead of +# re-deriving paths from __file__ or a relative "data" literal. SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json") MEMORY_FILE = os.path.join(DATA_DIR, "memory.json") MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md") @@ -18,6 +21,41 @@ RUNBOOK_DIR = os.path.join(PERSONAL_DIR, "runbook") UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") FEATURES_FILE = os.path.join(DATA_DIR, "features.json") SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json") +AUTH_FILE = os.path.join(DATA_DIR, "auth.json") +USER_PREFS_FILE = os.path.join(DATA_DIR, "user_prefs.json") +PRESETS_FILE = os.path.join(DATA_DIR, "presets.json") +INTEGRATIONS_FILE = os.path.join(DATA_DIR, "integrations.json") +CONTACTS_FILE = os.path.join(DATA_DIR, "contacts.json") +APP_KEY_FILE = os.path.join(DATA_DIR, ".app_key") +EMBEDDING_ENDPOINT_FILE = os.path.join(DATA_DIR, "embedding_endpoint.json") +COOKBOOK_STATE_FILE = os.path.join(DATA_DIR, "cookbook_state.json") +BG_JOBS_FILE = os.path.join(DATA_DIR, "bg_jobs.json") +VAULT_FILE = os.path.join(DATA_DIR, "vault.json") +TIDY_CALENDAR_STATE_FILE = os.path.join(DATA_DIR, "tidy_calendar_state.json") +SKILLS_FILE = os.path.join(DATA_DIR, "skills.json") +APP_DB = os.path.join(DATA_DIR, "app.db") +SCHEDULED_EMAILS_DB = os.path.join(DATA_DIR, "scheduled_emails.db") +EMAIL_CACHE_DB = os.path.join(DATA_DIR, "email_cache.db") + +# Data subdirectories +PERSONAL_UPLOADS_DIR = os.path.join(DATA_DIR, "personal_uploads") +EMOJI_CACHE_DIR = os.path.join(DATA_DIR, "emoji_cache") +RAG_DIR = os.path.join(DATA_DIR, "rag") +CHROMA_DIR = os.path.join(DATA_DIR, "chroma") +BG_JOBS_DIR = os.path.join(DATA_DIR, "bg_jobs") +DEEP_RESEARCH_DIR = os.path.join(DATA_DIR, "deep_research") +MCP_OAUTH_DIR = os.path.join(DATA_DIR, "mcp_oauth") +GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images") +TTS_CACHE_DIR = os.path.join(DATA_DIR, "tts_cache") +EMAIL_URGENCY_CACHE_DIR = os.path.join(DATA_DIR, "email_urgency_cache") +SKILLS_DIR = os.path.join(DATA_DIR, "skills") +GALLERY_DIR = os.path.join(DATA_DIR, "gallery") +GALLERY_UPLOADS_DIR = os.path.join(DATA_DIR, "gallery_uploads") +MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors") + +# Paths with an intentional dedicated env override, defaulting under DATA_DIR. +MAIL_ATTACHMENTS_DIR = os.getenv("ODYSSEUS_MAIL_ATTACHMENTS_DIR", os.path.join(DATA_DIR, "mail-attachments")) +FASTEMBED_CACHE_DIR = os.getenv("FASTEMBED_CACHE_PATH", os.path.join(DATA_DIR, "fastembed_cache")) # Agent tool output limits (single source of truth — imported by tool_execution.py, # tool_implementations.py, agent_tools.py, and any other module that needs them) @@ -44,3 +82,22 @@ CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24")) # Default parameters DEFAULT_TEMPERATURE = 1.0 DEFAULT_MAX_TOKENS = 0 + + +def internal_api_base() -> str: + """Base URL for in-process loopback calls to Odysseus's own API. + + Agent tools and background jobs reach admin-gated routes by calling the + running server over HTTP. Resolution order: + 1. ODYSSEUS_INTERNAL_BASE - explicit override (e.g. behind a TLS proxy). + 2. APP_PORT - http://127.0.0.1:$APP_PORT (docker-compose). + 3. Fallback http://127.0.0.1:7000 - legacy default. + + 127.0.0.1 (not "localhost") avoids IPv6/DNS ambiguity for a strictly-local + call. Without this, loopback tools fail with "All connection attempts + failed" whenever the server is not on port 7000. + """ + override = os.environ.get("ODYSSEUS_INTERNAL_BASE") + if override: + return override.rstrip("/") + return f"http://127.0.0.1:{os.environ.get('APP_PORT', '7000')}" diff --git a/src/context_compactor.py b/src/context_compactor.py index 7da52425a..150d7bb3c 100644 --- a/src/context_compactor.py +++ b/src/context_compactor.py @@ -307,6 +307,7 @@ async def maybe_compact( model: str, messages: List[Dict], headers: Optional[Dict] = None, + owner: Optional[str] = None, ) -> tuple: """Check context usage and compact if above threshold. @@ -353,7 +354,7 @@ async def maybe_compact( ) # Use utility model if configured, otherwise fall back to session model - util_url, util_model, util_headers = resolve_endpoint("utility") + util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner) compact_url = util_url or endpoint_url compact_model = util_model or model compact_headers = util_headers if util_url else headers @@ -380,7 +381,10 @@ async def maybe_compact( ) except Exception as e: logger.error(f"Compaction summary failed: {e}") - return system_msgs + recent, context_length, False + # Degrade gracefully: keep the conversation intact rather than + # silently dropping the older half. was_compacted=False signals the + # caller nothing was summarized; trim_for_context handles length. + return messages, context_length, False summary_msg = { "role": "system", @@ -434,8 +438,8 @@ def _update_session_history(session, split_point: int, summary: str, ) new_history = system_prefix + [summary_msg] + recent_history try: - from core import models as _core_models - manager = getattr(_core_models, "_session_manager", None) + from core.models import get_session_manager_instance + manager = get_session_manager_instance() except Exception: manager = None if manager and getattr(session, "id", None): diff --git a/src/cookbook_serve_lifecycle.py b/src/cookbook_serve_lifecycle.py index 58d424272..e30ddfd09 100644 --- a/src/cookbook_serve_lifecycle.py +++ b/src/cookbook_serve_lifecycle.py @@ -19,6 +19,8 @@ import time from pathlib import Path import httpx +from core.constants import internal_api_base +from src.constants import COOKBOOK_STATE_FILE logger = logging.getLogger(__name__) @@ -58,7 +60,7 @@ async def _delete_endpoint_for_task(task: dict) -> None: try: async with httpx.AsyncClient(timeout=8) as client: r = await client.get( - "http://localhost:7000/api/model-endpoints", + f"{internal_api_base()}/api/model-endpoints", headers=_internal_headers(), ) if r.status_code >= 400: @@ -73,7 +75,7 @@ async def _delete_endpoint_for_task(task: dict) -> None: ep = next((e for e in eps if hostport in (e.get("base_url") or "")), None) if ep: await client.delete( - f"http://localhost:7000/api/model-endpoints/{ep['id']}", + f"{internal_api_base()}/api/model-endpoints/{ep['id']}", headers=_internal_headers(), ) logger.info( @@ -108,7 +110,7 @@ async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "" try: async with httpx.AsyncClient(timeout=15) as client: r = await client.post( - "http://localhost:7000/api/shell/exec", + f"{internal_api_base()}/api/shell/exec", json={"command": cmd}, headers=_internal_headers(), ) @@ -129,7 +131,7 @@ async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "" async def _tick() -> None: - state_path = Path("/app/data/cookbook_state.json") + state_path = Path(COOKBOOK_STATE_FILE) if not state_path.exists(): return try: diff --git a/src/deep_research.py b/src/deep_research.py index 375d8d8ab..c8ed02b11 100644 --- a/src/deep_research.py +++ b/src/deep_research.py @@ -232,6 +232,7 @@ class DeepResearcher: self._start_time: float = 0 self.queries_used: Set[str] = set() self.urls_fetched: Set[str] = set() + self.analyzed_urls: List[Dict[str, str]] = [] self.round_count: int = 0 # Track which search providers actually returned results during the # run, in arrival order — surfaced in the visual report so users can @@ -439,7 +440,8 @@ class DeepResearcher: ) cat = (result or "").strip().lower() # Clean one-word answer first. - first = cat.split()[0].strip(".,\"'*:") if cat.split() else "" + parts = cat.split() + first = parts[0].strip(".,\"'*:") if parts else "" if first in CATEGORY_PROMPTS: return first # Weak local models often wrap the label in preamble ("the category @@ -524,6 +526,10 @@ class DeepResearcher: if url and url not in self.urls_fetched: urls_to_fetch.append(r) self.urls_fetched.add(url) + self.analyzed_urls.append({ + "url": url, + "title": r.get("title", "") or url, + }) if len(urls_to_fetch) >= self.max_urls_per_round * len(queries): break diff --git a/src/document_processor.py b/src/document_processor.py index 1d9a1ca9a..2448f1992 100644 --- a/src/document_processor.py +++ b/src/document_processor.py @@ -109,7 +109,7 @@ def _process_text_file(path: str) -> str: return result -def _process_pdf(path: str) -> str: +def _process_pdf(path: str, owner: str | None = None) -> str: """Process PDF file with text extraction (pypdf). Uses VL model for image-heavy pages.""" try: from pypdf import PdfReader @@ -133,7 +133,7 @@ def _process_pdf(path: str) -> str: temp_img_path = tmp.name try: img.image.save(temp_img_path, "PNG") # pypdf -> PIL image - ocr_text = analyze_image_with_vl(temp_img_path) + ocr_text = analyze_image_with_vl(temp_img_path, owner=owner) if ocr_text and "unavailable" not in ocr_text.lower(): pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}" finally: @@ -254,7 +254,7 @@ def _load_vl_settings() -> dict: return {} -def _resolve_vl_model(configured: str) -> tuple: +def _resolve_vl_model(configured: str, owner: str | None = None) -> tuple: """Resolve the vision model to (url, model_id, headers). Uses admin-configured model if set, otherwise tries auto-detection @@ -263,7 +263,7 @@ def _resolve_vl_model(configured: str) -> tuple: from src.ai_interaction import _resolve_model if configured: - return _resolve_model(configured) + return _resolve_model(configured, owner=owner) # Auto-detect: try known vision-capable models in priority order candidates = [ @@ -274,14 +274,14 @@ def _resolve_vl_model(configured: str) -> tuple: ] for candidate in candidates: try: - return _resolve_model(candidate) + return _resolve_model(candidate, owner=owner) except (ValueError, Exception): continue raise ValueError("No vision model available") -def analyze_image_with_vl_result(image_path: str) -> dict: +def analyze_image_with_vl_result(image_path: str, owner: str | None = None) -> dict: """Analyze an image and return both text and the model that produced it.""" logger.info(f"Analyzing image with VL model: {image_path}") try: @@ -291,7 +291,7 @@ def analyze_image_with_vl_result(image_path: str) -> dict: vl_model = settings.get("vision_model", "") try: - url, model_id, headers = _resolve_vl_model(vl_model) + url, model_id, headers = _resolve_vl_model(vl_model, owner=owner) except ValueError: return {"text": "[No vision model configured — set one in Settings → Vision]", "model": vl_model or ""} @@ -316,7 +316,7 @@ def analyze_image_with_vl_result(image_path: str) -> dict: # — same shape as task/chat but its own list (`vision_model_fallbacks`). try: from src.endpoint_resolver import resolve_vision_fallback_candidates - _vl_candidates = [(url, model_id, headers)] + resolve_vision_fallback_candidates() + _vl_candidates = [(url, model_id, headers)] + resolve_vision_fallback_candidates(owner=owner) except Exception: _vl_candidates = [(url, model_id, headers)] @@ -338,9 +338,9 @@ def analyze_image_with_vl_result(image_path: str) -> dict: return {"text": "[VL model unavailable - image not analyzed]", "model": ""} -def analyze_image_with_vl(image_path: str) -> str: +def analyze_image_with_vl(image_path: str, owner: str | None = None) -> str: """Analyze an image using the admin-configured Vision-Language model.""" - return analyze_image_with_vl_result(image_path).get("text", "") + return analyze_image_with_vl_result(image_path, owner=owner).get("text", "") def build_user_content( @@ -430,11 +430,11 @@ def build_user_content( create_form_markdown_document, create_plain_pdf_document, ) - title = os.path.splitext(os.path.basename(path))[0] + title = os.path.splitext(os.path.basename(display_name))[0] # Pull the PDF prose once — used as either intro_text # (form path) or the doc body (plain path). try: - pdf_body_text = strip_pdf_content_marker(_process_pdf(path)) + pdf_body_text = strip_pdf_content_marker(_process_pdf(path, owner=owner)) except Exception: pdf_body_text = None @@ -517,7 +517,7 @@ def build_user_content( except Exception as e: logger.warning(f"PDF auto-doc creation failed for {path}: {e}") if extracted_text is None: - extracted_text = _process_pdf(path) + extracted_text = _process_pdf(path, owner=owner) elif mime.startswith("text/") or _is_text_file(path): extracted_text = _process_text_file(path) else: diff --git a/src/embedding_lanes.py b/src/embedding_lanes.py index bca4eaef2..f23be32b8 100644 --- a/src/embedding_lanes.py +++ b/src/embedding_lanes.py @@ -196,13 +196,22 @@ def _get_or_reset_collection(chroma_client, name: str, metadata: Dict[str, Any], try: chroma_client.delete_collection(name) restored = chroma_client.get_or_create_collection(name=name, metadata=current) - old_embeddings = preserved.get("embeddings") or [] - if ids and docs and old_embeddings: + # chromadb returns embeddings as a numpy ndarray, whose truth value + # is ambiguous — `preserved.get("embeddings") or []` and a bare + # `if ... and old_embeddings:` both raise ValueError, which aborts + # the restore and loses the rows the reset was supposed to keep. + # Use explicit None/len checks instead. + old_embeddings = preserved.get("embeddings") + if old_embeddings is None: + old_embeddings = [] + if ids and docs and len(old_embeddings): for start in range(0, len(ids), 100): batch_ids = ids[start:start + 100] batch_docs = docs[start:start + 100] batch_metas = metas[start:start + 100] batch_embeddings = old_embeddings[start:start + 100] + if hasattr(batch_embeddings, "tolist"): + batch_embeddings = batch_embeddings.tolist() if len(batch_metas) < len(batch_ids): batch_metas += [{}] * (len(batch_ids) - len(batch_metas)) restored.add( diff --git a/src/embeddings.py b/src/embeddings.py index f2d0c5934..85a55c386 100644 --- a/src/embeddings.py +++ b/src/embeddings.py @@ -14,6 +14,8 @@ Set EMBEDDING_URL in .env, e.g.: import os +from src.constants import FASTEMBED_CACHE_DIR, EMBEDDING_ENDPOINT_FILE + # Windows: force HuggingFace/fastembed to COPY model files rather than symlink # them. On a network-share/UNC cache dir Windows can't follow HF's symlinks # ([WinError 1463] "symbolic link cannot be followed"), so ONNX fails to load the @@ -117,10 +119,7 @@ class FastEmbedClient: # Persistent cache under data/ so the model survives reboots and so # the download lands exactly where the admin panel's _is_downloaded() # check looks (both default to this same path). - cache_dir = os.getenv("FASTEMBED_CACHE_PATH") or os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "data", "fastembed_cache", - ) + cache_dir = FASTEMBED_CACHE_DIR os.makedirs(cache_dir, exist_ok=True) # Windows self-heal: the HuggingFace-hub cache stores model files as # symlinks (snapshots//model.onnx -> ../../blobs/). On a @@ -188,10 +187,7 @@ class FastEmbedClient: def _load_persisted_endpoint() -> dict: """Load the custom embedding endpoint saved from the admin panel.""" try: - endpoint_file = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "data", "embedding_endpoint.json", - ) + endpoint_file = EMBEDDING_ENDPOINT_FILE if os.path.exists(endpoint_file): import json data = json.loads(open(endpoint_file, encoding="utf-8").read()) diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py index a9ab5c780..0a3063638 100644 --- a/src/endpoint_resolver.py +++ b/src/endpoint_resolver.py @@ -12,7 +12,7 @@ from typing import Optional, Tuple, Dict from urllib.parse import urlparse, urlunparse from core.database import SessionLocal, ModelEndpoint -from src.llm_core import _detect_provider, _host_match +from src.llm_core import _detect_provider, _host_match, _ollama_api_root logger = logging.getLogger(__name__) @@ -70,6 +70,25 @@ def _endpoint_enabled_models(ep) -> list: return [m for m in _endpoint_cached_models(ep) if m not in hidden] +def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]: + """Resolve a ModelEndpoint row to its runtime base URL and bearer/API key. + + Static-key providers use ``ModelEndpoint.api_key``. Session-backed providers + store refreshable credentials in ProviderAuthSession and must resolve a + current access token at call time. + """ + base = normalize_base(getattr(ep, "base_url", "") or "") + api_key = getattr(ep, "api_key", None) + auth_id = getattr(ep, "provider_auth_id", None) + if auth_id: + from src.chatgpt_subscription import resolve_runtime_credentials + + creds = resolve_runtime_credentials(auth_id, owner=owner) + base = normalize_base(creds.get("base_url") or base) + api_key = creds.get("api_key") + return base, api_key + + # Cache for Tailscale hostname → IP resolution _tailscale_cache: Dict[str, Optional[str]] = {} @@ -133,7 +152,7 @@ def resolve_url(url: str) -> str: def normalize_base(url: str) -> str: """Strip known API path suffixes from a base URL.""" url = (url or "").strip().rstrip("/") - for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]: + for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages", "/responses"]: if url.endswith(suffix): url = url[: -len(suffix)].rstrip("/") for suffix in ["/chat", "/tags", "/generate"]: @@ -150,19 +169,6 @@ def _anthropic_api_root(base: str) -> str: return base -def _ollama_api_root(base: str) -> str: - """Return the native Ollama API root, adding /api for ollama.com hosts.""" - base = (base or "").strip().rstrip("/") - parsed = urlparse(base) - path = (parsed.path or "").rstrip("/") - if path.endswith("/api"): - return base - if _host_match(base, "ollama.com"): - root = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "https://ollama.com" - return root.rstrip("/") + "/api" - return base - - def build_chat_url(base: str) -> str: """Return the correct chat endpoint URL for a given base.""" base = resolve_url(base) @@ -171,17 +177,21 @@ def build_chat_url(base: str) -> str: return _anthropic_api_root(base) + "/v1/messages" if provider == "ollama": return _ollama_api_root(base) + "/chat" + if provider == "chatgpt-subscription": + return base.rstrip("/") + "/responses" return base + "/chat/completions" -def build_models_url(base: str) -> str: +def build_models_url(base: str) -> Optional[str]: """Return the provider-specific model-list endpoint URL for a base.""" - base = resolve_url(base) + base = normalize_base(resolve_url(base)) provider = _detect_provider(base) if provider == "anthropic": return _anthropic_api_root(base) + "/v1/models" if provider == "ollama": return _ollama_api_root(base) + "/tags" + if provider == "chatgpt-subscription": + return None return base + "/models" @@ -197,6 +207,9 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]: if provider == "copilot": from src.copilot import copilot_headers return copilot_headers(api_key) + if provider == "chatgpt-subscription": + from src.chatgpt_subscription import chatgpt_headers + return chatgpt_headers(api_key) if api_key: headers["Authorization"] = f"Bearer {api_key}" if provider == "openrouter": @@ -275,9 +288,13 @@ def resolve_endpoint( if not ep: return fallback_url, fallback_model, fallback_headers - base = normalize_base(ep.base_url) + try: + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + except Exception as e: + logger.warning("Could not resolve endpoint runtime credentials: %s", e) + return fallback_url, fallback_model, fallback_headers chat_url = build_chat_url(base) - headers = build_headers(ep.api_key, base) + headers = build_headers(api_key, base) # Discard a configured model the user has since disabled on the # endpoint (e.g. a stale `default_model` left pointing at a now-hidden @@ -321,9 +338,13 @@ def resolve_endpoint_by_id( ep = q.first() if not ep: return None - base = normalize_base(ep.base_url) + try: + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + except Exception as e: + logger.warning("Could not resolve endpoint runtime credentials: %s", e) + return None chat_url = build_chat_url(base) - headers = build_headers(ep.api_key, base) + headers = build_headers(api_key, base) m = (model or "").strip() # Drop a model the user disabled on the endpoint, then pick the first # enabled chat model rather than a hidden one. diff --git a/src/event_bus.py b/src/event_bus.py index 8bdb889a0..9b22d7821 100644 --- a/src/event_bus.py +++ b/src/event_bus.py @@ -12,6 +12,8 @@ import os from datetime import datetime from typing import Optional +from src.constants import AUTH_FILE + logger = logging.getLogger(__name__) _task_scheduler = None @@ -54,9 +56,7 @@ def _resolve_event_owner(owner: Optional[str]) -> Optional[str]: return owner try: - from src.constants import DATA_DIR - - auth_path = os.path.join(DATA_DIR, "auth.json") + auth_path = AUTH_FILE with open(auth_path, "r", encoding="utf-8") as f: users = (json.load(f).get("users") or {}) for username, data in users.items(): diff --git a/src/generated_images.py b/src/generated_images.py index 2e7994175..d40022d60 100644 --- a/src/generated_images.py +++ b/src/generated_images.py @@ -4,8 +4,10 @@ from pathlib import Path from fastapi import HTTPException +from src.constants import GENERATED_IMAGES_DIR -GENERATED_IMAGE_DIR = Path("data/generated_images") + +GENERATED_IMAGE_DIR = Path(GENERATED_IMAGES_DIR) GENERATED_IMAGE_RE = re.compile( r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$" ) diff --git a/src/integrations.py b/src/integrations.py index 8ff0aa065..11fee99e7 100644 --- a/src/integrations.py +++ b/src/integrations.py @@ -10,10 +10,11 @@ import httpx from core.atomic_io import atomic_write_json from core.platform_compat import safe_chmod from src.secret_storage import decrypt, encrypt, is_encrypted +from src.constants import DATA_DIR, INTEGRATIONS_FILE, SETTINGS_FILE log = logging.getLogger(__name__) -DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "integrations.json") +DATA_FILE = INTEGRATIONS_FILE # --------------------------------------------------------------------------- # Presets @@ -410,17 +411,80 @@ async def execute_api_call( if "application/json" in content_type: try: data = response.json() - formatted = json.dumps(data, indent=2, ensure_ascii=False) + full = json.dumps(data, indent=2, ensure_ascii=False) + if len(full) > 12000: + if isinstance(data, list): + # Binary-search for the largest prefix such that the + # final array (prefix + sentinel) fits within the limit. + # Pre-compute the sentinel so we know its serialized size. + sentinel_placeholder = { + "_truncated": True, + "total_items": len(data), + "shown_items": 0, + } + # Overhead: the sentinel appears as an extra array element. + # Add a conservative padding for the separating comma, + # newline, and indentation characters (~6 chars). + sentinel_overhead = len( + json.dumps(sentinel_placeholder, indent=2, ensure_ascii=False) + ) + 6 + budget = 12000 - sentinel_overhead + lo, hi = 0, len(data) + while lo < hi: + mid = (lo + hi + 1) // 2 + candidate = json.dumps( + data[:mid], indent=2, ensure_ascii=False + ) + if len(candidate) < budget: + lo = mid + else: + hi = mid - 1 + sentinel = { + "_truncated": True, + "total_items": len(data), + "shown_items": lo, + } + formatted = json.dumps( + data[:lo] + [sentinel], indent=2, ensure_ascii=False + ) + elif isinstance(data, dict): + # Truncate dict entries until the result fits, then add + # the _truncated marker. Walk keys in insertion order. + DICT_LIMIT = 12000 + kept: dict = {} + for k, v in data.items(): + candidate = json.dumps( + {**kept, k: v, "_truncated": True}, + indent=2, + ensure_ascii=False, + ) + if len(candidate) <= DICT_LIMIT: + kept[k] = v + else: + break + formatted = json.dumps( + {**kept, "_truncated": True}, indent=2, ensure_ascii=False + ) + else: + total = len(full) + formatted = full[:12000] + f"\n... (truncated, {total} chars total)" + else: + formatted = full except (json.JSONDecodeError, ValueError): formatted = response.text + if len(formatted) > 12000: + total = len(formatted) + formatted = formatted[:12000] + f"\n... (truncated, {total} chars total)" elif "text/html" in content_type: formatted = _strip_html_tags(response.text) + if len(formatted) > 12000: + total = len(formatted) + formatted = formatted[:12000] + f"\n... (truncated, {total} chars total)" else: formatted = response.text - - # Truncate - if len(formatted) > 12000: - formatted = formatted[:12000] + "\n... (truncated)" + if len(formatted) > 12000: + total = len(formatted) + formatted = formatted[:12000] + f"\n... (truncated, {total} chars total)" output = f"HTTP {status}\n{formatted}" @@ -471,7 +535,7 @@ def get_integrations_prompt() -> str: def migrate_from_settings() -> None: """If data/settings.json has miniflux_url and miniflux_api_key, create a Miniflux integration and clear those keys from settings.""" - settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "settings.json") + settings_path = SETTINGS_FILE if not os.path.exists(settings_path): return diff --git a/src/llm_core.py b/src/llm_core.py index 9123a1b4a..26b5f96e7 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -270,8 +270,28 @@ def _is_ollama_native_url(url: str) -> bool: path = (parsed.path or "").rstrip("/") if _host_match(url, "ollama.com"): return True + if path.startswith("/v1"): + return False local_ollama_host = host in {"localhost", "127.0.0.1", "0.0.0.0", "::1"} or parsed.port == 11434 - return local_ollama_host and (path == "/api" or path.startswith("/api/")) + return local_ollama_host and (path == "" or path == "/api" or path.startswith("/api/")) + + +def _is_ollama_openai_compat_url(url: str) -> bool: + """Return True for local Ollama's OpenAI-compatible /v1 surface. + + Mirrors the host detection used by ``_is_ollama_native_url`` so that the + two helpers stay in lockstep: a localhost Ollama on a non-default port + (custom ``OLLAMA_HOST``, reverse proxy, container port remap) is treated + the same way here as it is on the native ``/api`` path. + """ + try: + parsed = urlparse(url or "") + except Exception: + return False + host = parsed.hostname or "" + path = (parsed.path or "").rstrip("/") + local_ollama_host = host in {"localhost", "127.0.0.1", "0.0.0.0", "::1"} or parsed.port == 11434 + return local_ollama_host and (path == "/v1" or path.startswith("/v1/")) def _ollama_api_root(url: str) -> str: @@ -287,6 +307,8 @@ def _ollama_api_root(url: str) -> str: return url[: -len("/generate")] if path.endswith("/api"): return url + if path == "": + return url + "/api" if _host_match(url, "ollama.com"): root = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "https://ollama.com" return root.rstrip("/") + "/api" @@ -414,16 +436,62 @@ def _detect_provider(url: str) -> str: return "ollama" if _host_match(url, "anthropic.com"): return "anthropic" + if _host_match(url, "opencode.ai/zen/go"): + return "opencode-go" + if _host_match(url, "opencode.ai/zen"): + return "opencode-zen" if _host_match(url, "openrouter.ai"): return "openrouter" if _host_match(url, "groq.com"): return "groq" + if _host_match(url, "nvidia.com"): + return "nvidia" + from src.chatgpt_subscription import is_chatgpt_subscription_base + if is_chatgpt_subscription_base(url): + return "chatgpt-subscription" from src.copilot import is_copilot_base if is_copilot_base(url): return "copilot" return "openai" +def _is_self_hosted_openai_compatible(url: str) -> bool: + """True for custom/local OpenAI-compatible servers (llama.cpp, LM Studio, + vLLM, text-generation-webui, etc.) as opposed to api.openai.com itself. + + Used to gate llama.cpp-server-specific payload extras (``session_id``, + ``cache_prompt``) — sending unrecognized top-level fields to OpenAI's + actual API returns a 400 ("Unrecognized request argument"), but + self-hosted servers generally ignore unknown fields and many (notably + llama.cpp's server) use them for KV-cache slot affinity (issue #2927). + """ + return _detect_provider(url) == "openai" and not _host_match(url, "openai.com") + + +def _apply_local_cache_affinity(payload: Dict, url: str, session_id: Optional[str]) -> None: + """Add llama.cpp-server slot-affinity hints to an outgoing payload, in place. + + As diagnosed in issue #2927, llama.cpp assigns requests to processing + slots via LRU when no stable identifier is present ("session_id= + server-selected (LCP/LRU)"), which means consecutive turns of the same + chat can land on different slots and lose their cached prefix entirely. + Sending a stable ``session_id`` (derived from the Odysseus session) lets + the server keep routing the same conversation to the same slot, and + ``cache_prompt: true`` asks it to retain/reuse the prefix it already has. + + Both fields are llama.cpp / LM Studio extensions to the OpenAI schema; we + only set them for self-hosted OpenAI-compatible endpoints (never + api.openai.com or other cloud providers, which reject unrecognized + top-level request fields). + """ + if not session_id: + return + if not _is_self_hosted_openai_compatible(url): + return + payload.setdefault("session_id", str(session_id)) + payload.setdefault("cache_prompt", True) + + def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str, str]: h = {"Content-Type": "application/json"} if isinstance(headers, dict): @@ -451,11 +519,16 @@ def _provider_label(url: str) -> str: if _host_match(url, "x.ai"): return "xAI" if _host_match(url, "openai.com"): return "OpenAI" if _host_match(url, "openrouter.ai"): return "OpenRouter" + if _host_match(url, "opencode.ai/zen/go"): return "OpenCode Go" + if _host_match(url, "opencode.ai/zen"): return "OpenCode Zen" if _host_match(url, "groq.com"): return "Groq" + from src.chatgpt_subscription import is_chatgpt_subscription_base + if is_chatgpt_subscription_base(url): return "ChatGPT Subscription" from src.copilot import is_copilot_base if is_copilot_base(url): return "GitHub Copilot" if _host_match(url, "mistral.ai"): return "Mistral" if _host_match(url, "deepseek.com"): return "DeepSeek" + if _host_match(url, "nvidia.com"): return "NVIDIA" if _host_match(url, "googleapis.com"): return "Google" if _host_match(url, "together.xyz", "together.ai"): return "Together" if _host_match(url, "fireworks.ai"): return "Fireworks" @@ -469,6 +542,78 @@ def _provider_label(url: str) -> str: return host or "provider" +def _normalize_chatgpt_subscription_url(url: str) -> str: + base = (url or "").strip().rstrip("/") + if base.endswith("/responses"): + return base + return base + "/responses" + + +def _message_content_as_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if not isinstance(part, dict): + if part: + parts.append(str(part)) + continue + if isinstance(part.get("text"), str): + parts.append(part["text"]) + continue + if isinstance(part.get("content"), str): + parts.append(part["content"]) + return "\n".join(parts) + return "" if content is None else str(content) + + +def _chatgpt_subscription_instructions(messages: List[Dict]) -> str: + instructions = [ + _message_content_as_text(msg.get("content")).strip() + for msg in messages or [] + if (msg.get("role") or "") == "system" + ] + instructions = [part for part in instructions if part] + if instructions: + return "\n\n".join(instructions) + return "You are a helpful AI assistant." + + +def _build_chatgpt_responses_payload( + model: str, + messages: List[Dict], + temperature: float, + max_tokens: int, + *, + stream: bool = False, +) -> Dict: + from src.chatgpt_subscription import build_responses_input + + conversation = [msg for msg in (messages or []) if (msg.get("role") or "") != "system"] + payload: Dict = { + "model": model, + "instructions": _chatgpt_subscription_instructions(messages), + "input": build_responses_input(conversation), + "stream": stream, + "store": False, + } + if not _restricts_temperature(model): + payload["temperature"] = temperature + # ChatGPT Subscription Codex API does not support max_output_tokens — + # passing it returns HTTP 400 "Unsupported parameter: max_output_tokens". + # Do not include it in the payload. + return payload + + +def _format_chatgpt_subscription_error(status_code: int, text: str) -> str: + if status_code in (401, 403): + return "ChatGPT Subscription credentials expired or were rejected. Reconnect the provider." + if status_code == 429: + return "ChatGPT Subscription quota or rate limit was reached. Retry after the upstream limit resets." + return _format_upstream_error(status_code, text, "https://chatgpt.com/backend-api/codex") + + def _format_upstream_error(status: int, body: bytes | str, url: str) -> str: """Turn an upstream HTTP error into a user-readable sentence. @@ -724,7 +869,7 @@ def _sanitize_llm_messages(messages: List[Dict]) -> List[Dict]: (content=None, since Gemini/Ollama reject tool_calls alongside ""). Dropping it leaves the tool result dangling and breaks the next round. """ - allowed = {"role", "content", "name", "tool_call_id", "tool_calls", "function_call"} + allowed = {"role", "content", "name", "tool_call_id", "tool_calls", "function_call", "reasoning_content"} cleaned = [] for msg in messages or []: if not isinstance(msg, dict): @@ -864,7 +1009,7 @@ def _normalize_anthropic_url(url: str) -> str: def _model_list_base(url: str) -> str: """Normalize model/chat URLs to the configured endpoint base.""" base = (url or "").strip().rstrip("/") - for suffix in ("/models", "/chat/completions", "/completions", "/v1/messages"): + for suffix in ("/models", "/chat/completions", "/completions", "/v1/messages", "/responses"): if base.endswith(suffix): base = base[: -len(suffix)].rstrip("/") for suffix in ("/chat", "/tags", "/generate"): @@ -893,7 +1038,12 @@ def _parse_model_cache(raw) -> List[str]: return out -def _configured_cached_model_ids(endpoint_url: str) -> List[str]: +def _configured_cached_model_ids( + endpoint_url: str, + *, + owner: Optional[str] = None, + endpoint_id: Optional[str] = None, +) -> List[str]: """Return cached models for a configured endpoint matching endpoint_url.""" target = _model_list_base(endpoint_url) if not target: @@ -904,7 +1054,13 @@ def _configured_cached_model_ids(endpoint_url: str) -> List[str]: return [] db = SessionLocal() try: - rows = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() + q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + if endpoint_id: + q = q.filter(ModelEndpoint.id == endpoint_id) + if owner: + from src.auth_helpers import owner_filter + q = owner_filter(q, ModelEndpoint, owner) + rows = q.all() for ep in rows: if _model_list_base(getattr(ep, "base_url", "")) != target: continue @@ -923,9 +1079,16 @@ def _configured_cached_model_ids(endpoint_url: str) -> List[str]: return [] -def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT, headers: Optional[Dict] = None) -> List[str]: +def list_model_ids( + base_chat_url: str, + timeout: int = LLMConfig.DEFAULT_TIMEOUT, + headers: Optional[Dict] = None, + *, + owner: Optional[str] = None, + endpoint_id: Optional[str] = None, +) -> List[str]: """List available model IDs from an endpoint.""" - cached = _configured_cached_model_ids(base_chat_url) + cached = _configured_cached_model_ids(base_chat_url, owner=owner, endpoint_id=endpoint_id) if cached: return cached provider = _detect_provider(base_chat_url) @@ -938,7 +1101,9 @@ def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT, if provider == "ollama": models_url = _ollama_api_root(base_chat_url) + "/tags" else: - models_url = base_chat_url.replace("/chat/completions", "/models") + from src.endpoint_resolver import build_models_url + + models_url = build_models_url(base_chat_url) r = httpx.get(models_url, headers=h, timeout=timeout) r.raise_for_status() data = r.json() @@ -961,9 +1126,16 @@ def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT, pass return [] -def normalize_model_id(endpoint_url: str, requested: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT) -> Optional[str]: +def normalize_model_id( + endpoint_url: str, + requested: str, + timeout: int = LLMConfig.DEFAULT_TIMEOUT, + *, + owner: Optional[str] = None, + endpoint_id: Optional[str] = None, +) -> Optional[str]: """Normalize a model ID to match available models.""" - avail = list_model_ids(endpoint_url, timeout) + avail = list_model_ids(endpoint_url, timeout, owner=owner, endpoint_id=endpoint_id) if not avail: return None if requested in avail: @@ -1134,7 +1306,8 @@ async def llm_call_async( headers: Optional[Dict] = None, timeout: int = LLMConfig.STREAM_TIMEOUT, max_retries: int = LLMConfig.MAX_RETRIES, - prompt_type: Optional[str] = None + prompt_type: Optional[str] = None, + session_id: Optional[str] = None, ) -> str: """Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging.""" provider = _detect_provider(url) @@ -1159,6 +1332,49 @@ async def llm_call_async( logger.debug(f"Returning cached response for key: {cache_key}") return cached_response + if provider == "chatgpt-subscription": + # ChatGPT/Codex requires streamed Responses requests even for callers + # that want a plain string (auto-title, memory extraction, etc.). + # Reuse stream_llm's validated Codex SSE path and collect deltas. + parts: List[str] = [] + async for chunk in stream_llm( + url, + model, + messages_copy, + temperature=temperature, + max_tokens=max_tokens, + headers=headers, + timeout=timeout, + ): + event_is_error = False + for line in str(chunk).splitlines(): + if line.startswith("event:"): + event_is_error = line[6:].strip() == "error" + continue + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw: + continue + if raw == "[DONE]": + response = "".join(parts) + _set_cached_response(cache_key, response) + return response + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + if event_is_error or data.get("error") or (data.get("status") and data.get("text")): + status = int(data.get("status") or 502) + text = data.get("text") or data.get("error") or "ChatGPT Subscription request failed" + raise HTTPException(status, text) + delta = data.get("delta") + if isinstance(delta, str): + parts.append(delta) + response = "".join(parts) + _set_cached_response(cache_key, response) + return response + if provider == "anthropic": target_url = _normalize_anthropic_url(url) h = _build_anthropic_headers(headers) @@ -1188,6 +1404,10 @@ async def llm_call_async( if max_tokens and max_tokens > 0: tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" payload[tok_key] = max_tokens + # Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm. + if _is_ollama_openai_compat_url(url) and _supports_thinking(model): + payload["think"] = False + _apply_local_cache_affinity(payload, url, session_id) if _is_host_dead(target_url): raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)") @@ -1245,7 +1465,7 @@ async def llm_call_async( async def stream_llm(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE, max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None, timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None, - tools: Optional[List[Dict]] = None): + tools: Optional[List[Dict]] = None, session_id: Optional[str] = None): """Stream LLM responses with improved error handling. Yields SSE chunks: @@ -1284,6 +1504,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl model, messages_copy, temperature, max_tokens, stream=True, tools=tools, num_ctx=get_context_length(url, model), ) + elif provider == "chatgpt-subscription": + target_url = _normalize_chatgpt_subscription_url(url) + h = _provider_headers(provider, headers) + payload = _build_chatgpt_responses_payload(model, messages_copy, temperature, max_tokens, stream=True) else: target_url = url payload = { @@ -1301,6 +1525,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl payload[tok_key] = max_tokens if tools: payload["tools"] = tools + # For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3, + # gemma4, etc.), suppress thinking so tool calls aren't swallowed inside + # blocks. Ollama /v1 accepts "think": false as a top-level param. + if _is_ollama_openai_compat_url(url) and _supports_thinking(model): + payload["think"] = False + _apply_local_cache_affinity(payload, url, session_id) h = _provider_headers(provider, headers) if provider == "copilot": from src.copilot import apply_request_headers @@ -1315,6 +1545,68 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl return note_model_activity(target_url, model) + # ── ChatGPT Subscription / Codex Responses streaming ── + if provider == "chatgpt-subscription": + event_name = "" + input_tokens = 0 + output_tokens = 0 + try: + client = _get_http_client() + async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r: + _clear_host_dead(target_url) + if r.status_code != 200: + raw = (await r.aread()).decode(errors="replace") + friendly = _format_chatgpt_subscription_error(r.status_code, raw) + yield f'event: error\ndata: {json.dumps({"status": r.status_code, "text": friendly, "raw": raw[:500]})}\n\n' + return + async for line in r.aiter_lines(): + if not line: + continue + if line.startswith("event:"): + event_name = line[6:].strip() + continue + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw: + continue + try: + data = json.loads(raw) + except json.JSONDecodeError: + continue + evt = data.get("type") or event_name + if evt == "response.output_text.delta": + delta = data.get("delta") or "" + if delta: + yield f'data: {json.dumps({"delta": delta})}\n\n' + elif evt == "response.completed": + usage = (data.get("response") or {}).get("usage") or data.get("usage") or {} + input_tokens = usage.get("input_tokens") or usage.get("prompt_tokens") or input_tokens + output_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or output_tokens + if input_tokens or output_tokens: + yield f'data: {json.dumps({"type": "usage", "data": {"input_tokens": input_tokens, "output_tokens": output_tokens}})}\n\n' + yield "data: [DONE]\n\n" + return + elif evt in ("response.failed", "error"): + err = data.get("error") or (data.get("response") or {}).get("error") or {} + text = err.get("message") if isinstance(err, dict) else str(err or "ChatGPT Subscription request failed") + yield f'event: error\ndata: {json.dumps({"status": 502, "text": text})}\n\n' + return + yield "data: [DONE]\n\n" + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + _cooled = _mark_host_dead(target_url) + _tail = f" — host cooled for {DEAD_HOST_COOLDOWN:.0f}s" if _cooled else " — transient, will retry" + logger.warning(f"ChatGPT Subscription stream connect to {target_url} failed: {e}{_tail}") + yield f'event: error\ndata: {json.dumps({"error": f"Cannot reach {_host_key(target_url)}", "status": 503})}\n\n' + except httpx.ReadTimeout: + yield f'event: error\ndata: {json.dumps({"error": "Read timeout", "status": 504})}\n\n' + except httpx.NetworkError: + yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502})}\n\n' + except Exception as e: + logger.error(f"ChatGPT Subscription stream error: {e}") + yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502})}\n\n' + return + # ── Native Ollama streaming ── if provider == "ollama": _ollama_tool_calls: List[Dict] = [] diff --git a/src/model_context.py b/src/model_context.py index c71d76fcf..a2ce9f638 100644 --- a/src/model_context.py +++ b/src/model_context.py @@ -297,7 +297,9 @@ def _query_context_length(endpoint_url: str, model: str) -> int: logger.info(f"Using known context window for {model}: {known}") return known or DEFAULT_CONTEXT - models_url = endpoint_url.replace("/chat/completions", "/models") + from src.endpoint_resolver import build_models_url + + models_url = build_models_url(endpoint_url) try: r = httpx.get(models_url, timeout=REQUEST_TIMEOUT) if r.is_success: diff --git a/src/model_discovery.py b/src/model_discovery.py index ca62a9f96..506fcb6c4 100644 --- a/src/model_discovery.py +++ b/src/model_discovery.py @@ -44,8 +44,7 @@ def discover_tailscale_hosts() -> List[str]: hosts = [] try: result = subprocess.run( - ["tailscale", "status", "--json"], - capture_output=True, text=True, timeout=5 + ["tailscale", "status", "--json"], capture_output=True, text=True, timeout=5 ) if result.returncode != 0: return hosts @@ -154,9 +153,13 @@ class ModelDiscovery: r = httpx.get(f"http://{host}:{port}/api/v1/models", timeout=1.5) if r.is_success: models = (r.json() or {}).get("models") - if (isinstance(models, list) and models - and isinstance(models[0], dict) - and "key" in models[0] and "architecture" in models[0]): + if ( + isinstance(models, list) + and models + and isinstance(models[0], dict) + and "key" in models[0] + and "architecture" in models[0] + ): return "lmstudio" except Exception: pass @@ -192,12 +195,15 @@ class ModelDiscovery: logger.info(f"Scanning {len(hosts)} hosts for models: {hosts}") # Well-known ports: 8000-8020 (vLLM, llama.cpp, SGLang, Cookbook), - # 1234 (LM Studio), 11434 (Ollama) - ports = list(range(8000, 8021)) + [1234, 11434] + # 1234 (LM Studio), 11434 (Ollama), 11435 for APFEL as its default port is + # occupied by Ollama. The env vars can add more ports which will be merged in. + ports = list(range(8000, 8021)) + [1234, 11434, 11435] ports += [p for p in sorted(self._extra_ports) if p not in ports] targets = [(h, p) for h in hosts for p in ports] - seen_models = set() # dedupe by (port, model_ids) to avoid same machine via different IPs + seen_models = ( + set() + ) # dedupe by (port, model_ids) to avoid same machine via different IPs with ThreadPoolExecutor(max_workers=50) as pool: futures = {pool.submit(self._check_port, h, p): (h, p) for h, p in targets} @@ -212,9 +218,30 @@ class ModelDiscovery: # Sort by host then port for consistent ordering items.sort(key=lambda x: (x["host"], x["port"])) - logger.info(f"Discovered {len(items)} model endpoints across {len(hosts)} hosts") + logger.info( + f"Discovered {len(items)} model endpoints across {len(hosts)} hosts" + ) return {"hosts": hosts, "items": items} + def warmup_ping_urls(self, limit: int = 5) -> List[str]: + """The ``/models`` URLs of up to ``limit`` discovered endpoints. + + Used by the startup warmup / keepalive loop to prime connections. Each + discovered item already carries a ``/v1/chat/completions`` url; swap the + suffix for the cheap ``/models`` probe. Failures degrade to an empty list + so warmup never crashes the caller. + """ + try: + items = (self.discover_models() or {}).get("items", []) + except Exception: + return [] + urls: List[str] = [] + for ep in items[:limit]: + url = (ep.get("url") or "").replace("/chat/completions", "/models") + if url: + urls.append(url) + return urls + def get_providers(self) -> Dict[str, Any]: """Get all available providers""" discovery = self.discover_models() @@ -223,15 +250,23 @@ class ModelDiscovery: if self.openai_api_key: openai_models = [ - "gpt-5.2-codex", "gpt-4o-mini", "gpt-image-1.5", - "gpt-4o", "gpt-5.2", "gpt-5.2-pro", + "gpt-5.2-codex", + "gpt-4o-mini", + "gpt-image-1.5", + "gpt-4o", + "gpt-5.2", + "gpt-5.2-pro", ] - providers.append({ - "provider": "openai", - "items": [{ - "url": "https://api.openai.com/v1/chat/completions", - "models": openai_models - }] - }) + providers.append( + { + "provider": "openai", + "items": [ + { + "url": "https://api.openai.com/v1/chat/completions", + "models": openai_models, + } + ], + } + ) return {"providers": providers} diff --git a/src/pdf_form_doc.py b/src/pdf_form_doc.py index 47183b35d..26b59657f 100644 --- a/src/pdf_form_doc.py +++ b/src/pdf_form_doc.py @@ -219,7 +219,7 @@ def create_plain_pdf_document( pages without form-field overlays. """ from src.database import SessionLocal, Document, DocumentVersion, Session as DbSession - from src.tool_implementations import set_active_document + from src.agent_tools.document_tools import set_active_document content = render_plain_pdf_markdown(upload_id, title, body_text) db = SessionLocal() @@ -402,7 +402,7 @@ def create_form_markdown_document( inside the content, which the export route looks for. """ from src.database import SessionLocal, Document, DocumentVersion, Session as DbSession - from src.tool_implementations import set_active_document + from src.agent_tools.document_tools import set_active_document content = render_form_as_markdown(fields, upload_id, title, intro_text=intro_text) db = SessionLocal() diff --git a/src/preset_manager.py b/src/preset_manager.py index 6364b8a9c..ae88a9432 100644 --- a/src/preset_manager.py +++ b/src/preset_manager.py @@ -115,9 +115,12 @@ Use precise language. Show causal relationships explicitly. Quantify uncertainty def save(self, presets: Dict[str, Any]) -> bool: """Save presets to file""" try: - os.makedirs(os.path.dirname(self.presets_file), exist_ok=True) - with open(self.presets_file, 'w', encoding="utf-8") as f: - json.dump(presets, f, indent=2) + # Atomic write (tmp file + os.replace) so a crash or serialization + # error mid-write can't truncate presets.json and lose every saved + # preset. Lazy import keeps this module free of the heavy core + # package import graph at load time. + from core.atomic_io import atomic_write_json + atomic_write_json(self.presets_file, presets, indent=2) self.presets = presets return True except Exception as e: diff --git a/src/prompt_security.py b/src/prompt_security.py index c07f4f870..3ee529a66 100644 --- a/src/prompt_security.py +++ b/src/prompt_security.py @@ -23,17 +23,60 @@ UNTRUSTED_CONTEXT_HEADER = ( ) +GUARD_OPEN = "<<>>" +GUARD_CLOSE = "<<>>" + + +def _escape_guard_markers(text: str) -> str: + """Neutralise delimiter literals inside untrusted text. + + If an attacker embeds the exact guard marker strings they can + prematurely close the sandbox block and inject instructions outside + it. Replacing them with a visually distinct but structurally inert + token prevents the breakout while preserving the original meaning + for human review. + """ + text = text.replace(GUARD_OPEN, "<<<_UNTRUSTED_DATA>>>") + text = text.replace(GUARD_CLOSE, "<<<_END_UNTRUSTED_DATA>>>") + return text + + +def _sanitize_label(label: str) -> str: + """Sanitize a label for safe inclusion *inside* the guarded block. + + Even though the label now lives inside the sandboxed region, we still + escape it for defence-in-depth: + 1. Strips leading/trailing whitespace. + 2. Replaces every CR/LF with a single space. + 3. Escapes guard marker literals via _escape_guard_markers() so the + label cannot prematurely close the sandbox block. + """ + label = label.strip() + label = label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + label = _escape_guard_markers(label) + return label + + def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]: - """Return an LLM message that keeps retrieved/source text out of system role.""" + """Return an LLM message that keeps retrieved/source text out of system role. + + The template is structured so that *only* the hardcoded + UNTRUSTED_CONTEXT_HEADER appears before GUARD_OPEN. No user- or + caller-derived text is placed in the pre-guard trusted framing zone. + The source label and the body content are both placed *inside* the + guarded block where the LLM treats them as untrusted data. + """ + safe_label = _sanitize_label(label) text = "" if content is None else str(content) + text = _escape_guard_markers(text) return { "role": "user", "content": ( f"{UNTRUSTED_CONTEXT_HEADER}\n" - f"Source: {label}\n\n" - "<<>>\n" + f"{GUARD_OPEN}\n" + f"Source: {safe_label}\n" f"{text}\n" - "<<>>" + f"{GUARD_CLOSE}" ), "metadata": {"trusted": False, "source": label}, } diff --git a/src/rag_manager.py b/src/rag_manager.py index 87f370472..a41608ecf 100644 --- a/src/rag_manager.py +++ b/src/rag_manager.py @@ -5,7 +5,9 @@ A thin wrapper around VectorRAG for backward compatibility and additional featur """ import logging -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional + +from src.constants import CHROMA_DIR # Try to import from different possible locations try: @@ -24,7 +26,7 @@ class RAGManager: Most methods delegate directly to VectorRAG. """ - def __init__(self, persist_directory: str = "data/chroma"): + def __init__(self, persist_directory: str = CHROMA_DIR): """Initialize the RAGManager with VectorRAG.""" self.vector_rag = VectorRAG(persist_directory=persist_directory) logger.info("RAGManager initialized as wrapper for VectorRAG") @@ -34,9 +36,18 @@ class RAGManager: """Search for documents - delegates to VectorRAG.""" return self.vector_rag.search(query, k) - def index_personal_documents(self, directory: str) -> Dict[str, Any]: + def index_personal_documents( + self, + directory: str, + file_extensions: Optional[set] = None, + owner: Optional[str] = None, + ) -> Dict[str, Any]: """Index documents - delegates to VectorRAG.""" - return self.vector_rag.index_personal_documents(directory) + return self.vector_rag.index_personal_documents( + directory, + file_extensions=file_extensions, + owner=owner, + ) def retrieve(self, query: str, k: int = 5) -> List[str]: """Retrieve relevant chunks - delegates to VectorRAG.""" diff --git a/src/rag_singleton.py b/src/rag_singleton.py index eb90e847a..7bc5d74b4 100644 --- a/src/rag_singleton.py +++ b/src/rag_singleton.py @@ -6,6 +6,8 @@ import logging import time from pathlib import Path +from src.constants import RAG_DIR + logger = logging.getLogger(__name__) rag_instance = None @@ -41,8 +43,7 @@ def get_rag_manager(): try: from src.rag_vector import VectorRAG - base_dir = Path(__file__).parent.parent - persist_dir = os.path.join(base_dir, "data", "rag") + persist_dir = RAG_DIR rag_instance = VectorRAG(persist_directory=persist_dir) if not rag_instance.healthy: diff --git a/src/rag_vector.py b/src/rag_vector.py index b10680c45..fc66c82e1 100644 --- a/src/rag_vector.py +++ b/src/rag_vector.py @@ -12,6 +12,8 @@ import re import logging import numpy as np from typing import List, Dict, Any, Optional, Set + +from src.constants import CHROMA_DIR from pathlib import Path from src.embedding_lanes import ( @@ -51,7 +53,7 @@ def _generate_doc_id(text: str, owner: str = "") -> str: class VectorRAG: """RAG system using ChromaDB vector storage with hybrid search.""" - def __init__(self, persist_directory: str = "data/chroma"): + def __init__(self, persist_directory: str = CHROMA_DIR): self.persist_directory = persist_directory self._collection = None self._model = None diff --git a/src/research_handler.py b/src/research_handler.py index 70433b61b..f1d120ef2 100644 --- a/src/research_handler.py +++ b/src/research_handler.py @@ -16,10 +16,11 @@ from pathlib import Path from typing import Optional, Dict from src.research_utils import strip_thinking, is_low_quality +from src.constants import DEEP_RESEARCH_DIR logger = logging.getLogger(__name__) -RESEARCH_DATA_DIR = Path("data/deep_research") +RESEARCH_DATA_DIR = Path(DEEP_RESEARCH_DIR) _RESEARCH_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9-]{1,128}$") @@ -220,6 +221,22 @@ class ResearchHandler: # Task registry — background research with persistence # ------------------------------------------------------------------ + def rename_owner(self, old_owner: str, new_owner: str) -> int: + """Move in-flight research tasks from one owner key to another.""" + old_key = str(old_owner or "").strip().lower() + new_key = str(new_owner or "").strip().lower() + if not old_key or not new_key: + return 0 + + changed = 0 + for entry in list(self._active_tasks.values()): + if not isinstance(entry, dict): + continue + if str(entry.get("owner", "")).strip().lower() == old_key: + entry["owner"] = new_key + changed += 1 + return changed + def start_research( self, session_id: str, @@ -362,8 +379,26 @@ class ResearchHandler: raise except Exception as e: logger.error(f"Background research failed: {e}", exc_info=True) - entry["result"] = str(e) - entry["status"] = "error" + # Preserve partial findings if available (mirrors timeout branch) + researcher = entry.get("researcher") + if researcher and researcher.evolving_report: + _elapsed = time.time() - entry["started_at"] + entry["result"] = self._format_research_report( + query, researcher.evolving_report, + researcher.get_stats(), _elapsed, + ) + entry["status"] = "done" + self._save_result(session_id, entry) + try: + sources = self._extract_sources(researcher.findings) if researcher.findings else [] + findings = self._extract_raw_findings(researcher.findings) if researcher.findings else [] + _guarded_complete(session_id, entry["result"], sources, findings) + except Exception as cb_err: + logger.warning(f"on_complete callback failed in error branch: {cb_err}") + on_progress({"phase": "warning", "message": f"Research finished with errors — partial results saved ({_elapsed:.0f}s elapsed)"}) + else: + entry["result"] = str(e) + entry["status"] = "error" task = asyncio.create_task(_run()) entry["task"] = task @@ -371,7 +406,6 @@ class ResearchHandler: def get_status(self, session_id: str) -> Optional[dict]: """Get current research status for a session.""" - avg = self.get_avg_duration() if session_id in self._active_tasks: entry = self._active_tasks[session_id] result = { @@ -380,6 +414,14 @@ class ResearchHandler: "query": entry["query"], "started_at": entry["started_at"], } + # avg_duration is a historical figure over completed reports on + # disk; get_avg_duration() globs and JSON-parses the whole research + # dir, so compute it at most once per active stream (memoized on the + # entry) instead of on every ~1s SSE poll. The disk branch below + # never used it, so it no longer pays that cost at all. + if "_avg_duration" not in entry: + entry["_avg_duration"] = self.get_avg_duration() + avg = entry["_avg_duration"] if avg is not None: result["avg_duration"] = round(avg, 1) return result diff --git a/src/secret_storage.py b/src/secret_storage.py index 15f02f26a..c4a08be1d 100644 --- a/src/secret_storage.py +++ b/src/secret_storage.py @@ -25,10 +25,11 @@ from pathlib import Path from cryptography.fernet import Fernet, InvalidToken from core.platform_compat import safe_chmod +from src.constants import APP_KEY_FILE logger = logging.getLogger(__name__) -_KEY_PATH = Path(__file__).resolve().parent.parent / "data" / ".app_key" +_KEY_PATH = Path(APP_KEY_FILE) _PREFIX = "enc:" _fernet: Fernet | None = None diff --git a/src/service_health.py b/src/service_health.py new file mode 100644 index 000000000..4b24bc9ed --- /dev/null +++ b/src/service_health.py @@ -0,0 +1,506 @@ +"""Consolidated service health / degraded-state reporting. + +ROADMAP: "Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, +and provider probes." There was no single readout of which subsystems are +actually working — `/api/health` is only a liveness ping and each subsystem's +signal lives in a different module. This collects them into one uniform, +*non-intrusive* report (no test push is sent, no real search is run), so the +admin endpoint built on top of it is safe to poll. + +Each probe returns: + + {"name": str, "status": "ok"|"degraded"|"down"|"disabled", + "detail": str, "meta": dict} + +- ok — reachable / working +- degraded — partially working (one of several components down) +- down — configured & enabled but unreachable / erroring +- disabled — not configured or turned off (not counted as a failure) + +Design notes (driven by review feedback): + +- **Bounded wall-clock.** Per-item probes (providers, email accounts) fan out + across a bounded thread pool with a hard total budget (`_FANOUT_BUDGET`); + stragglers are reported as a controlled `timeout` rather than blocking. The + aggregate adds a per-subsystem deadline (`_SUBSYSTEM_DEADLINE`) and an overall + ceiling (`_AGGREGATE_DEADLINE`), so the endpoint cannot hang regardless of how + many endpoints/accounts are configured or how slowly they respond. +- **No secret leakage.** Even though the endpoint is admin-only, the response + never returns credential-bearing URLs or raw exception text: URLs are passed + through `_safe_url` (userinfo / query / fragment stripped) and failures are + mapped to controlled categories via `_classify_error`. + +The probe functions take their inputs as parameters (settings dict, account +list, endpoint list, manager objects) and isolate the network call to +``_http_get`` / injected callables, so they unit-test without touching the +network. +""" + +import asyncio +import concurrent.futures +import logging +import socket +import ssl +import time +from typing import Any, Callable, Dict, List, Optional +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# Status ordering for rolling up an overall verdict. "disabled" is excluded — +# a turned-off feature must never drag the overall status down. +_SEVERITY = {"ok": 0, "degraded": 1, "down": 2} + +OK = "ok" +DEGRADED = "degraded" +DOWN = "down" +DISABLED = "disabled" + +# Timing budgets (seconds). _PROBE_TIMEOUT bounds a single network op; +# _FANOUT_BUDGET bounds a whole fan-out (providers/email) regardless of count; +# the aggregate layer adds a per-subsystem deadline and an overall ceiling. +_PROBE_TIMEOUT = 4 +_PROBE_CONCURRENCY = 8 +_FANOUT_BUDGET = 8 +_SUBSYSTEM_DEADLINE = 10 +_AGGREGATE_DEADLINE = 14 + +# Controlled, secret-free phrasing for each failure category. +_ERROR_DETAIL = { + "timeout": "probe timed out", + "connection_refused": "connection refused", + "dns_error": "host could not be resolved", + "tls_error": "TLS handshake failed", + "network_error": "network error", + "http_error": "server returned an error response", + "auth_or_protocol_error": "authentication or protocol error", + "no_models": "endpoint returned no models", + "no_host": "no host configured", + "error": "probe failed", +} + + +def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]: + return {"name": name, "status": status, "detail": detail, "meta": dict(meta)} + + +def _safe_url(url: Optional[str]) -> str: + """Strip credentials (userinfo), query, and fragment from a URL. + + Keeps scheme / host / port / path so the report is still useful, but never + echoes `user:pass@`, `?api_key=…`, or `#…` back to the caller. Returns + "" if the URL can't be parsed into at least a host. + """ + if not url: + return "" + raw = url.strip() + try: + p = urlparse(raw if "://" in raw else "//" + raw) + host = p.hostname or "" + if not host: + return "" + netloc = f"{host}:{p.port}" if p.port else host + path = (p.path or "").rstrip("/") + scheme = f"{p.scheme}://" if p.scheme else "" + return f"{scheme}{netloc}{path}" + except Exception: + return "" + + +def _classify_error(exc: BaseException) -> str: + """Map an exception to a controlled, secret-free category token. + + Never returns `str(exc)` — httpx/imaplib exception text can embed the target + URL (which may carry credentials) or server-supplied detail. + """ + if isinstance(exc, (asyncio.TimeoutError, concurrent.futures.TimeoutError, + TimeoutError, socket.timeout)): + return "timeout" + name = type(exc).__name__ + mod = (type(exc).__module__ or "") + if isinstance(exc, ssl.SSLError) or "SSL" in name or "Certificate" in name: + return "tls_error" + if isinstance(exc, socket.gaierror) or name in ("gaierror", "herror"): + return "dns_error" + if isinstance(exc, ConnectionRefusedError) or "ConnectionRefused" in name \ + or name in ("ConnectError",): + return "connection_refused" + if "Timeout" in name: + return "timeout" + if mod.startswith("imaplib") or name in ("error", "abort", "readonly"): + return "auth_or_protocol_error" + if name == "HTTPStatusError": + return "http_error" + if name in ("ConnectTimeout", "ReadTimeout", "ReadError", "WriteError", + "PoolTimeout", "RemoteProtocolError", "NetworkError", + "ProxyError", "ProtocolError"): + return "network_error" + if isinstance(exc, OSError): + return "network_error" + return "error" + + +def _detail_for(category: str) -> str: + return _ERROR_DETAIL.get(category, _ERROR_DETAIL["error"]) + + +def _http_get(url: str, timeout: float = _PROBE_TIMEOUT): + """Single network entry point for the HTTP probes (monkeypatched in tests).""" + import httpx + return httpx.get(url, timeout=timeout) + + +def _bounded_map(items: List[Any], worker: Callable[[int, Any], Dict[str, Any]], + *, budget: float = _FANOUT_BUDGET, + concurrency: int = _PROBE_CONCURRENCY) -> List[Optional[Dict[str, Any]]]: + """Run ``worker(index, item)`` across a bounded thread pool, in order. + + `worker` must catch its own exceptions and return a per-item dict. Any item + not finished within `budget` seconds *in total* is left as ``None`` (the + caller substitutes a controlled `timeout` entry). The pool is shut down with + ``wait=False`` so stragglers never block the response — their own per-op + timeout reaps them shortly after. + """ + n = len(items) + out: List[Optional[Dict[str, Any]]] = [None] * n + if n == 0: + return out + ex = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, min(concurrency, n))) + futures = {ex.submit(worker, i, items[i]): i for i in range(n)} + try: + for fut in concurrent.futures.as_completed(futures, timeout=budget): + i = futures[fut] + try: + out[i] = fut.result() + except Exception as e: # worker is expected to handle its own errors + out[i] = {"ok": False, "error": _classify_error(e)} + except concurrent.futures.TimeoutError: + pass # unfinished items stay None → marked timeout by the caller + finally: + ex.shutdown(wait=False, cancel_futures=True) + return out + + +# ── ChromaDB (vector RAG + vector memory) ── + +def chromadb_health(rag_manager: Any, memory_vector: Any) -> Dict[str, Any]: + """Report on the two ChromaDB-backed stores via their `.healthy` flags. + + Both absent → disabled (Chroma/embeddings not installed or off). + Both healthy → ok. One down → degraded. Both present but unhealthy → down. + """ + rag_present = rag_manager is not None + mem_present = memory_vector is not None + if not rag_present and not mem_present: + return _svc("chromadb", DISABLED, + "Vector RAG and vector memory are not initialized.", + rag=None, memory=None) + + rag_ok = bool(rag_present and getattr(rag_manager, "healthy", False)) + mem_ok = bool(mem_present and getattr(memory_vector, "healthy", False)) + meta = {"rag": rag_ok if rag_present else None, + "memory": mem_ok if mem_present else None} + + healthy = [ok for ok in (rag_ok if rag_present else None, + mem_ok if mem_present else None) if ok is not None] + if healthy and all(healthy): + return _svc("chromadb", OK, "Vector stores healthy.", **meta) + if any(healthy): + return _svc("chromadb", DEGRADED, + "One vector store is unavailable.", **meta) + return _svc("chromadb", DOWN, "Vector stores are unavailable.", **meta) + + +# ── SearXNG ── + +def _searxng_instance(settings: Dict[str, Any]) -> str: + """Mirror src/search/providers.py:_get_search_instance precedence.""" + url = (settings.get("search_url") or "").strip() + if url: + return url.rstrip("/") + from src.constants import SEARXNG_INSTANCE + return SEARXNG_INSTANCE.rstrip("/") + + +def searxng_health(settings: Dict[str, Any], + *, http_get: Callable = _http_get) -> Dict[str, Any]: + """Non-intrusive reachability probe for the configured SearXNG instance. + + Tries `/healthz` (2xx), falling back to the instance root (any non-5xx means + the host answered). No search query is run. The configured instance is + probed in full, but only its sanitized form is returned in `meta`. + """ + provider = (settings.get("search_provider") or "searxng") + if provider != "searxng": + return _svc("searxng", DISABLED, + f"Search provider is '{provider}', not SearXNG.", + provider=provider) + instance = _searxng_instance(settings) + if not instance: + return _svc("searxng", DISABLED, "No SearXNG instance configured.") + safe_instance = _safe_url(instance) + last_category = "error" + for path, accept in (("/healthz", lambda c: 200 <= c < 300), + ("/", lambda c: 0 < c < 500)): + try: + r = http_get(instance + path, timeout=_PROBE_TIMEOUT) + code = getattr(r, "status_code", 0) + if accept(code): + return _svc("searxng", OK, f"Reachable (HTTP {code}).", + instance=safe_instance, probed=path, http_status=code) + last_category = "http_error" + except Exception as e: # connection refused, DNS, timeout, … + last_category = _classify_error(e) + return _svc("searxng", DOWN, f"Unreachable ({_detail_for(last_category)}).", + instance=safe_instance, error=last_category) + + +# ── ntfy ── + +def _ntfy_integration(integrations: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """First enabled ntfy integration with a base_url (matches note_routes).""" + for i in integrations or []: + if (i.get("preset") == "ntfy" and i.get("enabled", True) + and i.get("base_url")): + return i + return None + + +def ntfy_health(integrations: List[Dict[str, Any]], settings: Dict[str, Any], + *, http_get: Callable = _http_get) -> Dict[str, Any]: + """Non-intrusive ntfy probe via the server's built-in `/v1/health` route. + + No test notification is POSTed — `/v1/health` returns `{"healthy":true}` + without publishing to a topic. The request keeps whatever credentials the + configured base_url carries, but `meta.base` is sanitized. + """ + channel = settings.get("reminder_channel") or "browser" + intg = _ntfy_integration(integrations) + if not intg: + return _svc("ntfy", DISABLED, "No ntfy integration configured.", + reminder_channel=channel) + raw = (intg.get("base_url") or "").strip() + parsed = urlparse(raw) + probe_base = (f"{parsed.scheme}://{parsed.netloc}" + if parsed.scheme and parsed.netloc else raw.rstrip("/")) + safe_base = _safe_url(raw) + try: + r = http_get(probe_base + "/v1/health", timeout=_PROBE_TIMEOUT) + code = getattr(r, "status_code", 0) + if code and code < 500: + return _svc("ntfy", OK, f"Reachable (HTTP {code}).", + base=safe_base, reminder_channel=channel, http_status=code) + return _svc("ntfy", DOWN, "Server returned an error response.", + base=safe_base, reminder_channel=channel, error="http_error") + except Exception as e: + category = _classify_error(e) + return _svc("ntfy", DOWN, f"Unreachable ({_detail_for(category)}).", + base=safe_base, reminder_channel=channel, error=category) + + +# ── Email (IMAP) ── + +def email_health(accounts: List[Dict[str, Any]], + *, connect: Optional[Callable] = None) -> Dict[str, Any]: + """Try a short IMAP connect+logout per configured account, concurrently. + + All connect → ok. Some fail → degraded. All fail → down. No account + configured → disabled. Bounded by `_FANOUT_BUDGET` regardless of count. + `meta` carries only the account label and a controlled error category — + never credentials or raw exception text. + """ + if not accounts: + return _svc("email", DISABLED, "No email accounts configured.") + if connect is None: + from routes.email_helpers import _imap_connect + # Impose the service-health budget on the IMAP connect itself. + connect = lambda aid: _imap_connect(aid, timeout=_PROBE_TIMEOUT) # noqa: E731 + + def _label(acc: Dict[str, Any]) -> str: + return acc.get("account_name") or acc.get("account_id") or "account" + + def _check(_i: int, acc: Dict[str, Any]) -> Dict[str, Any]: + name = _label(acc) + if not (acc.get("imap_host") or ""): + return {"name": name, "ok": False, "error": "no_host"} + try: + conn = connect(acc.get("account_id")) + try: + conn.logout() + except Exception: + pass + return {"name": name, "ok": True, "error": None} + except Exception as e: + return {"name": name, "ok": False, "error": _classify_error(e)} + + raw = _bounded_map(accounts, _check, budget=_FANOUT_BUDGET, + concurrency=_PROBE_CONCURRENCY) + per_account = [r if r is not None + else {"name": _label(accounts[i]), "ok": False, "error": "timeout"} + for i, r in enumerate(raw)] + return _rollup_items("email", "mailbox(es)", per_account) + + +# ── Provider endpoints ── + +def providers_health(endpoints: List[Dict[str, Any]], + *, probe: Optional[Callable] = None) -> Dict[str, Any]: + """Probe each enabled model endpoint's model list, concurrently. + + `endpoints` is a list of plain dicts ({name, base_url, api_key}) so this + stays decoupled from the ORM and trivially testable. Non-empty model list + → reachable. Bounded by `_FANOUT_BUDGET` regardless of count. `meta` never + contains api_key or raw URLs — only a display name (or a sanitized URL when + no name is set) and a controlled error category. + """ + if not endpoints: + return _svc("providers", DISABLED, "No model endpoints configured.") + if probe is None: + from routes.model_routes import _probe_endpoint as probe + + def _label(ep: Dict[str, Any]) -> str: + return ep.get("name") or _safe_url(ep.get("base_url")) or "endpoint" + + def _check(_i: int, ep: Dict[str, Any]) -> Dict[str, Any]: + name = _label(ep) + try: + models = probe(ep.get("base_url"), ep.get("api_key"), + timeout=_PROBE_TIMEOUT) or [] + except Exception as e: + return {"name": name, "ok": False, "model_count": 0, + "error": _classify_error(e)} + count = len(models) + return {"name": name, "ok": bool(count), "model_count": count, + "error": None if count else "no_models"} + + raw = _bounded_map(endpoints, _check, budget=_FANOUT_BUDGET, + concurrency=_PROBE_CONCURRENCY) + per_endpoint = [r if r is not None + else {"name": _label(endpoints[i]), "ok": False, + "model_count": 0, "error": "timeout"} + for i, r in enumerate(raw)] + return _rollup_items("providers", "endpoint(s)", per_endpoint, key="endpoints") + + +def _rollup_items(name: str, noun: str, items: List[Dict[str, Any]], + key: str = "accounts") -> Dict[str, Any]: + """Shared ok/degraded/down rollup for a list of per-item probe results.""" + total = len(items) + ok_count = sum(1 for it in items if it.get("ok")) + if ok_count == total: + status, detail = OK, f"{ok_count}/{total} {noun} reachable." + elif ok_count == 0: + status, detail = DOWN, f"No {noun} reachable." + else: + status, detail = DEGRADED, f"{ok_count}/{total} {noun} reachable." + return _svc(name, status, detail, **{key: items}) + + +# ── Aggregate ── + +def _rollup(services: List[Dict[str, Any]]) -> str: + worst = OK + for s in services: + sev = _SEVERITY.get(s.get("status")) + if sev is not None and sev > _SEVERITY[worst]: + worst = s["status"] + return worst + + +def _gather_inputs() -> Dict[str, Any]: + """Pull live config/account/endpoint lists from the app's data sources. + + Each lookup fails soft: a broken source yields an empty/neutral value so a + single failure can't take down the whole health report. + """ + settings: Dict[str, Any] = {} + integrations: List[Dict[str, Any]] = [] + accounts: List[Dict[str, Any]] = [] + endpoints: List[Dict[str, Any]] = [] + try: + from src.settings import load_settings + settings = load_settings() or {} + except Exception as e: + logger.debug(f"service_health: settings load failed: {e}") + try: + from src.integrations import load_integrations + integrations = load_integrations() or [] + except Exception as e: + logger.debug(f"service_health: integrations load failed: {e}") + try: + from routes.email_helpers import _list_email_accounts + accounts = _list_email_accounts() or [] + except Exception as e: + logger.debug(f"service_health: email accounts load failed: {e}") + try: + from core.database import SessionLocal, ModelEndpoint + db = SessionLocal() + try: + rows = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True).all() # noqa: E712 + endpoints = [{"name": r.name, "base_url": r.base_url, + "api_key": r.api_key} for r in rows] + finally: + db.close() + except Exception as e: + logger.debug(f"service_health: endpoint load failed: {e}") + return {"settings": settings, "integrations": integrations, + "accounts": accounts, "endpoints": endpoints} + + +async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]: + """Run one (sync) subsystem probe in a thread under a hard deadline. + + A subsystem that overruns `_SUBSYSTEM_DEADLINE` (or raises) becomes a + controlled `down`/`timeout` entry instead of hanging or leaking the error. + """ + try: + return await asyncio.wait_for(asyncio.to_thread(fn, *args), + timeout=_SUBSYSTEM_DEADLINE) + except asyncio.TimeoutError: + return _svc(name, DOWN, _detail_for("timeout"), error="timeout") + except Exception as e: + category = _classify_error(e) + return _svc(name, DOWN, _detail_for(category), error=category) + + +async def collect_service_health(rag_manager: Any = None, + memory_vector: Any = None) -> Dict[str, Any]: + """Run every probe and return {overall, services, timestamp}. + + Bounded end-to-end: in-process ChromaDB flags are read synchronously; the + four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`, + with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside + providers/email are themselves bounded by `_FANOUT_BUDGET`. + """ + from datetime import datetime, timezone + + inputs = _gather_inputs() + settings = inputs["settings"] + + # ChromaDB is in-process and synchronous (just reads flags). + chroma = chromadb_health(rag_manager, memory_vector) + + names = ["searxng", "ntfy", "email", "providers"] + coros = [ + _run_subsystem("searxng", searxng_health, settings), + _run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings), + _run_subsystem("email", email_health, inputs["accounts"]), + _run_subsystem("providers", providers_health, inputs["endpoints"]), + ] + try: + results = await asyncio.wait_for(asyncio.gather(*coros), + timeout=_AGGREGATE_DEADLINE) + except asyncio.TimeoutError: + # Hard backstop — should not normally fire given per-subsystem deadlines. + results = [_svc(n, DOWN, _detail_for("timeout"), error="timeout") + for n in names] + + services = [chroma, *results] + return { + "overall": _rollup(services), + "services": services, + # Timezone-aware UTC (…+00:00). Avoids the deprecated naive + # datetime.utcnow() flagged in review (overlaps with #1116). + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/src/session_actions.py b/src/session_actions.py index 7f0944b2f..072bb4c06 100644 --- a/src/session_actions.py +++ b/src/session_actions.py @@ -8,7 +8,7 @@ and the task scheduler / builtin actions system. import json import logging import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone logger = logging.getLogger(__name__) @@ -23,6 +23,34 @@ _THROWAWAY_NAMES = { } _THROWAWAY_MAX_MESSAGES = 4 _FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10) +_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE + + +def _utcnow_naive() -> datetime: + """Return naive UTC for existing session DateTime columns.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _as_naive_utc(value): + if value is None: + return None + if getattr(value, "tzinfo", None) is not None: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value + + +def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> bool: + """Return True while a new or active session is too fresh to auto-delete.""" + now = _as_naive_utc(now) or _utcnow_naive() + for attr in ("last_message_at", "last_accessed", "updated_at", "created_at"): + value = _as_naive_utc(getattr(row, attr, None)) + if not value: + continue + if value >= now: + return True + if now - value <= grace: + return True + return False async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str: @@ -52,15 +80,18 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo *([DbSession.owner == owner] if owner else []), ).all() + cleanup_now = _utcnow_naive() for row in rows: if getattr(row, 'is_important', False): continue - created_at = row.created_at or row.updated_at or datetime.utcnow() - is_fresh = (datetime.utcnow() - created_at) < _FRESH_EMPTY_SESSION_GRACE + created_at = _as_naive_utc(row.created_at or row.updated_at) or _utcnow_naive() + is_fresh = (_utcnow_naive() - created_at) < _FRESH_EMPTY_SESSION_GRACE if (row.name or "").strip() == "Incognito": deleted_throwaway += 1 db.delete(row) continue + if is_session_recently_active(row, now=cleanup_now): + continue msg_count = db.query(DbMsg.id).filter( DbMsg.session_id == row.id @@ -132,7 +163,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo if skip_llm: return f"Cleaned {deleted_empty + deleted_throwaway} sessions (folder sort skipped)." - url, model, headers = resolve_task_endpoint() + url, model, headers = resolve_task_endpoint(owner=owner or None) if not url: return f"Cleaned {deleted_empty + deleted_throwaway} sessions. No model endpoint available for sorting." @@ -208,7 +239,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo db_sess = db.query(DbSession).filter(DbSession.id == full_id).first() if db_sess: db_sess.folder = folder_name - db_sess.updated_at = datetime.utcnow() + db_sess.updated_at = _utcnow_naive() updated += 1 db.commit() diff --git a/src/settings_scrub.py b/src/settings_scrub.py index 6c76438d6..7dc462f2e 100644 --- a/src/settings_scrub.py +++ b/src/settings_scrub.py @@ -18,12 +18,20 @@ _SECRET_KEY_PATTERNS = ( "_credential", "_credentials", "_key", ) _SECRET_KEY_ALLOW = ("google_pse_cx",) # public identifiers, not secrets +_SENSITIVE_KEY_EXACT = ( + # A stable global integration id is a capability handle for routes that can + # trigger outbound webhook sends; do not expose it to non-admin settings + # callers even though it is not secret-shaped. + "reminder_webhook_integration_id", +) def is_secret_key(name: str) -> bool: n = (name or "").lower() if n in _SECRET_KEY_ALLOW: return False + if n in _SENSITIVE_KEY_EXACT: + return True return any(n.endswith(p) or n == p.lstrip("_") for p in _SECRET_KEY_PATTERNS) diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 2fcb5dc09..4b71ff8f6 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -844,7 +844,13 @@ class TaskScheduler: # Task chaining — trigger the next task on success if run.status == "success" and task.then_task_id: chain_id = task.then_task_id - if not self._has_chain_cycle(db, chain_id): + chain_task = db.query(ScheduledTask).filter(ScheduledTask.id == chain_id).first() + if not chain_task or chain_task.owner != task.owner: + logger.warning( + "Skipping chain from %r: target task %s is missing or not owned by %r", + task.name, chain_id, task.owner, + ) + elif not self._has_chain_cycle(db, chain_id, owner=task.owner): logger.info(f"Chaining: '{task.name}' → task {chain_id}") asyncio.create_task(self._run_chained(chain_id)) else: @@ -1092,7 +1098,7 @@ class TaskScheduler: endpoint_url: str, model: str) -> str: """Gather raw data from all integrations, hand it to the LLM to write the check-in.""" from src.tool_implementations import do_manage_notes - from src.agent_tools import get_mcp_manager + from src.tool_utils import get_mcp_manager tz_name = _resolve_task_timezone(db, task) try: @@ -1309,6 +1315,7 @@ class TaskScheduler: endpoint_url=endpoint_url, model=model, owner=task.owner, + folder="Tasks", created_at=_utcnow(), updated_at=_utcnow(), ) @@ -1317,7 +1324,10 @@ class TaskScheduler: db.commit() if self._session_manager: try: - self._session_manager.sessions[session_id] = self._session_manager._db_to_session(sess) + self._session_manager.ensure_task_session( + session_id, f"[Task] {task.name}", endpoint_url, model, + owner=task.owner, task=task + ) except Exception: pass @@ -1410,6 +1420,7 @@ class TaskScheduler: task's visible output target. """ from core.database import Session as DbSession, ChatMessage, CrewMember + from core.models import ChatMessage as MemChatMessage output = task.output_target or "session" if ( @@ -1457,6 +1468,7 @@ class TaskScheduler: endpoint_url=endpoint_url or "", model=model_name or "", owner=task.owner, + folder="Tasks", created_at=_utcnow(), updated_at=_utcnow(), ) @@ -1465,7 +1477,10 @@ class TaskScheduler: db.commit() if self._session_manager: try: - self._session_manager.sessions[session_id] = self._session_manager._db_to_session(sess) + self._session_manager.ensure_task_session( + session_id, f"[Task] {task.name}", endpoint_url, model_name, + owner=task.owner, task=task + ) except Exception: pass @@ -1474,36 +1489,50 @@ class TaskScheduler: meta["model"] = model_name if crew and crew.is_default_assistant: meta.update({"source": "cron", "task_id": task.id, "task_name": task.name}) - msg_meta = json.dumps(meta) - user_content = task.prompt or f"[Task] {task.name}" - user_msg = ChatMessage( - id=str(uuid.uuid4()), - session_id=session_id, - role="user", - content=user_content, - timestamp=_utcnow(), - meta_data=msg_meta, - ) - assistant_msg = ChatMessage( - id=str(uuid.uuid4()), - session_id=session_id, - role="assistant", - content=result or "", - timestamp=_utcnow(), - meta_data=msg_meta, - ) - db.add(user_msg) - db.add(assistant_msg) - db.commit() - if self._session_manager: + # Use SessionManager for persistence so in-memory cache stays in sync + if self._session_manager and session_id: try: - from core.models import ChatMessage as MemMsg - sess_obj = self._session_manager.get_session(session_id) - sess_obj.history.append(MemMsg(role="user", content=user_msg.content, metadata=meta)) - sess_obj.history.append(MemMsg(role="assistant", content=assistant_msg.content, metadata=meta)) + self._session_manager.add_message( + session_id, + MemChatMessage( + "user", + task.prompt or f"[Task] {task.name}", + metadata=dict(meta), + ), + ) + self._session_manager.add_message( + session_id, + MemChatMessage( + "assistant", + result or "", + metadata=dict(meta), + ), + ) except Exception: - pass + logger.exception("Failed to deliver task %s through SessionManager", task.id) + else: + # Fallback: raw DB write (no session manager available) + msg_meta = json.dumps(meta) + user_msg = ChatMessage( + id=str(uuid.uuid4()), + session_id=session_id, + role="user", + content=task.prompt or f"[Task] {task.name}", + timestamp=_utcnow(), + meta_data=msg_meta, + ) + assistant_msg = ChatMessage( + id=str(uuid.uuid4()), + session_id=session_id, + role="assistant", + content=result or "", + timestamp=_utcnow(), + meta_data=msg_meta, + ) + db.add(user_msg) + db.add(assistant_msg) + db.commit() @staticmethod def _is_email_output_target(output: str) -> bool: @@ -1574,9 +1603,12 @@ class TaskScheduler: try: from core.database import SessionLocal, ModelEndpoint from src.endpoint_resolver import normalize_base, build_headers + from src.auth_helpers import owner_filter db2 = SessionLocal() try: - eps = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() + ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None) + eps = ep_q.all() for ep in eps: if normalize_base(ep.base_url) in endpoint_url or endpoint_url in normalize_base(ep.base_url): headers = build_headers(ep.api_key, normalize_base(ep.base_url)) @@ -1597,7 +1629,7 @@ class TaskScheduler: # chat uses but with the utility list (`utility_model_fallbacks`). try: from src.endpoint_resolver import resolve_utility_fallback_candidates - _task_fallbacks = resolve_utility_fallback_candidates() + _task_fallbacks = resolve_utility_fallback_candidates(owner=task.owner or None) except Exception: _task_fallbacks = [] async for event_str in stream_agent_loop( @@ -1640,7 +1672,7 @@ class TaskScheduler: else: grace_context += "No tool results were captured." grace_context += "\n\nSummarize what you accomplished and what's still pending. Be concise." - _grace_candidates = [(endpoint_url, model, headers)] + resolve_utility_fallback_candidates() + _grace_candidates = [(endpoint_url, model, headers)] + resolve_utility_fallback_candidates(owner=task.owner or None) full_text = await llm_call_async_with_fallback( _grace_candidates, messages=[ @@ -1668,6 +1700,8 @@ class TaskScheduler: # Resolve endpoint/model: research settings > task settings > session defaults endpoint_url = task.endpoint_url model = task.model + headers = {} + headers_from_resolver = False if not endpoint_url or not model: try: @@ -1677,9 +1711,13 @@ class TaskScheduler: endpoint_url or None, model or None, None, + owner=task.owner or None, ) endpoint_url = ep_url or endpoint_url model = ep_model or model + if ep_headers is not None: + headers = ep_headers + headers_from_resolver = True except Exception: pass @@ -1691,16 +1729,19 @@ class TaskScheduler: self._last_run_model = model # Resolve headers - headers = {} try: from core.database import ModelEndpoint from src.endpoint_resolver import normalize_base, build_headers + from src.auth_helpers import owner_filter db2 = db - eps = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all() - for ep in eps: - if normalize_base(ep.base_url) in endpoint_url or endpoint_url in normalize_base(ep.base_url): - headers = build_headers(ep.api_key, normalize_base(ep.base_url)) - break + if not headers_from_resolver: + ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) + ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None) + eps = ep_q.all() + for ep in eps: + if normalize_base(ep.base_url) in endpoint_url or endpoint_url in normalize_base(ep.base_url): + headers = build_headers(ep.api_key, normalize_base(ep.base_url)) + break except Exception: pass @@ -1737,6 +1778,7 @@ class TaskScheduler: endpoint_url=endpoint_url, model=model, owner=task.owner, + folder="Tasks", created_at=_utcnow(), updated_at=_utcnow(), ) @@ -1791,7 +1833,7 @@ class TaskScheduler: self._executing.add(task_id) await self._execute_task(task_id) - def _has_chain_cycle(self, db, start_id: str, max_depth: int = 10) -> bool: + def _has_chain_cycle(self, db, start_id: str, max_depth: int = 10, owner: str | None = None) -> bool: """Detect cycles in task chains.""" from core.database import ScheduledTask visited = set() @@ -1801,6 +1843,8 @@ class TaskScheduler: return True visited.add(current) task = db.query(ScheduledTask).filter(ScheduledTask.id == current).first() + if owner is not None and task and task.owner != owner: + return True if not task or not task.then_task_id: return False current = task.then_task_id @@ -1831,7 +1875,7 @@ class TaskScheduler: have to special-case each tool's schema; the MCP tool ignores keys it doesn't recognise. """ - from src.agent_tools import get_mcp_manager + from src.tool_utils import get_mcp_manager mcp = get_mcp_manager() if not mcp: logger.warning(f"Task {task.id}: MCP manager not available for delivery") diff --git a/src/teacher_escalation.py b/src/teacher_escalation.py index e830ce17f..94d9ee81c 100644 --- a/src/teacher_escalation.py +++ b/src/teacher_escalation.py @@ -229,12 +229,13 @@ portable across users / hosts. """ -async def _call_teacher(teacher_model_spec: str, prompt: str) -> Optional[str]: +async def _call_teacher(teacher_model_spec: str, prompt: str, + owner: Optional[str] = None) -> Optional[str]: """Call the configured teacher endpoint with the escalation prompt.""" from src.llm_core import llm_call_async from src.ai_interaction import _resolve_model, _TEACHER_SYSTEM_PROMPT try: - url, model, headers = _resolve_model(teacher_model_spec) + url, model, headers = _resolve_model(teacher_model_spec, owner=owner) except Exception as e: logger.warning(f"teacher endpoint not resolvable ({teacher_model_spec!r}): {e}") return None @@ -388,7 +389,7 @@ async def escalate_and_learn( untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD, trace=_format_trace(tool_results, agent_reply), ) - response = await _call_teacher(teacher_spec, prompt) + response = await _call_teacher(teacher_spec, prompt, owner=owner) if not response: return None @@ -523,7 +524,7 @@ async def run_teacher_inline( # Resolve teacher endpoint try: from src.ai_interaction import _resolve_model - teacher_url, teacher_model, teacher_headers = _resolve_model(teacher_spec) + teacher_url, teacher_model, teacher_headers = _resolve_model(teacher_spec, owner=owner) except Exception as e: logger.warning(f"teacher endpoint not resolvable ({teacher_spec!r}): {e}") yield ( @@ -617,7 +618,7 @@ async def run_teacher_inline( untrusted_trace_guard=_UNTRUSTED_TRACE_GUARD, trace=_format_trace(captured_tool_events, teacher_text), ) - skill_response = await _call_teacher(teacher_spec, prompt) + skill_response = await _call_teacher(teacher_spec, prompt, owner=owner) if skill_response and "NO_SKILL" in skill_response and not _extract_skill_json(skill_response): logger.info("teacher declined to write a skill (NO_SKILL)") yield ( diff --git a/src/tool_execution.py b/src/tool_execution.py index f4dc9ae0d..751bc13af 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -18,119 +18,21 @@ import sys import time from typing import Any, Awaitable, Callable, Dict, Optional, Tuple + + from src.tool_security import is_public_blocked_tool, owner_is_admin_or_single_user -from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES +from src.tool_policy import ToolPolicy +from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS, MAX_DIFF_LINES, DATA_DIR +from src.tool_utils import _truncate, get_mcp_manager # Persistent working directory for agent subprocesses. # Resolves to /data, which is the bind-mounted volume in Docker # (/app/data) and the local data directory for manual installs. # Using this as cwd and HOME prevents the agent from silently creating files # in ephemeral container layers that are lost on the next rebuild. -_AGENT_WORKDIR = str(pathlib.Path(__file__).parent.parent / "data") +_AGENT_WORKDIR = DATA_DIR -def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]: - """Build a unified diff of a file write for display in the chat. - - Returns {"text": , "added": N, "removed": M, "new_file": bool} - or None when there's no textual change. Truncates very large diffs. - """ - if old == new: - return None - import difflib - - old_lines = old.splitlines() - new_lines = new.splitlines() - label = path or "file" - diff_lines = list(difflib.unified_diff( - old_lines, new_lines, - fromfile=f"a/{label}", tofile=f"b/{label}", - lineterm="", - )) - added = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++")) - removed = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---")) - truncated = False - if len(diff_lines) > MAX_DIFF_LINES: - diff_lines = diff_lines[:MAX_DIFF_LINES] - truncated = True - text = "\n".join(diff_lines) - if truncated: - text += f"\n… diff truncated at {MAX_DIFF_LINES} lines" - return { - "text": text, - "added": added, - "removed": removed, - "new_file": old == "", - "file": os.path.basename(path) or (path or "file"), - } - - -async def _do_edit_file(content: str, workspace: Optional[str] = None) -> Dict[str, Any]: - """Exact string-replacement edit of an on-disk file. - - content is JSON: {"path", "old_string", "new_string", "replace_all"?}. - Fails if old_string is missing or non-unique (unless replace_all) so the - model can't silently edit the wrong place. Returns a unified diff for the UI. - Confined to the workspace when one is set (same policy as write_file). - """ - try: - args = json.loads(content) if content.strip().startswith("{") else {} - except (json.JSONDecodeError, TypeError): - args = {} - raw_path = (args.get("path") or "").strip() - old = args.get("old_string", "") - new = args.get("new_string", "") - replace_all = bool(args.get("replace_all", False)) - if not raw_path: - return {"error": "edit_file: path required", "exit_code": 1} - # Confine to the workspace when set, else the same allowlist + sensitive-file - # policy as read/write_file. - try: - path = (_resolve_tool_path_in_workspace(workspace, raw_path) - if workspace else _resolve_tool_path(raw_path)) - except ValueError as e: - return {"error": f"edit_file: {e}", "exit_code": 1} - if old == "": - return {"error": "edit_file: old_string required (use write_file to create a file)", "exit_code": 1} - if old == new: - return {"error": "edit_file: old_string and new_string are identical", "exit_code": 1} - - def _apply(): - with open(path, "r", encoding="utf-8") as f: - original = f.read() - count = original.count(old) - if count == 0: - return original, None, "not_found" - if count > 1 and not replace_all: - return original, None, f"not_unique:{count}" - updated = original.replace(old, new) if replace_all else original.replace(old, new, 1) - with open(path, "w", encoding="utf-8") as f: - f.write(updated) - return original, updated, "ok" - - try: - original, updated, status = await asyncio.to_thread(_apply) - except FileNotFoundError: - return {"error": f"edit_file: {path}: not found (use write_file to create it)", "exit_code": 1} - except (IsADirectoryError, UnicodeDecodeError): - return {"error": f"edit_file: {path}: not an editable text file", "exit_code": 1} - except PermissionError: - return {"error": f"edit_file: {path}: permission denied", "exit_code": 1} - except OSError as e: - return {"error": f"edit_file: {path}: {e}", "exit_code": 1} - - if status == "not_found": - return {"error": f"edit_file: old_string not found in {path}. Read the file and match it exactly.", "exit_code": 1} - if status.startswith("not_unique"): - n = status.split(":", 1)[1] - return {"error": f"edit_file: old_string is not unique in {path} ({n} matches). Add surrounding context or set replace_all=true.", "exit_code": 1} - - n = original.count(old) - result = {"output": f"Edited {path} ({n} replacement{'s' if n != 1 else ''})", "exit_code": 0} - diff = _unified_diff(original, updated, path) - if diff: - result["diff"] = diff - return result # --------------------------------------------------------------------------- # Path confinement for read_file / write_file @@ -303,27 +205,6 @@ def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str: raise ValueError(f"path '{raw_path}' is outside the workspace ({workspace})") return resolved -# Bash + python tools used to share a single 60s timeout. That's -# enough for one-shot commands but starves real workloads (pip -# install, ffmpeg conversions, etc.) — and worse, the agent saw the -# 60s timeout and went silent because it had nothing to report. -# The new default is intentionally generous: long enough that real -# work isn't killed mid-flight, but bounded so a runaway process -# (infinite loop, hung connect, etc.) eventually frees the worker. -# The user can cancel sooner via the chat stop button — when the -# SSE stream is torn down, the asyncio task running the subprocess -# gets cancelled and the subprocess is killed by the finally block. -DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour -DEFAULT_PYTHON_TIMEOUT = 60 * 60 - -# How often to push a progress event while a long-running subprocess -# is still in flight. The frontend cares about "alive" more than -# "every-byte" — 2s is the sweet spot. -PROGRESS_INTERVAL_S = 2.0 -# Tail buffer size — we keep the most recent N lines of stdout + -# stderr so the progress event includes a "what's it doing right now" -# snippet without dragging the whole output along. -PROGRESS_TAIL_LINES = 12 def get_mcp_manager(): @@ -331,157 +212,23 @@ def get_mcp_manager(): return agent_tools.get_mcp_manager() -# Directories ignored by the code-nav tools' Python fallbacks so results aren't -# polluted by VCS internals / dependency trees / build caches. ripgrep already -# honours .gitignore; this is the parity floor for the no-rg path (and the -# explicit excludes passed to rg so it skips them even without a .gitignore). -_CODENAV_SKIP_DIRS = frozenset({ - ".git", ".hg", ".svn", "node_modules", "venv", ".venv", "__pycache__", - ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", - ".next", ".cache", "site-packages", ".idea", ".tox", -}) -# Per-tool result caps (keep tool output cheap + model-friendly). -_CODENAV_MAX_HITS = 200 -_CODENAV_MAX_LINE = 400 -def _resolve_search_root(raw_path: str, workspace: Optional[str] = None) -> str: +def _resolve_search_root(raw_path: str) -> str: """Resolve + confine a code-nav path (grep/glob/ls). - With a workspace set, the workspace folder is the root and supplied paths are - confined inside it (same policy as read_file). Without one, an empty path - defaults to the agent's primary root (project data dir) and a supplied path - is confined by the global allowlist + sensitive-file policy. + An empty path defaults to the agent's primary root (project data dir) and a + supplied path is confined by the global allowlist + sensitive-file policy. """ raw = (raw_path or "").strip() - if workspace: - if not raw: - return os.path.realpath(workspace) - return _resolve_tool_path_in_workspace(workspace, raw) if not raw: roots = _tool_path_roots() return roots[0] if roots else os.path.realpath(".") return _resolve_tool_path(raw) - -def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str: - if len(text) > limit: - return text[:limit] + f"\n... (truncated, {len(text)} chars total)" - return text - logger = logging.getLogger(__name__) -async def _run_subprocess_streaming( - proc: asyncio.subprocess.Process, - *, - timeout: float, - progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, -) -> Tuple[str, str, Optional[int], bool]: - """Run a subprocess to completion, streaming progress. - - Reads stdout + stderr line-by-line into ring buffers so a - periodic progress callback can emit a "tail" of recent output - without waiting for the full result. Returns - (full_stdout, full_stderr, return_code, timed_out). - - `timed_out=True` means the process was killed because it ran - past `timeout` seconds. Whatever output we'd buffered up to - that point is still returned. - """ - started = time.time() - stdout_full: list[str] = [] - stderr_full: list[str] = [] - tail = collections.deque(maxlen=PROGRESS_TAIL_LINES) - - async def _reader(stream, full_buf, label: str): - if stream is None: - return - while True: - line = await stream.readline() - if not line: - break - decoded = line.decode("utf-8", errors="replace").rstrip("\n") - full_buf.append(decoded) - if label == "err": - tail.append(f"! {decoded}") - else: - tail.append(decoded) - - async def _progress_emitter(): - # Skip the first push — many commands finish well under - # PROGRESS_INTERVAL_S and a 0-second "progress" event would - # just add UI churn. - await asyncio.sleep(PROGRESS_INTERVAL_S) - while True: - if progress_cb: - try: - await progress_cb({ - "elapsed_s": round(time.time() - started, 1), - "tail": "\n".join(list(tail)), - }) - except Exception: - # Progress is best-effort — never let a UI hiccup - # break the underlying subprocess. - pass - await asyncio.sleep(PROGRESS_INTERVAL_S) - - rd_out = asyncio.create_task(_reader(proc.stdout, stdout_full, "out")) - rd_err = asyncio.create_task(_reader(proc.stderr, stderr_full, "err")) - prog_task = asyncio.create_task(_progress_emitter()) if progress_cb else None - - timed_out = False - try: - await asyncio.wait_for(proc.wait(), timeout=timeout) - except asyncio.TimeoutError: - timed_out = True - try: - proc.kill() - except Exception: - pass - try: - await asyncio.wait_for(proc.wait(), timeout=2) - except Exception: - pass - except asyncio.CancelledError: - # User hit stop / SSE stream torn down. Kill the child so it - # doesn't keep running orphaned. Re-raise so the agent loop's - # cancellation propagates as the user expects. - try: - proc.kill() - except Exception: - pass - try: - await asyncio.wait_for(proc.wait(), timeout=2) - except Exception: - pass - # Best-effort: stop the readers + emitter before re-raising. - for t in (rd_out, rd_err): - t.cancel() - if prog_task is not None: - prog_task.cancel() - raise - finally: - if prog_task is not None and not prog_task.done(): - prog_task.cancel() - try: - await prog_task - except (asyncio.CancelledError, Exception): - pass - # Wait for readers to finish draining the pipes. - for t in (rd_out, rd_err): - try: - await asyncio.wait_for(t, timeout=1) - except Exception: - pass - - return ( - "\n".join(stdout_full), - "\n".join(stderr_full), - proc.returncode, - timed_out, - ) - _ADMIN_TOOLS = { "app_api", "manage_endpoints", @@ -574,12 +321,11 @@ async def _call_mcp_tool( tool: str, content: str, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, - workspace: Optional[str] = None, ) -> Dict: """Route a legacy tool call through the MCP manager, with direct fallbacks.""" mcp = get_mcp_manager() if not mcp: - return await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1} + return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1} server_id, tool_name = _MCP_TOOL_MAP[tool] qualified = f"mcp__{server_id}__{tool_name}" @@ -588,7 +334,7 @@ async def _call_mcp_tool( # If MCP server not connected, try direct fallback if isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""): - fallback = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) + fallback = await _direct_fallback(tool, content, progress_cb=progress_cb) if fallback: return fallback @@ -648,23 +394,6 @@ async def _direct_fallback( progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, workspace: Optional[str] = None, ) -> Optional[Dict]: - """In-process execution path for the eight tools that used to live as - stdio MCP servers under mcp_servers/. Those servers were deleted in - favor of native execution; this function is now the canonical path, - not a fallback. The name is kept for backwards compat with callers. - - `progress_cb` is called periodically while bash/python subprocesses - are still running, with `{elapsed_s, tail}` payloads. Other tools - ignore it. - """ - # Inherit env + force a sane terminal so subprocesses that touch - # terminfo (anything calling `clear`, `tput`, `os.system("clear")`, - # or scripts that probe $TERM) don't spam "TERM environment variable - # not set" errors. The agent's bash/python tool calls run with PIPE - # stdin/stdout (no real TTY), so curses/termios still won't work — - # but at least non-interactive code with incidental TERM lookups - # stops failing. COLUMNS/LINES give terminal-width-aware tools (less, - # rich, etc.) reasonable defaults instead of 0×0. _subproc_env = { **os.environ, "TERM": "xterm-256color", @@ -674,452 +403,36 @@ async def _direct_fallback( } try: - if tool == "bash": - proc = await asyncio.create_subprocess_shell( - content, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_subproc_env, - cwd=workspace or _AGENT_WORKDIR, - ) - stdout, stderr, rc, timed_out = await _run_subprocess_streaming( - proc, - timeout=DEFAULT_BASH_TIMEOUT, - progress_cb=progress_cb, - ) - if timed_out: - return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)} - output = stdout.rstrip() - err = stderr.rstrip() - if err: - output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err - output = _truncate(output, MAX_OUTPUT_CHARS) - return {"output": output or "(no output)", "exit_code": rc or 0} + ctx = { + "progress_cb": progress_cb, + "workspace": workspace, + "subproc_env": _subproc_env, + } - if tool == "python": - # Run user code in a subprocess so an infinite loop or crash - # can't take the whole server down. -I = isolated mode (skip - # user site, no PYTHONPATH inheritance) for hygiene. - proc = await asyncio.create_subprocess_exec( - # Use the running interpreter — there is no `python3.exe` on - # Windows, which made the agent's `python` tool fail there. - (sys.executable or "python"), "-I", "-c", content, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_subproc_env, - cwd=workspace or _AGENT_WORKDIR, - ) - stdout, stderr, rc, timed_out = await _run_subprocess_streaming( - proc, - timeout=DEFAULT_PYTHON_TIMEOUT, - progress_cb=progress_cb, - ) - if timed_out: - return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)} - output = stdout.rstrip() - err = stderr.rstrip() - if err: - output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err - output = _truncate(output, MAX_OUTPUT_CHARS) - return {"output": output or "(no output)", "exit_code": rc or 0} + from src.agent_tools import TOOL_HANDLERS + if tool in TOOL_HANDLERS: + return await TOOL_HANDLERS[tool](content, ctx) - if tool == "read_file": - # Args: plain path on line 1 (back-compat) OR JSON - # {path, offset?, limit?} where offset/limit are a 1-based line range. - raw_path, offset, limit = content.split("\n", 1)[0].strip(), 0, 0 - _stripped = content.strip() - if _stripped.startswith("{"): - try: - _a = json.loads(_stripped) - raw_path = str(_a.get("path", "")).strip() - offset = int(_a.get("offset") or 0) - limit = int(_a.get("limit") or 0) - except (json.JSONDecodeError, TypeError, ValueError): - pass - try: - path = (_resolve_tool_path_in_workspace(workspace, raw_path) - if workspace else _resolve_tool_path(raw_path)) - except ValueError as e: - return {"error": f"read_file: {e}", "exit_code": 1} - try: - # Run blocking read in a thread to keep the loop responsive. - def _read(): - if offset > 0 or limit > 0: - # Line-range read: slice [offset, offset+limit). - start = max(offset, 1) - out, n, budget = [], 0, MAX_READ_CHARS - with open(path, "r", encoding="utf-8", errors="replace") as f: - for i, line in enumerate(f, 1): - if i < start: - continue - if limit > 0 and n >= limit: - break - out.append(line) - n += 1 - budget -= len(line) - if budget <= 0: - out.append(f"\n... [truncated at {MAX_READ_CHARS} chars]") - break - return "".join(out) - with open(path, "r", encoding="utf-8", errors="replace") as f: - return f.read(MAX_READ_CHARS + 1) - data = await asyncio.to_thread(_read) - except FileNotFoundError: - return {"error": f"read_file: {path}: not found", "exit_code": 1} - except PermissionError: - return {"error": f"read_file: {path}: permission denied", "exit_code": 1} - except IsADirectoryError: - return {"error": f"read_file: {path}: is a directory (use ls)", "exit_code": 1} - except OSError as e: - return {"error": f"read_file: {path}: {e}", "exit_code": 1} - if not (offset > 0 or limit > 0) and len(data) > MAX_READ_CHARS: - data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]" - return {"output": data, "exit_code": 0} - - if tool == "write_file": - lines = content.split("\n", 1) - raw_path = lines[0].strip() - body = lines[1] if len(lines) > 1 else "" - try: - path = (_resolve_tool_path_in_workspace(workspace, raw_path) - if workspace else _resolve_tool_path(raw_path)) - except ValueError as e: - return {"error": f"write_file: {e}", "exit_code": 1} - try: - def _write(): - # Capture prior content (best-effort, text) so we can show a - # before/after diff. Missing/binary file → treat as empty. - old = "" - try: - with open(path, "r", encoding="utf-8") as f: - old = f.read() - except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError, OSError): - old = "" - d = os.path.dirname(path) - if d: - os.makedirs(d, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - f.write(body) - return old, len(body) - old_content, size = await asyncio.to_thread(_write) - except PermissionError: - return {"error": f"write_file: {path}: permission denied", "exit_code": 1} - except OSError as e: - return {"error": f"write_file: {path}: {e}", "exit_code": 1} - diff = _unified_diff(old_content, body, path) - result = {"output": f"Wrote {size} bytes to {path}", "exit_code": 0} - if diff: - result["diff"] = diff - return result - - if tool == "grep": - # Args (JSON): {pattern, path?, glob?, ignore_case?, max_results?}. - # Bare string → treated as the pattern. - args: Dict[str, Any] = {} - _s = (content or "").strip() - if _s.startswith("{"): - try: - args = json.loads(_s) - except json.JSONDecodeError: - args = {} - else: - args = {"pattern": _s} - pattern = str(args.get("pattern", "")).strip() - if not pattern: - return {"error": "grep: pattern is required", "exit_code": 1} - ignore_case = bool(args.get("ignore_case")) - glob_pat = str(args.get("glob", "") or "").strip() - try: - max_hits = int(args.get("max_results") or _CODENAV_MAX_HITS) - except (TypeError, ValueError): - max_hits = _CODENAV_MAX_HITS - max_hits = max(1, min(max_hits, _CODENAV_MAX_HITS)) - try: - root = _resolve_search_root(str(args.get("path", "")), workspace) - except ValueError as e: - return {"error": f"grep: {e}", "exit_code": 1} - - def _grep(): - import re as _re - import shutil - rg = shutil.which("rg") - if rg: - cmd = [rg, "--line-number", "--no-heading", "--color=never", - "--max-count", str(max_hits)] - if ignore_case: - cmd.append("--ignore-case") - if glob_pat: - cmd += ["--glob", glob_pat] - # Exclude junk dirs even when the tree has no .gitignore, so - # results match the Python fallback's skip set. - for _d in _CODENAV_SKIP_DIRS: - cmd += ["--glob", f"!**/{_d}/**"] - cmd += ["--regexp", pattern, root] - try: - import subprocess - p = subprocess.run(cmd, capture_output=True, text=True, timeout=20) - lines = [ln for ln in (p.stdout or "").splitlines() if ln][:max_hits] - return lines, None - except subprocess.TimeoutExpired: - return None, "grep: timed out" - except Exception as _e: - return None, f"grep: {_e}" - # Python fallback (no ripgrep): walk + regex. - try: - rx = _re.compile(pattern, _re.IGNORECASE if ignore_case else 0) - except _re.error as _e: - return None, f"grep: bad pattern: {_e}" - import fnmatch - hits = [] - if os.path.isfile(root): - file_iter = [root] - else: - file_iter = [] - for dp, dns, fns in os.walk(root): - dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS] - for fn in fns: - if glob_pat and not fnmatch.fnmatch(fn, glob_pat): - continue - file_iter.append(os.path.join(dp, fn)) - for fp in file_iter: - if len(hits) >= max_hits: - break - try: - with open(fp, "r", encoding="utf-8", errors="strict") as f: - for i, line in enumerate(f, 1): - if rx.search(line): - hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}") - if len(hits) >= max_hits: - break - except (UnicodeDecodeError, OSError): - continue # skip binary / unreadable - return hits, None - - lines, err = await asyncio.to_thread(_grep) - if err: - return {"error": err, "exit_code": 1} - if not lines: - return {"output": f"No matches for {pattern!r} under {root}", "exit_code": 0} - out = "\n".join(ln[:_CODENAV_MAX_LINE] for ln in lines) - if len(lines) >= max_hits: - out += f"\n... [capped at {max_hits} matches]" - return {"output": _truncate(out), "exit_code": 0} - - if tool == "glob": - args = {} - _s = (content or "").strip() - if _s.startswith("{"): - try: - args = json.loads(_s) - except json.JSONDecodeError: - args = {} - else: - args = {"pattern": _s} - pattern = str(args.get("pattern", "")).strip() - if not pattern: - return {"error": "glob: pattern is required", "exit_code": 1} - try: - root = _resolve_search_root(str(args.get("path", "")), workspace) - except ValueError as e: - return {"error": f"glob: {e}", "exit_code": 1} - - def _glob(): - from pathlib import Path - base = Path(root) - if not base.is_dir(): - return None, f"glob: {root}: not a directory" - matched = [] - try: - for p in base.rglob(pattern): - if set(p.relative_to(base).parts) & _CODENAV_SKIP_DIRS: - continue - try: - mtime = p.stat().st_mtime - except OSError: - mtime = 0 - matched.append((mtime, str(p))) - if len(matched) > _CODENAV_MAX_HITS * 5: - break - except (OSError, ValueError) as _e: - return None, f"glob: {_e}" - matched.sort(key=lambda t: t[0], reverse=True) # newest first - return [pth for _, pth in matched[:_CODENAV_MAX_HITS]], None - - paths, err = await asyncio.to_thread(_glob) - if err: - return {"error": err, "exit_code": 1} - if not paths: - return {"output": f"No files matching {pattern!r} under {root}", "exit_code": 0} - out = "\n".join(paths) - if len(paths) >= _CODENAV_MAX_HITS: - out += f"\n... [capped at {_CODENAV_MAX_HITS} files]" - return {"output": _truncate(out), "exit_code": 0} - - if tool == "ls": - raw_path = "" - _s = (content or "").strip() - if _s.startswith("{"): - try: - raw_path = str(json.loads(_s).get("path", "")).strip() - except json.JSONDecodeError: - raw_path = "" - else: - raw_path = _s.split("\n", 1)[0].strip() - try: - root = _resolve_search_root(raw_path, workspace) - except ValueError as e: - return {"error": f"ls: {e}", "exit_code": 1} - - def _ls(): - if not os.path.isdir(root): - return None, f"ls: {root}: not a directory" - rows = [] - try: - with os.scandir(root) as it: - for entry in it: - if entry.name.startswith("."): - continue - try: - is_dir = entry.is_dir(follow_symlinks=False) - size = entry.stat(follow_symlinks=False).st_size if not is_dir else 0 - except OSError: - continue - rows.append((is_dir, entry.name, size)) - except (PermissionError, OSError) as _e: - return None, f"ls: {_e}" - rows.sort(key=lambda r: (not r[0], r[1].lower())) # dirs first, then name - lines = [f"{root}:"] - for is_dir, name, size in rows[:_CODENAV_MAX_HITS]: - lines.append(f" {name}/" if is_dir else f" {name} ({size} B)") - if len(rows) > _CODENAV_MAX_HITS: - lines.append(f" ... [{len(rows) - _CODENAV_MAX_HITS} more]") - if not rows: - lines.append(" (empty)") - return "\n".join(lines), None - - out, err = await asyncio.to_thread(_ls) - if err: - return {"error": err, "exit_code": 1} - return {"output": _truncate(out), "exit_code": 0} - - if tool == "web_search": - from src.search import comprehensive_web_search - raw = content.strip() - query = raw - time_filter = None - max_pages = 5 - # Allow JSON-shaped args: {"query": "...", "time_filter": "day", "max_pages": 7} - if raw.startswith("{"): - try: - parsed = json.loads(raw) - if isinstance(parsed, dict) and "query" in parsed: - query = str(parsed.get("query", "")).strip() - tf = parsed.get("time_filter") or parsed.get("freshness") - if isinstance(tf, str) and tf.lower() in ("day", "week", "month", "year"): - time_filter = tf.lower() - mp = parsed.get("max_pages") - if isinstance(mp, int) and 1 <= mp <= 10: - max_pages = mp - except json.JSONDecodeError: - pass - if not query: - query = raw.split("\n")[0].strip() - # Auto-detect freshness from query phrasing when not explicit - if time_filter is None: - q_lc = query.lower() - if any(kw in q_lc for kw in ("today", "latest", "breaking", "this morning", "right now", "currently")): - time_filter = "day" - elif any(kw in q_lc for kw in ("this week", "past week", "recent news", "last few days")): - time_filter = "week" - elif any(kw in q_lc for kw in ("this month", "past month")): - time_filter = "month" - elif " news" in q_lc or q_lc.startswith("news ") or q_lc.endswith(" news"): - time_filter = "week" - loop = asyncio.get_running_loop() - text, sources = await asyncio.wait_for( - loop.run_in_executor( - None, - lambda: comprehensive_web_search( - query, - max_pages=max_pages, - time_filter=time_filter, - return_sources=True, - ), - ), - timeout=30, - ) - output = text[:MAX_OUTPUT_CHARS] if len(text) > MAX_OUTPUT_CHARS else text - if sources: - output += "\n\n" - return {"output": output, "exit_code": 0} - - if tool == "web_fetch": - # Lightweight single-URL fetch. Wraps the SSRF-safe fetcher used - # by deep research, so private/loopback/metadata addresses are - # already blocked there. - from src.search.content import fetch_webpage_content - raw = content.strip() - url = "" - # Accept either a JSON arg ({"url": "..."}) or a plain URL/domain. - if raw.startswith("{"): - try: - parsed = json.loads(raw) - if isinstance(parsed, dict): - url = str(parsed.get("url") or "").strip() - except json.JSONDecodeError: - url = "" - if not url: - # Non-JSON (or JSON without a usable url): take the first line - # only, so a URL followed by commentary still parses. - url = raw.split("\n")[0].strip() - # Reject anything that isn't a single bare URL/domain token. - if not url or url.startswith("{") or any(c in url for c in (" ", "\t", "\n")): - return {"error": "web_fetch: provide a single URL or domain, e.g. example.com", "exit_code": 1} - low = url.lower() - if "://" in low and not low.startswith(("http://", "https://")): - return {"error": f"web_fetch: unsupported URL scheme (only http/https): {url[:80]}", "exit_code": 1} - # Accept bare domains like "example.com" by defaulting to https. - if not low.startswith(("http://", "https://")): - url = "https://" + url - loop = asyncio.get_running_loop() - try: - result = await asyncio.wait_for( - loop.run_in_executor(None, lambda: fetch_webpage_content(url, timeout=10)), - timeout=30, - ) - except asyncio.TimeoutError: - return {"error": f"web_fetch: timed out fetching {url}", "exit_code": 1} - except Exception as e: - # Direct URL fetches can hit bot protection / auth walls - # (e.g. eBay 403). Treat that as a tool failure the model can - # reason around, not an uncaught chat-stream 500. - return {"error": f"web_fetch: {url}: {e}", "exit_code": 1} - err = result.get("error") - text = (result.get("content") or "").strip() - title = result.get("title") or "" - - if not text: - if err: - return {"error": f"web_fetch: {url}: {err}", "exit_code": 1} - # No extractable text: non-HTML body, or a pure client-rendered - # shell. The agent can fall back to the builtin_browser tool. - return {"error": f"web_fetch: {url}: no readable text content (not HTML, or the page needs JS/login)", "exit_code": 1} - - header = (f"# {title}\n" if title else "") + f"Source: {url}\n\n" - output = header + text - if len(output) > MAX_OUTPUT_CHARS: - output = output[:MAX_OUTPUT_CHARS] + "\n\n[...truncated]" - return {"output": output, "exit_code": 0} - - # manage_memory / generate_image still live as MCP servers - # (mcp_servers/{memory,image_gen}_server.py); the MCP path above - # handles them. except Exception as e: return {"error": f"{tool}: {e}", "exit_code": 1} return None +async def _document_tool_dispatch( + tool: str, + content: str, + session_id: Optional[str] = None, + owner: Optional[str] = None, +) -> Optional[Dict]: + """Route a document tool through TOOL_HANDLERS with the right ctx shape.""" + from src.agent_tools import TOOL_HANDLERS + ctx = {"session_id": session_id, "owner": owner} + if tool in TOOL_HANDLERS: + return await TOOL_HANDLERS[tool](content, ctx) + return None + + # --------------------------------------------------------------------------- # Dispatcher # --------------------------------------------------------------------------- @@ -1131,6 +444,7 @@ async def execute_tool_block( owner: Optional[str] = None, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, workspace: Optional[str] = None, + tool_policy: Optional[Any] = None, ) -> Tuple[str, Dict]: """Execute a single tool block. Returns (description, result_dict). @@ -1139,11 +453,10 @@ async def execute_tool_block( events while the command is in flight. Ignored by other tools. """ from src.tool_implementations import ( - do_create_document, do_update_document, do_edit_document, - do_suggest_document, do_search_chats, do_manage_tasks, + do_search_chats, do_manage_tasks, do_manage_skills, do_api_call, do_manage_endpoints, do_manage_mcp, do_manage_webhooks, do_manage_tokens, - do_manage_documents, do_manage_settings, do_manage_notes, + do_manage_settings, do_manage_notes, do_manage_calendar, do_download_model, do_serve_model, do_list_served_models, do_stop_served_model, do_tail_serve_output, @@ -1192,6 +505,15 @@ async def execute_tool_block( logger.info(f"Tool blocked by user: {tool}") return desc, result + if tool_policy and tool_policy.blocks(tool): + desc = f"{tool}: BLOCKED" + result = { + "error": f"Execution of tool '{tool}' is forbade by the active guide-only policy.", + "exit_code": 1, + } + logger.warning("Tool policy blocked tool=%s", tool) + return desc, result + if tool in _ADMIN_TOOLS and not _owner_is_admin(owner): desc = f"{tool}: BLOCKED" result = {"error": f"Tool '{tool}' requires an admin user.", "exit_code": 1} @@ -1299,7 +621,7 @@ async def execute_tool_block( _is_bg, _bg_cmd = _split_bg_marker(content) if _is_bg and _bg_cmd: from src import bg_jobs - rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=workspace or _AGENT_WORKDIR) + rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=_AGENT_WORKDIR) short = _bg_cmd.strip().split(chr(10))[0][:80] desc = f"bash (background): {short}" result = { @@ -1321,27 +643,20 @@ async def execute_tool_block( if tool in _MCP_TOOL_MAP: first_line = content.split(chr(10))[0][:80] desc = f"{tool}: {first_line}" - result = await _call_mcp_tool(tool, content, progress_cb=progress_cb, workspace=workspace) + result = await _call_mcp_tool(tool, content, progress_cb=progress_cb) elif tool in ("grep", "glob", "ls"): # Code-navigation tools — no MCP server; run the direct implementation. - # Confined to the workspace when one is set (same policy as read_file). first_line = content.split(chr(10))[0][:80] desc = f"{tool}: {first_line}" - result = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) \ + result = await _direct_fallback(tool, content, progress_cb=progress_cb) \ or {"error": f"{tool}: execution failed", "exit_code": 1} - elif tool == "create_document": - title = content.split("\n")[0].strip()[:60] - desc = f"create_document: {title}" - result = await do_create_document(content, session_id=session_id, owner=owner) - elif tool == "update_document": - desc = f"update_document: {content.split(chr(10))[0][:60]}" - result = await do_update_document(content, owner=owner) - elif tool == "edit_document": - result = await do_edit_document(content, owner=owner) - desc = f"edit_document: {result.get('title', '')}" - elif tool == "suggest_document": - result = await do_suggest_document(content, owner=owner) - desc = f"suggest_document: {result.get('count', 0)} suggestions" + elif tool in ("create_document", "update_document", "edit_document", + "suggest_document", "manage_documents"): + desc = f"{tool}: {content.split(chr(10))[0][:80]}" + result = await _document_tool_dispatch(tool, content, session_id, owner) \ + or {"error": f"{tool}: execution failed", "exit_code": 1} + if tool in ("edit_document", "suggest_document") and "title" in (result or {}): + desc = f"{tool}: {result.get('title', '')}" elif tool == "search_chats": query = content.split("\n")[0].strip() desc = f"search_chats: {query[:80]}" @@ -1374,9 +689,6 @@ async def execute_tool_block( elif tool == "manage_tokens": desc = "manage_tokens" result = await do_manage_tokens(content, owner=owner) - elif tool == "manage_documents": - desc = "manage_documents" - result = await do_manage_documents(content, owner=owner) elif tool == "manage_settings": desc = "manage_settings" result = await do_manage_settings(content, owner=owner) @@ -1432,7 +744,7 @@ async def execute_tool_block( desc = "edit_image" result = await do_edit_image(content, owner=owner) elif tool == "edit_file": - result = await _do_edit_file(content, workspace=workspace) + result = await _direct_fallback(tool, content, workspace=workspace) or {"error": "edit failed", "exit_code": 1} desc = result.get("output") or result.get("error") or "edit_file" elif tool == "trigger_research": desc = "trigger_research" diff --git a/src/tool_implementations.py b/src/tool_implementations.py index 62ac23a08..27c05f139 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -12,18 +12,9 @@ import os import re from typing import Any, Dict, List, Optional -from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS - - -def get_mcp_manager(): - from src import agent_tools - return agent_tools.get_mcp_manager() - - -def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str: - if len(text) > limit: - return text[:limit] + f"\n... (truncated, {len(text)} chars total)" - return text +from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE +from src.tool_utils import get_mcp_manager +from core.constants import internal_api_base logger = logging.getLogger(__name__) @@ -63,486 +54,6 @@ def _parse_tool_args(content): args = args["body"] return args - -# --------------------------------------------------------------------------- -# Active document state -# --------------------------------------------------------------------------- - -_active_document_id: Optional[str] = None -_active_model: Optional[str] = None - - -def set_active_document(doc_id: Optional[str]): - """Set the active document ID for document tool execution.""" - global _active_document_id - _active_document_id = doc_id - - -def set_active_model(model: Optional[str]): - """Set the current model name for version summaries.""" - global _active_model - _active_model = model - - -def get_active_document(): - return _active_document_id - - -def clear_active_document(doc_id: Optional[str] = None) -> bool: - """Clear the in-memory active-document pointer. - - With ``doc_id`` given, only clears when it matches the current pointer, so a - different active document is left untouched. Returns True if it was cleared. - - Called when a document is detached from its session or deleted (its tab is - closed): without this, the stale pointer makes the last-resort doc-injection - path re-surface a closed document in a later, unrelated chat — even one whose - session no longer matches — because an unlinked doc has session_id NULL (#1160). - """ - global _active_document_id - if doc_id is None or _active_document_id == doc_id: - _active_document_id = None - return True - return False - - -def _owned_document_query(query, Document, owner: Optional[str]): - if owner is None: - # A bare Python `False` is not a valid SQL expression — SQLAlchemy 1.4 - # deprecates it and 2.0 raises ArgumentError. Use the SQL `false()` - # literal to return zero rows for an unscoped (owner-less) query. - from sqlalchemy import false - return query.filter(false()) - return query.filter(Document.owner == owner) - - -def _get_owned_document(db, Document, doc_id: str, owner: Optional[str], active_only: bool = False): - q = db.query(Document).filter(Document.id == doc_id) - if active_only: - q = q.filter(Document.is_active == True) - q = _owned_document_query(q, Document, owner) - return q.first() - - -def _most_recent_owned_document(db, Document, owner: Optional[str], active_only: bool = False): - q = db.query(Document) - if active_only: - q = q.filter(Document.is_active == True) - q = _owned_document_query(q, Document, owner) - return q.order_by(Document.updated_at.desc()).first() - - -# --------------------------------------------------------------------------- -# Document tools — create/update/edit/suggest living documents -# --------------------------------------------------------------------------- - -def _sniff_doc_language(text: str) -> str: - """Best-effort detect a document's language from its content when the model - didn't specify one. Defaults to 'markdown' (prose). Recognizes the common - markup/code types the editor supports so e.g. an SVG isn't saved as markdown.""" - import json as _json, re as _re2 - s = (text or "").strip() - if not s: - return "markdown" - head = s[:600] - hl = head.lower() - if _looks_like_email_document(s): - return "email" - # Markup (unambiguous) - if " bool: - import re as _re - title_l = (title or "").strip().lower() - if title_l in {"new email", "new mail", "new message"}: - return True - s = (text or "").lstrip() - if "\n---\n" in s and _re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s): - return True - return bool(_re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s)) - - -def _coerce_email_document_content(existing: str, incoming: str) -> str: - """Keep email docs in the To/Subject/---/body shape even if a model writes - only the body or dumps header labels without the separator.""" - import re as _re - old = existing or "" - new = (incoming or "").strip() - if "\n---\n" in new: - return new - header = old.split("\n---\n", 1)[0] if "\n---\n" in old else "To: \nSubject: " - if _looks_like_email_document(new): - lines = new.splitlines() - last_header_idx = -1 - header_re = _re.compile(r"^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Attachments):", _re.I) - for i, line in enumerate(lines): - if header_re.match(line.strip()): - last_header_idx = i - body_lines = lines[last_header_idx + 1:] if last_header_idx >= 0 else lines - while body_lines and not body_lines[0].strip(): - body_lines.pop(0) - body = "\n".join(body_lines).strip() - else: - body = new - return header.rstrip() + "\n---\n" + body - - -async def do_create_document(content_block: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict: - """Create a new document. Supports two formats: - 1) Line-based: line 1 = title, line 2 (optional) = language, rest = content - 2) XML-like tags: ......... - Some models mix them — strip any XML-style tags and fall back to line parsing.""" - import uuid, re as _re - from src.database import SessionLocal, Document, DocumentVersion, Session as DbSession - - raw = content_block or "" - - # Known languages the editor understands (match the - @@ -1499,21 +1479,7 @@
-
-

Agent

-
Controls for the agent tool loop.
-
-
- - -
-
- - -
-
-
-
+ + +
+ + + - - -
-
@@ -2108,6 +2081,8 @@ + + @@ -2117,28 +2092,54 @@ + + + -
- + + +
- + - - + + + + - +
+
-

Added Models (Endpoints)

+

Added Models (Endpoints) + + + +

Manage the endpoints you've added.
@@ -2169,10 +2170,45 @@
+
+

API Tokens

+
Bearer tokens for external integrations (scripts, Codex, headless agent runs). Token value shown ONCE on create — copy it then.
+
+
+ + + +
+
+ +