Compare commits

...
Author SHA1 Message Date
RaresKeY 98c1fd50a0 fix(auth): require owner for cached provider credentials 2026-08-31 00:09:19 +00:00
RaresKeY aee6655fbd security: fail closed on bearer endpoint credentials 2026-08-30 22:27:35 +00:00
RaresKeY c473240131 security: bind bearer sessions to endpoint provenance 2026-08-30 20:03:06 +00:00
RaresKeY a09b0b5722 fix(security): close bearer alias boundaries 2026-08-30 17:52:35 +00:00
RaresKeY 0b32ecea50 fix(security): restore scoped bearer compatibility 2026-08-30 15:35:17 +00:00
RaresKeY ddeeb10f59 fix(security): block live bearer model recovery 2026-08-30 12:58:09 +00:00
RaresKeY e1da1264dc fix(security): close bearer auxiliary boundaries 2026-08-29 12:27:14 +00:00
RaresKeY 31c7249ef3 fix(security): close bearer side-effect boundaries 2026-08-29 00:19:59 +00:00
RaresKeY 50c8675a21 fix(security): isolate bearer authorization paths 2026-08-28 21:25:23 +00:00
RaresKeY 9150a453b4 fix(security): close API token agent authorization gaps 2026-08-28 18:07:40 +00:00
RaresKeY aeff329c05 fix(security): constrain API token chat authority 2026-08-28 13:25:13 +00:00
RaresKeY c9dd68d890 refactor(docs): separate Pages site source (#6176)
* refactor(docs): separate Pages site source

* fix(docs): preserve published guide pages

* fix(ci): run asset ownership tests for site changes

* fix(docs): track future website media

* fix(ci): let Pages deployments finish

* build(deps): update Pages checkout action

* fix(ci): follow moved setup guide

* fix(docs): repair published setup guide

* fix(docs): retarget preview encoder
2026-08-27 10:20:36 +02:00
RaresKeYandStressTestor 7026cf40b5 docs: bootstrap specs ground truth (#5794)
* docs(specs): restore bootstrap after dev rewrite

* docs(specs): remove runtime inventory snapshot

* docs(specs): reconcile current dev truth

* docs(specs): document scheduled task actions as an owner-attribution source

Owner Attribution covered cookie, bearer-token and internal-loopback
requests. Scheduled task actions are a fourth source and behave
differently: _execute_action passes owner=task.owner off the stored
ScheduledTask row, so no request and no resolved principal are in
flight, and route-level require_user() never runs.

Webhook triggers are the sharp case. They are unauthenticated by
design with the token as the only credential and execute under the
stored task.owner.

Paths cite routes/task/task_routes.py, the canonical location after
the task subpackage move (#6081); routes/task_routes.py on current dev
is the backward-compat shim.

* docs(specs): add chained tasks to the trigger list, refresh dev stamp

Review feedback from RaresKeY on the previous commit.

"Every trigger path" was too broad: success-chained tasks are another
path into _execute_action. Added them with their own citation, and
noted that chaining additionally requires the target task to share
task.owner and rejects cycles, which is stricter than the trigger-side
checks. Softened the lead-in to "these trigger paths".

Line 56 still pointed at routes/task_routes.py for webhook credential
validation. That path is the backward-compat shim on current dev after
the task subpackage move (#6081); repointed to the canonical
routes/task/task_routes.py.

Stamp moved to dev@2a6b09b. Inspection backing that bump was scoped:
every file path cited in this spec was mechanically checked to resolve
on 2a6b09b, and every file:line in the Owner Attribution additions was
read against it. Behavioral claims elsewhere in the file were not
re-audited.

* docs(specs): correct SECURE_COOKIES description to match current behavior

Third of the stale details RaresKeY enumerated. The cookie section
described SECURE_COOKIES as purely opt-in, which stopped being true.

_secure_cookie() (routes/auth_routes.py:89) treats an explicit true or
false as authoritative and derives the Secure attribute from the
request otherwise, including when the variable is unset and when
docker-compose injects it present-but-empty. Either the connection
scheme or the first X-Forwarded-Proto hop being https is enough.

* docs(specs): refresh current dev truth

---------

Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
2026-08-25 14:18:44 +02:00
dependabot[bot] bc7514fa3e build(deps): bump the actions group with 11 updates (#6141)
Bumps the actions group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `7.0.0` | `7.0.1` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `7.0.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6.4.0` | `7.0.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.36.2` | `4.37.7` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.36.2` | `4.37.7` |
| [hadolint/hadolint-action](https://github.com/hadolint/hadolint-action) | `3.3.0` | `3.4.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.1.0` | `4.3.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.2.0` | `7.3.0` |
| [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.36.2` | `4.37.7` |
| [docker/login-action](https://github.com/docker/login-action) | `4.2.0` | `4.6.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `6.1.0` | `6.2.0` |


Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `actions/setup-python` from 6.2.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...5fda3b95a4ea91299a34e894583c3862153e4b97)

Updates `actions/setup-node` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020)

Updates `github/codeql-action/init` from 4.36.2 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.36.2 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `hadolint/hadolint-action` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/hadolint/hadolint-action/releases)
- [Commits](https://github.com/hadolint/hadolint-action/compare/2332a7b74a6de0dda2e2221d575162eba76ba5e5...2a66e89f53d0771bb131a7fa31f3136336094aa6)

Updates `docker/setup-buildx-action` from 4.1.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5...37fe631027851001ddb9b187196cc803df7f5f0e)

Updates `docker/build-push-action` from 7.2.0 to 7.3.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/f9f3042f7e2789586610d6e8b85c8f03e5195baf...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a)

Updates `github/codeql-action/upload-sarif` from 4.36.2 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `docker/login-action` from 4.2.0 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...dbcb813823bdd20940b903addbd779551569679f)

Updates `docker/metadata-action` from 6.1.0 to 6.2.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9...dc802804100637a589fabce1cb79ff13a1411302)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: hadolint/hadolint-action
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 14:06:31 +02:00
dependabot[bot] e5ab632270 build(deps-dev): bump @antithesishq/bombadil (#6026)
Bumps the npm group with 1 update in the / directory: [@antithesishq/bombadil](https://github.com/antithesishq/bombadil).


Updates `@antithesishq/bombadil` from 0.6.1 to 0.7.0
- [Release notes](https://github.com/antithesishq/bombadil/releases)
- [Changelog](https://github.com/antithesishq/bombadil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/antithesishq/bombadil/compare/v0.6.1...v0.7.0)

---
updated-dependencies:
- dependency-name: "@antithesishq/bombadil"
  dependency-version: 0.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 14:03:11 +02:00
RaresKeY e71f8ceb65 chore(release): align dev version with 1.0.3 (#6168)
Keep dev version metadata aligned with the current hotfix release while the rolling branch continues toward 1.1.0.

Evidence: the canonical APP_VERSION imports as 1.0.3 and the diff check is clean. This commit changes version metadata only; it does not tag or publish a release.
2026-08-25 10:26:18 +01:00
161 changed files with 12477 additions and 1076 deletions
+2
View File
@@ -30,6 +30,8 @@ secrets.env~
.idea/
dev-docs/
docs/
website/
assets/branding/
*.md
*.db
*.sqlite
+1 -1
View File
@@ -6,4 +6,4 @@
# A per-area ownership map (security/auth, CI, frontend, agent internals, with
# multiple named owners per line) is being worked out in issue #593; once
# agreed it replaces this file. Until then, required reviews and the security
# CI gate (docs/security-ci.md) remain in force via branch protection.
# CI gate (website/security-ci.md) remain in force via branch protection.
+11 -10
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -73,10 +73,10 @@ jobs:
name: Python syntax (compileall)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps.
@@ -86,10 +86,10 @@ jobs:
name: JS syntax (node --check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"
# Syntax-check our own JS (skip vendored libs in static/lib).
@@ -105,12 +105,12 @@ jobs:
runs-on: ubuntu-latest
# Make Python test validation authoritative for the configured scope.
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
# Detect whether this PR only touches documentation files.
# Detect whether this PR only touches repository prose outside the Pages site.
# If so, skip the expensive pytest run while still reporting a passing check.
- name: Check for docs-only changes
id: docs-check
@@ -122,9 +122,10 @@ jobs:
BASE="${{ github.event.before }}"
HEAD="${{ github.sha }}"
fi
# List all changed files; if every file matches docs/markdown patterns, skip pytest.
# Keep website/ and assets/branding/ out of this bypass: pytest owns
# regression guards for their published-file and orphan-asset contracts.
changed=$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || git diff --name-only HEAD~1 HEAD)
non_docs=$(echo "$changed" | grep -Ev '^(docs/|.*\.md$|\.github/[^/]+\.md$)' || true)
non_docs=$(echo "$changed" | grep -Ev '^(docs/|[^/]+\.md$|\.github/[^/]+\.md$)' || true)
if [ -z "$non_docs" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
echo "Docs-only change detected — skipping pytest."
@@ -132,7 +133,7 @@ jobs:
echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true'
with:
python-version: "3.11"
+3 -3
View File
@@ -27,15 +27,15 @@ jobs:
language: [actions, javascript-typescript, python]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{ matrix.language }}"
+2 -2
View File
@@ -37,12 +37,12 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Lint Dockerfile
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
with:
dockerfile: Dockerfile
# DL3008: pinning apt package versions is impractical on a -slim base
+11 -7
View File
@@ -23,12 +23,16 @@ on:
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
workflow_dispatch:
@@ -52,17 +56,17 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# Build without pushing so a broken Dockerfile is caught here, and the
# exact image we ship is what gets scanned.
- name: Build image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: false
@@ -93,15 +97,15 @@ jobs:
security-events: write # upload SARIF to the Security tab
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: false
@@ -119,7 +123,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-results.sarif
category: trivy-image
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -55,12 +55,12 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
+50
View File
@@ -0,0 +1,50 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/deploy-pages.yml'
workflow_dispatch:
permissions: {}
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Package static site
runs-on: ubuntu-latest
permissions:
contents: read
pages: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
with:
source: website
destination: _site
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site
deploy:
name: Deploy static site
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+10 -8
View File
@@ -14,6 +14,8 @@ on:
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
concurrency:
@@ -45,20 +47,20 @@ jobs:
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -86,7 +88,7 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Read APP_VERSION + short sha
@@ -103,16 +105,16 @@ jobs:
pattern: digest-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -14,7 +14,7 @@ jobs:
# Skip bots (Dependabot, release-drafter, etc.)
if: ${{ github.event.issue.user.type != 'Bot' }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: .github/scripts
persist-credentials: false
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
# Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.base_ref }}
sparse-checkout: .github/scripts
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history so a secret committed in an earlier commit (and later
# deleted) is still caught -- deletion does not remove it from Git.
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -61,12 +61,12 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
+18
View File
@@ -85,6 +85,24 @@ output.txt.txt
!docs/**/*.gif
!docs/**/*.webp
# …except shipped website and branding media.
!website/**/*.jpg
!website/**/*.jpeg
!website/**/*.png
!website/**/*.gif
!website/**/*.bmp
!website/**/*.webp
!website/**/*.tiff
!website/**/*.pdf
!assets/branding/**/*.jpg
!assets/branding/**/*.jpeg
!assets/branding/**/*.png
!assets/branding/**/*.gif
!assets/branding/**/*.bmp
!assets/branding/**/*.webp
!assets/branding/**/*.tiff
!assets/branding/**/*.pdf
# Reports and temp files
reports/
tasks/
+6 -6
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="238">
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
</p>
<p align="center">
@@ -8,7 +8,7 @@
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="docs/setup.md">Setup Guide</a> ·
<a href="website/setup.md">Setup Guide</a> ·
<a href="CONTRIBUTING.md">Contributing</a> ·
<a href="ROADMAP.md">Roadmap</a>
</p>
@@ -18,7 +18,7 @@
</p>
<p align="center">
<img src="docs/odysseus-browser.jpg" alt="Odysseus interface">
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
</p>
---
@@ -36,7 +36,7 @@ docker compose up -d --build
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](website/setup.md).
## Features
@@ -51,7 +51,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
## Demo
A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
## Contributing
@@ -64,7 +64,7 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
Deployment details are in the [setup guide](docs/setup.md#security-notes).
Deployment details are in the [setup guide](website/setup.md#security-notes).
## Star History
+26 -4
View File
@@ -516,23 +516,45 @@ async def serve_generated_image(filename: str, request: Request):
# SECURITY: filename is the only key, so anyone who knows / guesses a
# 12-hex content hash could pull another user's image bytes. Require
# auth and verify ownership via the gallery row (when one exists).
_is_bearer = False
try:
from src.auth_helpers import get_current_user
from src.auth_helpers import (
effective_user,
get_current_user,
is_bearer_principal,
require_chat_scope,
)
from core.database import SessionLocal as _SL, GalleryImage as _GI
_user = get_current_user(request)
_is_bearer = is_bearer_principal(request)
if _is_bearer:
# Gallery JSON attributes rows to the token owner. Reuse the same
# owner/scope gate for the binary follow-up so the returned URL is
# actually readable by that bearer principal.
require_chat_scope(request)
_user = effective_user(request)
else:
_user = get_current_user(request)
if _user:
_db = _SL()
try:
_row = _db.query(_GI).filter(_GI.filename == filename).first()
# Generated-but-not-yet-imported images have no row → allow.
# Row exists with a different owner → 404 (don't confirm existence).
if _row is not None and _row.owner and _row.owner != _user:
# A bearer gallery row must have the exact token owner; cookie
# callers retain the legacy null-owner compatibility below.
if _row is not None and (
(_is_bearer and _row.owner != _user)
or (not _is_bearer and _row.owner and _row.owner != _user)
):
raise HTTPException(status_code=404, detail="Image not found")
finally:
_db.close()
except HTTPException:
raise
except Exception as _e:
if _is_bearer:
# An authenticated bearer request must not become a public file
# read because ownership lookup degraded or the DB was unavailable.
raise HTTPException(status_code=404, detail="Image not found") from _e
logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
ext = filename.rsplit('.', 1)[-1].lower()
mime = {

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

+4 -4
View File
@@ -27,13 +27,13 @@ echo " port: $PORT"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# ── Icon (best effort) — center-crop docs/odysseus.jpg to a square .icns ──
if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
# ── Icon (best effort) — center-crop the branding image to a square .icns ──
if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
TMPIMG="$(mktemp -d)"
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
# let sips emit the .icns directly — more robust across macOS versions than
# building an .iconset by hand.
sips -c 720 720 "$REPO_DIR/docs/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/docs/odysseus.jpg" "$TMPIMG/sq.png"
sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
echo " icon: odysseus.icns"
@@ -42,7 +42,7 @@ if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
fi
rm -rf "$TMPIMG"
else
echo " icon: (skipped — no docs/odysseus.jpg)"
echo " icon: (skipped — no assets/branding/odysseus.jpg)"
fi
# ── Info.plist ──
+84 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import unquote, urlparse
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, UniqueConstraint, func, inspect, text
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.types import TypeDecorator
from sqlalchemy.ext.declarative import declarative_base, declared_attr
@@ -187,6 +187,13 @@ class Session(TimestampMixin, Base):
endpoint_url = Column(String, nullable=False)
model = Column(String, nullable=False)
owner = Column(String, nullable=True, index=True) # username; null = legacy/shared
# Bearer-chat sessions must retain the exact server-owned endpoint they
# were created from. Keep this reference non-cascading so endpoint
# disable/delete/owner changes remain observable as an orphan and fail
# closed at the next bearer LLM boundary.
model_endpoint_id = Column(String, nullable=True, index=True)
endpoint_provenance = Column(String, nullable=True)
# Configuration flags
rag = Column(Boolean, default=False)
@@ -280,6 +287,47 @@ class ChatMessage(Base):
Index('ix_messages_session_time', 'session_id', 'timestamp'), # Composite for efficient message retrieval
)
class ChatSessionApprovalGrant(Base):
"""Server-owned, durable approval provenance for one chat session.
A resolved tool-approval card is display/history data, not authority. This
separate row is inserted only by the interactive approval continuation and
is keyed by the real session owner plus session id. It deliberately has no
update path; deleting the owning session cascades the grant so an old id
cannot carry approval authority into a newly-created conversation.
"""
__tablename__ = "chat_session_approval_grants"
id = Column(String, primary_key=True, index=True)
session_id = Column(
String,
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
owner = Column(String, nullable=False, index=True)
approval_id = Column(String, nullable=False, index=True)
provenance_version = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, default=utcnow_naive, nullable=False)
__table_args__ = (
UniqueConstraint(
"session_id",
"owner",
"approval_id",
name="uq_chat_session_approval_grant",
),
Index(
"ix_chat_session_approval_grant_lookup",
"session_id",
"owner",
"provenance_version",
),
)
class Document(TimestampMixin, Base):
"""Living document that the AI can create and edit in-place."""
__tablename__ = "documents"
@@ -958,6 +1006,40 @@ def _migrate_add_owner_column():
except Exception:
pass
def _migrate_add_session_endpoint_provenance_columns():
"""Add the durable endpoint identity used by bearer session validation."""
import sqlite3
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(sessions)")}
if "model_endpoint_id" not in columns:
conn.execute("ALTER TABLE sessions ADD COLUMN model_endpoint_id TEXT")
if "endpoint_provenance" not in columns:
conn.execute("ALTER TABLE sessions ADD COLUMN endpoint_provenance TEXT")
conn.execute(
"CREATE INDEX IF NOT EXISTS ix_sessions_model_endpoint_id "
"ON sessions(model_endpoint_id)"
)
conn.commit()
logging.getLogger(__name__).info(
"Migrated: added session endpoint identity/provenance columns"
)
except Exception as e:
logging.getLogger(__name__).warning(
"Session endpoint provenance migration failed: %s", e
)
finally:
try:
conn.close()
except Exception:
pass
def _migrate_model_endpoints():
"""Recreate model_endpoints table if schema changed (url->base_url)."""
import sqlite3
@@ -2111,6 +2193,7 @@ def init_db():
_migrate_add_supports_tools_column()
_migrate_add_task_run_model_column()
_migrate_add_owner_column()
_migrate_add_session_endpoint_provenance_columns()
_migrate_add_document_archived_column()
_migrate_add_last_message_at_column()
_migrate_add_folder_column()
+8
View File
@@ -11,6 +11,7 @@ from starlette.responses import Response
from starlette.routing import get_route_path
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
from src.auth_helpers import is_bearer_principal
# Per-process token that lets the in-app tool layer hit admin-gated
@@ -59,6 +60,13 @@ def require_admin(request: Request):
Allows access when auth is explicitly disabled, or when the request carries
the in-process internal-tool token used by loopback agent tools.
"""
# A bearer principal never inherits admin authority, even when the token
# carries a legacy cookbook scope or auth is disabled in a direct-entry
# test. Host-control routes use this centralized gate, so rejecting here
# covers shell, model-serving, MCP, runtime, and other admin surfaces.
if is_bearer_principal(request):
raise HTTPException(403, "API tokens cannot use admin host-control surfaces")
# In-process bypass for tool-layer loopback calls. Two paths:
# (a) header-direct (caller set X-Odysseus-Internal-Token), or
# (b) the auth middleware already validated the token and stamped
+29 -28
View File
@@ -10,8 +10,9 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
CHAT_SESSION_APPROVAL_DECISION,
)
from src.message_metadata import sanitize_projected_message_metadata
from src.tool_approval_provenance import has_chat_session_approval_grant
if TYPE_CHECKING:
from .session_manager import SessionManager
@@ -40,28 +41,11 @@ def _history_grants_chat_session_approval(
history: List["ChatMessage"],
session_id: str,
) -> bool:
"""Return whether this exact chat has a resolved session-scope grant."""
"""Compatibility shim: durable history is never an authority source.
expected_session = str(session_id or "")
if not expected_session:
return False
for message in reversed(history or []):
metadata = getattr(message, "metadata", None)
if not isinstance(metadata, dict):
continue
tool_events = metadata.get("tool_events")
if not isinstance(tool_events, list):
continue
for event in reversed(tool_events):
ask_user = event.get("ask_user") if isinstance(event, dict) else None
if not isinstance(ask_user, dict):
continue
if (
ask_user.get("kind") == "tool_approval"
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
and str(ask_user.get("session_id") or "") == expected_session
):
return True
Keep the old private symbol for downstream imports, but deliberately return
false. The live projection checks the separate server-owned grant table.
"""
return False
@@ -106,6 +90,8 @@ class Session:
headers: Optional[Dict[str, str]] = None
history: List[ChatMessage] = None
owner: Optional[str] = None
model_endpoint_id: Optional[str] = None
endpoint_provenance: Optional[str] = None
is_important: bool = False
message_count: int = 0
@@ -150,12 +136,27 @@ class Session:
the model. Display/history-load paths use the raw ``history`` and are
unaffected.
"""
messages = [
msg.to_dict()
for msg in self.history
if (msg.metadata or {}).get("source") != "slash"
]
if not _history_grants_chat_session_approval(self.history, self.id):
messages = []
for msg in self.history:
raw_metadata = getattr(msg, "metadata", None)
if isinstance(raw_metadata, dict) and raw_metadata.get("source") == "slash":
continue
projected = msg.to_dict()
metadata = projected.get("metadata")
if not isinstance(metadata, dict):
# Old or malformed durable rows must not make context
# projection fail, and non-mapping metadata has no trusted
# fields that belong in the model context.
projected.pop("metadata", None)
messages.append(projected)
continue
metadata = sanitize_projected_message_metadata(metadata)
if metadata:
projected["metadata"] = metadata
else:
projected.pop("metadata", None)
messages.append(projected)
if not has_chat_session_approval_grant(self.id, self.owner):
return messages
# Keep the grant close to the latest user request so route-neutral
+72 -4
View File
@@ -62,6 +62,22 @@ def _parse_msg_content(raw):
return raw
def _parse_message_metadata(raw) -> dict:
"""Decode only JSON objects from durable message metadata.
Legacy rows may contain a JSON list (including list-of-pairs) or another
scalar. Such values have no trusted message fields and must not reach the
``_db_id``/timestamp merge below or any approval projection.
"""
if not raw:
return {}
try:
parsed = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError, ValueError):
return {}
return dict(parsed) if isinstance(parsed, dict) else {}
class SessionManager:
"""
Manages chat sessions with database persistence.
@@ -149,6 +165,8 @@ class SessionManager:
headers=headers,
history=[],
owner=getattr(db_session, "owner", None),
model_endpoint_id=getattr(db_session, "model_endpoint_id", None),
endpoint_provenance=getattr(db_session, "endpoint_provenance", None),
is_important=getattr(db_session, "is_important", False) or False,
)
session.message_count = getattr(db_session, "message_count", 0) or 0
@@ -161,8 +179,7 @@ class SessionManager:
# Try relationship first, then direct query
if db_session.messages:
for db_msg in db_session.messages:
meta = json.loads(db_msg.meta_data) if db_msg.meta_data else {}
if meta is None: meta = {}
meta = _parse_message_metadata(db_msg.meta_data)
meta['_db_id'] = db_msg.id
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
history.append(ChatMessage(
@@ -176,8 +193,7 @@ class SessionManager:
).order_by(DbChatMessage.timestamp).all()
for db_msg in db_messages:
meta = json.loads(db_msg.meta_data) if db_msg.meta_data else {}
if meta is None: meta = {}
meta = _parse_message_metadata(db_msg.meta_data)
meta['_db_id'] = db_msg.id
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
history.append(ChatMessage(
@@ -207,6 +223,8 @@ class SessionManager:
headers=headers,
history=history,
owner=getattr(db_session, 'owner', None),
model_endpoint_id=getattr(db_session, 'model_endpoint_id', None),
endpoint_provenance=getattr(db_session, 'endpoint_provenance', None),
is_important=getattr(db_session, 'is_important', False) or False,
)
@@ -254,6 +272,8 @@ class SessionManager:
logger.warning("Dropping message for deleted session %s", session_id)
return
if not isinstance(message.metadata, dict):
message.metadata = None
missing_upload_id = reserve_message_upload_references(
getattr(self, "upload_handler", None),
getattr(db_session, "owner", None),
@@ -366,6 +386,8 @@ class SessionManager:
# ownership check/access touch and the replacement transaction.
# A failed reservation must leave the existing transcript intact.
for message in messages:
if not isinstance(message.metadata, dict):
message.metadata = None
missing_upload_id = reserve_message_upload_references(
getattr(self, "upload_handler", None),
getattr(db_session, "owner", None),
@@ -484,6 +506,8 @@ class SessionManager:
session.rag = db_session.rag
session.archived = db_session.archived
session.owner = getattr(db_session, "owner", None)
session.model_endpoint_id = getattr(db_session, "model_endpoint_id", None)
session.endpoint_provenance = getattr(db_session, "endpoint_provenance", None)
session.is_important = getattr(db_session, "is_important", False) or False
session.message_count = (
db.query(DbChatMessage)
@@ -584,6 +608,50 @@ class SessionManager:
finally:
db.close()
def set_session_endpoint_provenance(
self,
session_id: str,
*,
model_endpoint_id: Optional[str],
endpoint_provenance: str,
) -> bool:
"""Persist the server-owned endpoint provenance for a session.
``registered`` rows carry an exact ModelEndpoint id. ``direct`` rows
deliberately carry no endpoint id and retain direct API-key
compatibility. The values are assigned only after the durable write
succeeds so an in-memory session cannot claim provenance the database
did not accept.
"""
provenance = str(endpoint_provenance or "").strip().lower()
endpoint_id = str(model_endpoint_id or "").strip() or None
if provenance == "registered" and not endpoint_id:
raise ValueError("registered session provenance requires an endpoint id")
if provenance == "direct":
endpoint_id = None
if provenance not in {"registered", "direct"}:
raise ValueError("unsupported session endpoint provenance")
db = SessionLocal()
try:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session is None:
raise KeyError(f"Session {session_id} not found")
db_session.model_endpoint_id = endpoint_id
db_session.endpoint_provenance = provenance
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
session = self.sessions.get(session_id)
if session is not None:
session.model_endpoint_id = endpoint_id
session.endpoint_provenance = provenance
return True
def delete_session(self, session_id: str) -> bool:
"""Permanently delete a session and all its messages."""
db = SessionLocal()
+4 -4
View File
@@ -5,13 +5,13 @@
"packages": {
"": {
"devDependencies": {
"@antithesishq/bombadil": "^0.6.1"
"@antithesishq/bombadil": "^0.7.0"
}
},
"node_modules/@antithesishq/bombadil": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.6.1.tgz",
"integrity": "sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.0.tgz",
"integrity": "sha512-alJmnphJ/iUoL5mCsnV3DwtajGy/sEQ3NJJCiMhgjqXshSq2BUtAs0vqdXEiiSkB8HbsOX5CLrAcaogYdwfAJg==",
"dev": true,
"license": "MIT",
"bin": {
+1 -1
View File
@@ -4,6 +4,6 @@
"url": "https://github.com/odysseus-dev/odysseus.git"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.6.1"
"@antithesishq/bombadil": "^0.7.0"
}
}
+8 -4
View File
@@ -11,11 +11,11 @@ import json
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from core.database import SessionLocal, CrewMember, ScheduledTask
from src.auth_helpers import get_current_user
from src.auth_helpers import require_interactive_request
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.task_scheduler import compute_next_run
@@ -78,10 +78,14 @@ def _task_to_checkin_dict(t: ScheduledTask) -> dict:
def setup_assistant_routes(task_scheduler) -> APIRouter:
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
router = APIRouter(
prefix="/api/assistant",
tags=["assistant"],
dependencies=[Depends(require_interactive_request)],
)
def _owner(request: Request) -> str:
owner = get_current_user(request)
owner = require_interactive_request(request)
if not owner:
raise HTTPException(status_code=401, detail="Not authenticated")
return owner
+249 -21
View File
@@ -16,7 +16,13 @@ from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context
from src.model_context import estimate_tokens, get_context_length
from src.auth_helpers import effective_user
from src.auth_helpers import (
RequestCapability,
effective_user,
is_bearer_principal,
request_capability as build_request_capability,
)
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
from src.prompt_security import untrusted_context_message
from src.attachment_refs import attachment_ref
from routes.prefs_routes import _load_for_user as load_prefs_for_user
@@ -104,6 +110,33 @@ def _append_incognito_message(session_id: str, role: str, content: Any, metadata
bundle["updated_at"] = time.time()
def _history_for_request_capability(sess, capability: RequestCapability) -> list[dict[str, Any]]:
"""Project persisted history without interactive approval authority for bearers."""
history = sess.get_context_messages()
if not capability.is_bearer:
return history
# Session.get_context_messages() derives the marker only from the separate
# server-owned grant table. A pure bearer chat may still read its owner's
# ordinary transcript, but it must not receive even that interactive
# approval signal as model context or future tool authority.
projected = []
for item in history or []:
if not isinstance(item, dict):
continue
message = dict(item)
metadata = message.get("metadata")
if isinstance(metadata, dict) and CHAT_SESSION_APPROVAL_CONTEXT_MARKER in metadata:
metadata = dict(metadata)
metadata.pop(CHAT_SESSION_APPROVAL_CONTEXT_MARKER, None)
if metadata:
message["metadata"] = metadata
else:
message.pop("metadata", None)
projected.append(message)
return projected
# ── Data containers ────────────────────────────────────────────────────── #
@dataclass
@@ -172,6 +205,11 @@ def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
# ``effective_user`` is an attribution/storage identity for bearers, not a
# browser privilege principal. In particular, an admin-owned token must
# not inherit the owner's ADMIN_PRIVILEGES map through this lookup.
if is_bearer_principal(request):
return None
try:
user = effective_user(request)
except Exception:
@@ -194,6 +232,12 @@ def _enforce_chat_privileges(request, sess) -> None:
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
which means unrestricted allowed_models / zero cap -> no-op for them.
"""
# Bearer authority is defined by the token scope at the route boundary.
# Do not turn its owner attribution back into a browser privilege lookup;
# that would make an admin-owned token inherit the admin model/cap policy.
if is_bearer_principal(request):
return
try:
user = effective_user(request)
except Exception:
@@ -406,7 +450,13 @@ def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[
return manifest
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
def add_user_message(
sess,
chat_handler,
preprocessed: PreprocessedMessage,
incognito: bool = False,
capability: RequestCapability | None = None,
):
"""Add user message to session history and update session name.
Incognito messages must not mutate persistent session history, even in
memory, because a later normal turn can persist the same session object."""
@@ -414,11 +464,23 @@ def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, inco
return
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
if capability is None or capability.allow_auto_naming:
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
def fire_message_event(
request,
webhook_manager,
session_id: str,
sess,
message: str,
compare_mode: bool = False,
capability: RequestCapability | None = None,
):
"""Fire webhook and event_bus events for a new user message."""
capability = capability or build_request_capability(request)
if not capability.allow_message_events:
return
if webhook_manager and not compare_mode:
webhook_manager.fire_and_forget("chat.message", {
"session_id": session_id, "model": sess.model, "message": message[:2000],
@@ -452,16 +514,37 @@ def _has_auth_keys(headers) -> bool:
)
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
def resolve_session_auth(
sess,
session_id: str,
owner: Optional[str] = None,
*,
allow_live_probes: bool = True,
):
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
if not allow_live_probes:
# Bearer chat is cache-only and request-local. Do not resolve provider
# credentials or write recovered headers/session state in this mode.
return
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
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
has_auth = _has_auth_keys(sess.headers)
if has_auth and not is_chatgpt_subscription:
if has_auth and not is_chatgpt_subscription and provenance != "registered":
return
if provenance == "direct":
# A direct API-key session owns its request headers; a same-URL
# registered endpoint must never supply another user's credentials by
# coincidence.
return
if provenance == "registered":
# Do not carry a previously persisted key through endpoint rotation or
# an unavailable endpoint while attempting exact re-resolution below.
sess.headers = {}
try:
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
@@ -477,6 +560,10 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
# with similar endpoint URLs can borrow each other's API key.
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
if provenance == "registered":
if not endpoint_id:
return
q = q.filter(ModelEndpoint.id == endpoint_id)
for ep in q.all():
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
continue
@@ -532,7 +619,7 @@ def _match_cached_model_id(requested: str, models) -> Optional[str]:
def _normalize_model_id_from_cache(sess) -> Optional[str]:
"""Use stored endpoint model IDs before falling back to a live /models probe."""
"""Use stored ``cached_models``/pinned IDs before a live /models probe."""
endpoint_url = getattr(sess, "endpoint_url", "") or ""
requested = getattr(sess, "model", "") or ""
if not endpoint_url or not requested:
@@ -545,6 +632,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
if not session_base:
return None
provenance = getattr(sess, "endpoint_provenance", None)
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
if provenance == "direct":
# Direct API-key sessions are intentionally outside the registered
# endpoint inventory. Never borrow a same-URL endpoint's model list.
return None
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
@@ -552,6 +645,10 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
if provenance == "registered":
if not endpoint_id:
return None
q = q.filter(ModelEndpoint.id == endpoint_id)
endpoints = q.all()
for ep in endpoints:
try:
@@ -560,11 +657,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
except Exception:
continue
raw_models = getattr(ep, "cached_models", None)
if not raw_models:
continue
try:
models = json.loads(raw_models) if isinstance(raw_models, str) else raw_models
from routes.model_routes import _effective_endpoint_kind, _picker_models_for_endpoint
base_url = getattr(ep, "base_url", "") or ""
kind = _effective_endpoint_kind(ep, base_url)
models, _ = _picker_models_for_endpoint(ep, base_url, kind)
except Exception:
continue
@@ -579,6 +677,91 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
return None
def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[str]:
"""Enforce endpoint-picker authority for a bearer session model.
Direct API-key sessions intentionally have no ``ModelEndpoint`` row and
retain their documented compatibility behavior. Registered endpoint
sessions, including provider-auth-backed rows, must use the visible
server-owned inventory and never trigger a live provider lookup here.
"""
# Lightweight in-memory test doubles from older route tests do not carry
# durable provenance fields. They cannot represent a persisted bearer
# session; retain their historical seam while every SessionManager-loaded
# object (which always has both fields) takes the fail-closed path below.
if not hasattr(sess, "endpoint_provenance") and not hasattr(sess, "model_endpoint_id"):
return None
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
if provenance == "direct":
if endpoint_id:
raise HTTPException(400, "Direct API-key sessions cannot carry a registered endpoint")
# No registered ModelEndpoint row is consulted for this documented
# compatibility path.
return None
if provenance != "registered":
raise HTTPException(400, "Session endpoint provenance is unavailable")
if not owner:
raise HTTPException(403, "A bearer session owner is required")
if not endpoint_id:
raise HTTPException(400, "Registered session endpoint identity is unavailable")
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
requested = (getattr(sess, "model", "") or "").strip()
if not endpoint_url:
raise HTTPException(400, "Registered session endpoint is not configured")
if not requested:
raise HTTPException(400, "Registered session model is not configured")
db = SessionLocal()
try:
from src.auth_helpers import owner_filter
q = db.query(ModelEndpoint).filter(
ModelEndpoint.id == endpoint_id,
ModelEndpoint.is_enabled == True,
)
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
if len(endpoints) != 1:
# This covers disabled/deleted/owner-mismatched rows as well as
# malformed duplicate results. Do not fall back to URL matching.
raise HTTPException(400, "Registered model endpoint is no longer available")
ep = endpoints[0]
if not _session_url_matches_endpoint(endpoint_url, getattr(ep, "base_url", "") or ""):
raise HTTPException(400, "Session endpoint provenance is stale")
from routes.model_routes import _validate_bearer_model_selection
validated = _validate_bearer_model_selection(ep, requested)
# A session may outlive an endpoint-key rotation. For bearer calls,
# use the current exact-endpoint credentials and never trust a stale
# persisted Authorization header. Provider-auth credentials are
# owner-scoped, request-local, and cache-only in this boundary.
try:
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
base, api_key = resolve_endpoint_runtime(
ep,
owner=owner,
allow_live_probes=False,
)
sess.headers = build_headers(api_key, base)
except Exception as exc:
logger.warning("Could not refresh bearer session endpoint auth: %s", exc)
sess.headers = {}
if getattr(ep, "provider_auth_id", None):
raise HTTPException(401, "Registered provider credentials are unavailable") from exc
raise HTTPException(400, "Registered endpoint credentials are unavailable") from exc
sess.model = validated
return validated
finally:
db.close()
def _session_is_research_spinoff(sess) -> bool:
"""True if this session was created via research "Discuss" spin-off.
@@ -626,12 +809,15 @@ async def build_chat_context(
defer_context_shaping: bool = False,
continuation_context_message: str | None = None,
persist_user_message: bool = True,
capability: RequestCapability | None = None,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
This is the shared logic between /chat and /chat_stream preset extraction,
message preprocessing, memory/RAG/web injection, compaction, normalization.
"""
capability = capability or build_request_capability(request)
# Preset
preset = extract_preset(chat_handler, preset_id)
@@ -653,11 +839,25 @@ async def build_chat_context(
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
elif persist_user_message:
add_user_message(sess, chat_handler, preprocessed, incognito=False)
add_user_message(
sess,
chat_handler,
preprocessed,
incognito=False,
capability=capability,
)
# Fire events
if persist_user_message and not incognito:
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
fire_message_event(
request,
webhook_manager,
session_id,
sess,
message,
compare_mode,
capability=capability,
)
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
# bearer-token chat requests use the token owner instead of the "api" sentinel.
@@ -731,6 +931,7 @@ async def build_chat_context(
agent_mode=agent_mode,
incognito=incognito,
use_skills=skills_enabled,
allow_tool_preprocessing=allow_tool_preprocessing,
)
if use_rag is not None or is_research_spinoff or casual_low_signal:
_preface_kwargs["use_rag"] = use_rag_val
@@ -749,18 +950,27 @@ 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,
owner=getattr(sess, "owner", None),
)
norm = _normalize_model_id_from_cache(sess)
# Model normalization falls back to a live /models or /tags request on a
# cache miss. A bearer chat request may use the stored model as-is, but it
# must not implicitly refresh an endpoint catalogue while building context.
if norm is None and capability.allow_live_probes:
norm = normalize_model_id(
sess.endpoint_url,
sess.model,
owner=getattr(sess, "owner", None),
)
if norm:
sess.model = norm
# Build messages. In Nobody/incognito mode, never read saved session
# history: the session id may be a temporary wrapper or, in buggy clients, a
# stale normal session id. Only the ephemeral incognito transcript is safe.
messages = preface + (_incognito_messages(session_id) if incognito else sess.get_context_messages())
messages = preface + (
_incognito_messages(session_id)
if incognito
else _history_for_request_capability(sess, capability)
)
# Current date/time — injected as a standalone *user*-role context message
# placed immediately before the latest user turn, NOT folded into the
@@ -788,11 +998,22 @@ async def build_chat_context(
# session history before we know which route can answer and would make a
# later larger-context candidate unable to recover discarded history.
if defer_context_shaping:
context_length = get_context_length(sess.endpoint_url, sess.model)
context_kwargs = {}
if not capability.allow_live_probes:
context_kwargs["allow_live_probes"] = False
context_length = get_context_length(sess.endpoint_url, sess.model, **context_kwargs)
was_compacted = False
else:
compact_kwargs = {"owner": user}
if not capability.allow_live_probes:
compact_kwargs["allow_live_probes"] = False
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
sess,
sess.endpoint_url,
sess.model,
messages,
sess.headers,
**compact_kwargs,
)
_before_trim_messages = len(messages)
_before_trim_tokens = estimate_tokens(messages)
@@ -1172,6 +1393,7 @@ def run_post_response_tasks(
owner: str = None,
extract_skills: bool = True,
allow_background_extraction: bool = True,
capability: RequestCapability | None = None,
):
"""Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.
@@ -1187,6 +1409,12 @@ def run_post_response_tasks(
``_queue_background_extraction`` keeps them from overlapping the *next*
turn's request too.
"""
if capability is not None and not capability.allow_deferred_work:
# Pure bearer chat is intentionally synchronous and request-bound.
# Do not schedule extraction, teacher/model work, callbacks, or
# auto-naming after the authorized request has returned/disconnected.
return
_extraction_jobs: list = []
# Memory extraction — only every 4th message pair to avoid excess LLM calls
+367 -90
View File
@@ -9,7 +9,7 @@ import logging
from datetime import datetime
from typing import Dict, Any, AsyncGenerator, List, Optional
from fastapi import APIRouter, Request, HTTPException, Form, Query
from fastapi import APIRouter, Depends, Request, HTTPException, Form, Query
from fastapi.responses import StreamingResponse
from pydantic import ValidationError
@@ -40,7 +40,14 @@ from src.foreground_model_routing import (
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError
from src.auth_helpers import effective_user, get_current_user
from src.auth_helpers import (
effective_user,
enforce_api_token_chat_controls,
get_current_user,
request_capability as build_request_capability,
require_chat_scope,
require_interactive_request,
)
from routes.session_routes import _verify_session_owner
from routes.document_helpers import _owner_session_filter
from core.database import SessionLocal, get_session_mode, set_session_mode
@@ -48,10 +55,15 @@ from core.database import Session as DBSession, ChatMessage as DBChatMessage
from core.database import Document as DBDocument, ModelEndpoint
from core.log_safety import redact_url
from routes.research_routes import _resolve_research_endpoint
from routes.model_routes import _visible_models
from routes.model_routes import (
_effective_endpoint_kind,
_picker_models_for_endpoint,
_visible_models,
)
from routes.chat_helpers import (
resolve_session_auth,
build_chat_context,
_validate_bearer_session_model,
save_assistant_response,
run_post_response_tasks,
accumulate_token_usage,
@@ -68,6 +80,7 @@ from src.tool_policy import (
web_search_enabled_for_turn,
)
from src.tool_approvals import tool_approval_store
from src.tool_approval_provenance import create_chat_session_approval_grant
logger = logging.getLogger(__name__)
@@ -90,7 +103,11 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
"""Persist a consumed approval decision on its existing tool event."""
"""Persist display-only resolution state on the existing tool event.
This metadata is intentionally never consulted for authorization; the
separate provenance row is written by the interactive approval path.
"""
approval_key = str(approval_id or "")
normalized_decision = str(decision or "").strip().lower()
@@ -113,6 +130,7 @@ def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
if str(ask_user.get("approval_id") or "") != approval_key:
continue
ask_user["resolved"] = normalized_decision
ask_user["approved_by_interactive_session"] = True
message_id = metadata.get("_db_id")
resolved_metadata = {
key: value for key, value in metadata.items() if key != "_db_id"
@@ -154,6 +172,7 @@ def _chat_candidate_request_factory(
*,
session=None,
owner: Optional[str] = None,
allow_live_probes: bool = True,
):
"""Shape one route-neutral Chat prompt for each candidate window."""
@@ -167,15 +186,20 @@ def _chat_candidate_request_factory(
async def factory(index, candidate_url, candidate_model, candidate_headers):
compaction_state = {}
compact_kwargs = {
"owner": owner,
"persist": False,
"compaction_state": compaction_state,
}
if not allow_live_probes:
compact_kwargs["allow_live_probes"] = False
candidate_messages, context_length, was_compacted = await maybe_compact(
session,
candidate_url,
candidate_model,
list(messages),
candidate_headers,
owner=owner,
persist=False,
compaction_state=compaction_state,
**compact_kwargs,
)
if not context_length:
context_length = fallback_context_length
@@ -395,17 +419,36 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
return sess in variants or sess.startswith(base + "/")
def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool:
def _clear_orphaned_session_endpoint(
sess,
owner: str | None = None,
*,
allow_live_probes: bool = True,
) -> bool:
"""Clear a session model if its endpoint was deleted from ModelEndpoint."""
if not allow_live_probes:
# Bearer chat must not turn an orphan check into a session/database
# mutation. Interactive callers retain the repair behavior below.
return False
if not getattr(sess, "endpoint_url", ""):
return False
db = SessionLocal()
try:
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
if provenance == "direct":
return False
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
if provenance == "registered":
if not endpoint_id:
endpoints = []
else:
endpoints = q.filter(ModelEndpoint.id == endpoint_id).all()
else:
endpoints = q.all()
for ep in endpoints:
if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""):
return False
@@ -508,7 +551,7 @@ def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None
return None
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None, *, allow_live_probes: bool = True) -> bool:
"""Re-populate sess.model from the matching endpoint's cached models.
Covers the window between endpoint setup and the first chat send: the
@@ -516,6 +559,11 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
written (Issue #587 — UI uses the cached endpoint list, not s.model).
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.
Bearer chat callers set ``allow_live_probes`` to false. They may use the
already-persisted visible cache for this request, but recovery must not
resolve provider credentials, refresh the catalog, or persist a model/cache
change as a side effect.
"""
current_model = (getattr(sess, "model", "") or "").strip()
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
@@ -530,15 +578,24 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
return False
db = SessionLocal()
try:
# Prefer the endpoint whose base URL matches the session — we know the
# user already pointed this session at that endpoint, so its first
# cached model is the most defensible default.
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
has_provenance_fields = hasattr(sess, "endpoint_provenance") or hasattr(sess, "model_endpoint_id")
if not allow_live_probes and has_provenance_fields and provenance not in {"registered", "direct"}:
return False
# Registered sessions use their immutable endpoint identity. URL-only
# fallback remains for legacy browser sessions, but is never used to
# recover a bearer session with a missing/invalid identity.
ep = None
if getattr(sess, "endpoint_url", ""):
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
if provenance == "registered":
if not endpoint_id:
return False
q = q.filter(ModelEndpoint.id == endpoint_id)
endpoints = q.all()
for cand in endpoints:
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
@@ -557,16 +614,19 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
except Exception as e:
logger.warning("Failed to parse cached_models for endpoint %r", getattr(ep, "id", "?"), exc_info=e)
cached = []
if not cached:
visible = []
else:
try:
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
except Exception:
visible = cached
try:
base_url = getattr(ep, "base_url", "") or ""
kind = _effective_endpoint_kind(ep, base_url)
visible, _ = _picker_models_for_endpoint(ep, base_url, kind)
except Exception:
visible = _visible_models(
cached,
getattr(ep, "hidden_models", None),
getattr(ep, "pinned_models", None),
)
if current_model and current_model in {str(item).strip() for item in visible}:
return False
if is_chatgpt_subscription:
if is_chatgpt_subscription and allow_live_probes:
live_models = []
if getattr(ep, "provider_auth_id", None):
try:
@@ -587,9 +647,15 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
if not cached:
return False
try:
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
base_url = getattr(ep, "base_url", "") or ""
kind = _effective_endpoint_kind(ep, base_url)
visible, _ = _picker_models_for_endpoint(ep, base_url, kind)
except Exception:
visible = cached
visible = _visible_models(
cached,
getattr(ep, "hidden_models", None),
getattr(ep, "pinned_models", None),
)
if current_model and current_model in {str(item).strip() for item in visible}:
return False
if not visible:
@@ -598,6 +664,16 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
if not isinstance(model, str) or not model.strip():
return False
model = model.strip()
if not allow_live_probes:
# Keep this request usable without turning cache-based recovery
# into a durable session mutation. The normal chat save path will
# persist user/assistant messages, not this transient selection.
sess.model = model
logger.info(
"Recovered session model for %s from cached endpoint model %r (no persistence)",
session_id, model,
)
return True
# 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).
@@ -627,6 +703,8 @@ def _reconcile_selected_route_from_request(
session_id: str,
form_data,
owner: str | None = None,
*,
allow_live_probes: bool = True,
) -> bool:
"""Apply the model route the browser selected before streaming.
@@ -635,6 +713,11 @@ def _reconcile_selected_route_from_request(
stream request includes the route that was selected at click/send time.
Trust only registered endpoint ids, or the session's existing endpoint URL.
"""
if not allow_live_probes:
# The bearer path may consume the already-selected session route, but
# it must not resolve credentials or persist a browser-supplied route.
return False
selected_model = str(form_data.get("selected_model") or "").strip()
selected_endpoint_id = str(form_data.get("selected_endpoint_id") or "").strip()
selected_endpoint_url = str(form_data.get("selected_endpoint_url") or "").strip()
@@ -643,6 +726,7 @@ def _reconcile_selected_route_from_request(
endpoint_url = ""
headers = None
resolved_endpoint_id = None
if selected_endpoint_id or selected_endpoint_url:
try:
from src.auth_helpers import owner_filter
@@ -654,16 +738,27 @@ def _reconcile_selected_route_from_request(
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
if owner:
q = owner_filter(q, ModelEndpoint, owner)
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
ep = None
for cand in candidates:
if not cand:
continue
if selected_endpoint_id or _session_url_matches_endpoint(selected_endpoint_url, cand.base_url or ""):
ep = cand
break
if selected_endpoint_id:
candidates = [q.first()]
else:
candidates = [
cand for cand in q.all()
if cand and _session_url_matches_endpoint(
selected_endpoint_url,
cand.base_url or "",
)
]
# A URL is not stable identity. Refuse to choose between
# duplicate visible endpoints instead of binding the
# session to whichever row happens to come first.
if len(candidates) != 1:
return False
ep = candidates[0] if candidates else None
if not ep:
return False
resolved_endpoint_id = str(getattr(ep, "id", "") or "").strip() or None
if not resolved_endpoint_id:
return False
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
finally:
@@ -678,12 +773,16 @@ def _reconcile_selected_route_from_request(
if (
selected_model == (getattr(sess, "model", "") or "")
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
and resolved_endpoint_id == (getattr(sess, "model_endpoint_id", "") or "")
and getattr(sess, "endpoint_provenance", None) == "registered"
):
return False
sess.model = selected_model
sess.endpoint_url = endpoint_url
sess.headers = headers or {}
sess.model_endpoint_id = resolved_endpoint_id
sess.endpoint_provenance = "registered"
db = SessionLocal()
try:
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
@@ -691,6 +790,8 @@ def _reconcile_selected_route_from_request(
db_session.model = selected_model
db_session.endpoint_url = endpoint_url
db_session.headers = sess.headers or {}
db_session.model_endpoint_id = resolved_endpoint_id
db_session.endpoint_provenance = "registered"
db_session.updated_at = datetime.utcnow()
db.commit()
finally:
@@ -730,13 +831,14 @@ def setup_chat_routes(
webhook_manager=None,
skills_manager=None,
) -> APIRouter:
router = APIRouter(tags=["chat"])
router = APIRouter(tags=["chat"], dependencies=[Depends(require_chat_scope)])
# ------------------------------------------------------------------ #
# POST /api/chat (non-streaming)
# ------------------------------------------------------------------ #
@router.post("/api/chat", response_model=Dict[str, Any])
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
require_chat_scope(request)
_set_user_time_from_request(request)
message = chat_request.message
@@ -747,6 +849,9 @@ def setup_chat_routes(
time_filter = chat_request.time_filter
preset_id = chat_request.preset_id
if getattr(request.state, "api_token", False) is True and use_research:
raise HTTPException(403, "API tokens cannot use research or agent execution")
# Verify the caller owns this session before loading it.
# Without this, any authenticated user can post into another user's chat.
_verify_session_owner(request, session)
@@ -756,13 +861,23 @@ def setup_chat_routes(
except KeyError:
raise HTTPException(404, f"Session '{session}' not found")
owner = effective_user(request)
if _clear_orphaned_session_endpoint(sess, owner=owner):
request_capability = build_request_capability(request)
if _clear_orphaned_session_endpoint(
sess,
owner=owner,
allow_live_probes=request_capability.allow_live_probes,
):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
# Empty model + live endpoint = setup race (Issue #587). Repair from
# the endpoint's cached model list before privilege checks, which
# otherwise see "" and behave inconsistently with the allowlist.
_recover_empty_session_model(sess, session, owner=owner)
_recover_empty_session_model(
sess,
session,
owner=owner,
allow_live_probes=request_capability.allow_live_probes,
)
if not getattr(sess, "model", "").strip():
raise HTTPException(
400,
@@ -770,17 +885,23 @@ def setup_chat_routes(
)
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
if request_capability.is_bearer:
_validate_bearer_session_model(sess, owner=owner)
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
# non-streaming path can't be used to bypass).
_enforce_chat_privileges(request, sess)
api_token_request = request_capability.is_bearer
tool_policy = build_effective_tool_policy(last_user_message=message)
allow_tool_preprocessing = not tool_policy.block_all_tool_calls
allow_tool_preprocessing = (
not api_token_request
and not tool_policy.block_all_tool_calls
)
# Inline memory command
memory_response = None
if not tool_policy.blocks("manage_memory"):
if allow_tool_preprocessing and not tool_policy.blocks("manage_memory"):
memory_response = await chat_handler.handle_memory_command(sess, message)
if memory_response:
return {"response": memory_response}
@@ -788,6 +909,7 @@ def setup_chat_routes(
foreground_policy = resolve_foreground_model_policy(
owner=owner,
allowed_models=_allowed_models_for_request(request),
allow_live_probes=request_capability.allow_live_probes,
)
# Build shared context (preset, preprocess, preface, compact)
@@ -802,6 +924,7 @@ def setup_chat_routes(
webhook_manager=webhook_manager,
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
capability=request_capability,
)
# Research injection
@@ -809,7 +932,7 @@ def setup_chat_routes(
tool_policy.blocks("trigger_research")
or tool_policy.blocks("manage_research")
)
if use_research and not research_blocked_by_policy:
if use_research and not api_token_request 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(
@@ -831,6 +954,7 @@ def setup_chat_routes(
sess.headers,
owner=owner,
policy=foreground_policy,
allow_live_probes=request_capability.allow_live_probes,
)
route_descriptors = build_foreground_route_descriptors(
sess.endpoint_url,
@@ -839,6 +963,7 @@ def setup_chat_routes(
owner=owner,
policy=foreground_policy,
selected_endpoint_id=chat_request.selected_endpoint_id,
allow_live_probes=request_capability.allow_live_probes,
)
candidate_request_factory = None
selected_context_length = getattr(ctx, "context_length", 0)
@@ -855,17 +980,23 @@ def setup_chat_routes(
selected_context_length,
session=sess,
owner=owner,
allow_live_probes=request_capability.allow_live_probes,
)
requested_model = sess.model
llm_kwargs = {
"fallback_statuses": foreground_policy.eligible_statuses,
"candidate_request_factory": candidate_request_factory,
"temperature": ctx.preset.temperature,
"max_tokens": ctx.preset.max_tokens,
"prompt_type": preset_id,
"session_id": session,
}
if not request_capability.allow_live_probes:
llm_kwargs["allow_live_probes"] = False
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
foreground_candidates,
request_messages,
fallback_statuses=foreground_policy.eligible_statuses,
candidate_request_factory=candidate_request_factory,
temperature=ctx.preset.temperature,
max_tokens=ctx.preset.max_tokens,
prompt_type=preset_id,
session_id=session,
**llm_kwargs,
)
actual_index = _candidate_index(foreground_candidates, actual_candidate)
apply_compaction_state(
@@ -909,7 +1040,8 @@ 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,
allow_background_extraction=allow_tool_preprocessing,
capability=request_capability,
)
return {
@@ -927,6 +1059,7 @@ def setup_chat_routes(
# ------------------------------------------------------------------ #
@router.post("/api/chat_stream")
async def chat_stream(request: Request) -> StreamingResponse:
require_chat_scope(request)
body = None
try:
if request.headers.get("content-type", "").startswith("application/json"):
@@ -947,6 +1080,8 @@ def setup_chat_routes(
attachments = form_data.get("attachments")
use_web = form_data.get("use_web")
use_research = form_data.get("use_research")
if use_research is None:
use_research = (body or {}).get("use_research")
time_filter = form_data.get("time_filter")
preset_id = form_data.get("preset_id")
selected_endpoint_id = str(
@@ -964,7 +1099,7 @@ def setup_chat_routes(
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") or (body or {}).get("plan_mode") or "").lower() == "true"
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
chat_mode = str(form_data.get("mode") or (body or {}).get("mode") or "chat").lower()
tool_approval_id = (
form_data.get("tool_approval_id")
or (body or {}).get("tool_approval_id")
@@ -978,10 +1113,47 @@ def setup_chat_routes(
retired_tool_approval_taint = False
external_untrusted_context_seen = False
tool_approval_continuation = False
# Workspace: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace(
request, form_data.get("workspace")
api_token_request = enforce_api_token_chat_controls(
request,
mode=chat_mode,
plan_mode=plan_mode,
approval_id=tool_approval_id,
allow_bash=allow_bash,
)
request_capability = build_request_capability(request)
# Keep the route decision and the downstream capability derived from
# the same verified principal. The explicit control gate above remains
# the source of the chat-mode rejection message.
api_token_request = request_capability.is_bearer
# A bearer token is a chat-only integration credential. Reject every
# remaining control-plane input before approval lookup, intent
# detection, context shaping, or active-email/workspace resolution can
# turn the request into an interactive agent turn.
approved_plan = ""
if not plan_mode:
approved_plan = str(
form_data.get("approved_plan")
or (body or {}).get("approved_plan")
or ""
).strip()[:8192]
if api_token_request and (
str(use_research).lower() == "true"
or bool(tool_approval_decision)
or bool(approved_plan)
):
raise HTTPException(
403,
"API tokens cannot use research, agent, plan, or tool approvals",
)
# Workspace: confine the agent's file/shell tools to this folder.
if api_token_request:
workspace, workspace_rejected = "", ""
else:
workspace, workspace_rejected = _resolve_request_workspace(
request, form_data.get("workspace")
)
# Plan mode is a modifier on agent mode — it only makes sense with tools.
if plan_mode:
chat_mode = "agent"
@@ -990,9 +1162,6 @@ def setup_chat_routes(
# weak model survives history truncation — the agent can always re-read
# the plan. Ignored while still proposing (plan_mode on). Capped so a
# huge plan can't blow the prompt.
approved_plan = ""
if not plan_mode:
approved_plan = (form_data.get("approved_plan") or "").strip()[:8192]
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
# below). Skill extraction should only learn from real agent sessions,
# not chats we quietly promoted for a notes/calendar intent.
@@ -1025,9 +1194,13 @@ def setup_chat_routes(
# its way through a plain chat request (and fail, especially with the
# shell disabled).
auto_escalated = False
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
_tool_intent = (
_classify_tool_intent(message)
if not api_token_request and isinstance(message, str)
else None
)
_workspace_agent_intent = False
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
if not api_token_request and chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
@@ -1038,24 +1211,28 @@ def setup_chat_routes(
_tool_intent.category,
_tool_intent.reason,
)
elif chat_mode == "chat" and _search_enabled:
elif not api_token_request and chat_mode == "chat" and _search_enabled:
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: search enabled")
elif chat_mode == "chat" and _explicit_web_intent:
elif not api_token_request and chat_mode == "chat" and _explicit_web_intent:
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: explicit web intent")
active_doc_id = form_data.get("active_doc_id", "").strip()
active_doc_id = "" if api_token_request else str(form_data.get("active_doc_id") or "").strip()
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
# Active email reader — when the user has an email open in the UI, the
# frontend passes its uid/folder/account so "reply", "summarize this",
# etc. resolve to the real email instead of the agent inventing a
# fake markdown draft.
active_email_uid = form_data.get("active_email_uid", "").strip()
active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX"
active_email_account = form_data.get("active_email_account", "").strip()
active_email_uid = "" if api_token_request else str(form_data.get("active_email_uid") or "").strip()
active_email_folder = (
"INBOX"
if api_token_request
else str(form_data.get("active_email_folder") or "INBOX").strip() or "INBOX"
)
active_email_account = "" if api_token_request else str(form_data.get("active_email_account") or "").strip()
active_email_ctx: Optional[Dict[str, str]] = None
# Always reset between requests so a stale active-email pointer from
# a previous turn (different reader closed, different account, etc.)
@@ -1165,6 +1342,21 @@ def setup_chat_routes(
409,
"This tool approval could not be consumed.",
)
if decision == "approve":
# The transcript card is display data. Only the exact
# interactive, one-use store result may create durable
# session-scope provenance for later tool gates.
if not create_chat_session_approval_grant(
request,
approval=exact_tool_approval,
approval_id=tool_approval_id,
session_id=session,
owner=owner,
):
logger.warning(
"Tool approval %s ran without a durable chat-session grant",
tool_approval_id,
)
if not _mark_tool_approval_resolved(
sess,
tool_approval_id,
@@ -1210,8 +1402,19 @@ def setup_chat_routes(
external_untrusted_context_seen = (
external_untrusted_context_seen or retired_tool_approval_taint
)
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
if _clear_orphaned_session_endpoint(sess, owner=owner):
_reconcile_selected_route_from_request(
request,
sess,
session,
form_data,
owner=owner,
allow_live_probes=request_capability.allow_live_probes,
)
if _clear_orphaned_session_endpoint(
sess,
owner=owner,
allow_live_probes=request_capability.allow_live_probes,
):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
# Issue #587: picker shows a model from the endpoint cache but
# s.model never made it onto the DB row (first-send race after
@@ -1219,7 +1422,12 @@ def setup_chat_routes(
# the first cached model off the matching endpoint so the
# upstream isn't called with model="" (which surfaces as a
# generic 401/503).
_recover_empty_session_model(sess, session, owner=owner)
_recover_empty_session_model(
sess,
session,
owner=owner,
allow_live_probes=request_capability.allow_live_probes,
)
if not getattr(sess, "model", "").strip():
raise HTTPException(
400,
@@ -1227,8 +1435,11 @@ def setup_chat_routes(
)
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
if request_capability.is_bearer:
_validate_bearer_session_model(sess, owner=owner)
if (
chat_mode == "chat"
not api_token_request
and chat_mode == "chat"
and isinstance(message, str)
and (not _tool_intent or not _tool_intent.needs_tools)
and _is_contextual_web_followup(message, sess)
@@ -1242,14 +1453,14 @@ def setup_chat_routes(
_tool_intent.category,
_tool_intent.reason,
)
if isinstance(message, str) and _is_contextual_browser_followup(message, sess):
if not api_token_request and isinstance(message, str) and _is_contextual_browser_followup(message, sess):
_explicit_browser_intent = True
if chat_mode == "chat":
chat_mode = "agent"
auto_escalated = True
_workspace_agent_intent = False
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
if not workspace and isinstance(message, str):
if not api_token_request and not workspace and isinstance(message, str):
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
if _auto_workspace:
workspace = _auto_workspace
@@ -1263,6 +1474,16 @@ def setup_chat_routes(
except (ValueError, ValidationError):
raise HTTPException(400, "Invalid request parameters")
# API tokens are integration credentials, not interactive humans. They
# may stream ordinary chat responses, but intent detection, contextual
# follow-ups, and workspace parsing must never promote them into the
# agent/research execution branch.
if api_token_request:
chat_mode = "chat"
auto_escalated = False
workspace = None
use_research = "false"
# ------------------------------------------------------------------ #
# Privilege gates that must fire BEFORE any LLM work / token spend.
# 1. allowed_models — reject if session.model isn't in the user's
@@ -1274,17 +1495,24 @@ def setup_chat_routes(
_enforce_chat_privileges(request, sess)
# Ensure session has auth headers
resolve_session_auth(sess, session, owner=effective_user(request))
resolve_session_auth(
sess,
session,
owner=effective_user(request),
allow_live_probes=request_capability.allow_live_probes,
)
# Check for research_pending BEFORE mode persist overwrites it
# An approval response resumes the sealed agent action. Do not let
# mutable form fields, or a stale research_pending session marker,
# consume the one-use grant on the unrelated research path.
do_research = (
not api_token_request
and
not tool_approval_continuation
and str(use_research).lower() == "true"
)
if not do_research and not tool_approval_continuation:
if not api_token_request and not do_research and not tool_approval_continuation:
if get_session_mode(session) == 'research_pending':
do_research = True
logger.info(f"Session {session} in research_pending — auto-triggering research")
@@ -1302,7 +1530,11 @@ def setup_chat_routes(
except Exception as e:
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
image_generation_session = _is_image_generation_session(sess, owner=effective_user(request))
image_generation_session = _is_image_generation_session(
sess, owner=effective_user(request)
)
if api_token_request and image_generation_session:
raise HTTPException(403, "API tokens cannot use image generation")
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
if image_generation_session:
no_memory = True
@@ -1311,10 +1543,14 @@ def setup_chat_routes(
pre_context_tool_policy = build_effective_tool_policy(
last_user_message=message,
)
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
allow_tool_preprocessing = (
not api_token_request
and not pre_context_tool_policy.block_all_tool_calls
)
foreground_policy = resolve_foreground_model_policy(
owner=owner,
allowed_models=_allowed_models_for_request(request),
allow_live_probes=request_capability.allow_live_probes,
)
# Build shared context (stream path uses enhanced_message for context preface)
@@ -1347,6 +1583,7 @@ def setup_chat_routes(
else None
),
persist_user_message=not tool_approval_continuation,
capability=request_capability,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -1504,7 +1741,12 @@ def setup_chat_routes(
# Enforce per-user privileges
_privs = {}
_user = ctx.user
if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager:
if (
not api_token_request
and _user
and hasattr(request.app.state, 'auth_manager')
and request.app.state.auth_manager
):
_privs = request.app.state.auth_manager.get_privileges(_user)
if _privs:
if not _privs.get("can_use_bash", True):
@@ -1581,7 +1823,7 @@ def setup_chat_routes(
# 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'):
if _effective_mode in ('agent', 'research', 'chat') and not request_capability.is_bearer:
set_session_mode(session, _effective_mode)
async def stream_with_save() -> AsyncGenerator[str, None]:
@@ -1774,6 +2016,7 @@ def setup_chat_routes(
sess.headers,
owner=_user,
policy=_foreground_policy,
allow_live_probes=request_capability.allow_live_probes,
)
_foreground_route_descriptors = build_foreground_route_descriptors(
sess.endpoint_url,
@@ -1782,6 +2025,7 @@ def setup_chat_routes(
owner=_user,
policy=_foreground_policy,
selected_endpoint_id=selected_endpoint_id,
allow_live_probes=request_capability.allow_live_probes,
)
_chat_request_factory = None
_selected_context_length = getattr(ctx, "context_length", 0)
@@ -1796,6 +2040,7 @@ def setup_chat_routes(
_selected_context_length,
session=sess,
owner=_user,
allow_live_probes=request_capability.allow_live_probes,
)
# Send model name early so the frontend can show it during streaming
@@ -1814,7 +2059,7 @@ def setup_chat_routes(
yield f'data: {json.dumps(_model_info)}\n\n'
_terminal_saved = False
if _is_image_generation_session(sess, owner=_user):
if image_generation_session:
from src.settings import get_setting
if tool_policy.blocks("generate_image"):
_blocked_msg = tool_policy.reason_for("generate_image")
@@ -1929,23 +2174,28 @@ def setup_chat_routes(
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
try:
async for chunk in stream_llm_with_fallback(
_foreground_candidates,
messages,
temperature=ctx.preset.temperature,
stream_kwargs = {
"temperature": ctx.preset.temperature,
# Respect the preset; 0/unset = let the server decide (no
# cap), matching agent mode. The old hard 4096 fallback
# truncated reasoning models mid-<think> — they'd burn the
# whole budget thinking and never emit the answer (seen in
# Compare on heavy generation prompts).
max_tokens=ctx.preset.max_tokens,
prompt_type=preset_id,
tools=None,
session_id=session,
fallback_statuses=_foreground_policy.eligible_statuses,
fallback_on_empty=_foreground_policy.fallback_on_empty,
candidate_request_factory=_chat_request_factory,
candidate_route_descriptors=_foreground_route_descriptors,
"max_tokens": ctx.preset.max_tokens,
"prompt_type": preset_id,
"tools": None,
"session_id": session,
"fallback_statuses": _foreground_policy.eligible_statuses,
"fallback_on_empty": _foreground_policy.fallback_on_empty,
"candidate_request_factory": _chat_request_factory,
"candidate_route_descriptors": _foreground_route_descriptors,
}
if not request_capability.allow_live_probes:
stream_kwargs["allow_live_probes"] = False
async for chunk in stream_llm_with_fallback(
_foreground_candidates,
messages,
**stream_kwargs,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -2225,9 +2475,10 @@ def setup_chat_routes(
character_name=ctx.preset.character_name,
owner=_user,
allow_background_extraction=(
not tool_policy.block_all_tool_calls
allow_tool_preprocessing
and not tool_approval_continuation
),
capability=request_capability,
)
_stream_set(session, status="done")
yield chunk
@@ -2499,9 +2750,10 @@ def setup_chat_routes(
and not tool_approval_continuation
),
allow_background_extraction=(
not tool_policy.block_all_tool_calls
allow_tool_preprocessing
and not tool_approval_continuation
),
capability=request_capability,
)
_stream_set(session, status="done")
yield chunk
@@ -2576,7 +2828,7 @@ def setup_chat_routes(
# 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:
if compare_mode or not request_capability.allow_detached_execution:
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
_detached_run = agent_runs.start(session, _safe_stream())
@@ -2590,8 +2842,12 @@ def setup_chat_routes(
# GET /api/chat/resume — reconnect to a detached run that's still going
# (e.g. after reopening a session whose agent kept running in the background)
# ------------------------------------------------------------------ #
@router.get("/api/chat/resume/{session_id}")
@router.get(
"/api/chat/resume/{session_id}",
dependencies=[Depends(require_interactive_request)],
)
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
require_interactive_request(request)
_verify_session_owner(request, session_id)
_active_run = agent_runs.get_active_run(session_id)
if _active_run is None:
@@ -2606,8 +2862,12 @@ def setup_chat_routes(
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
# no longer stops it (it's detached), so the Stop button must call this.
# ------------------------------------------------------------------ #
@router.post("/api/chat/stop/{session_id}")
@router.post(
"/api/chat/stop/{session_id}",
dependencies=[Depends(require_interactive_request)],
)
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
require_interactive_request(request)
_verify_session_owner(request, session_id)
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
stopped = agent_runs.stop(session_id, _expected_run_id)
@@ -2616,8 +2876,12 @@ def setup_chat_routes(
# ------------------------------------------------------------------ #
# GET /api/chat/stream_status — check if a stream is active for a session
# ------------------------------------------------------------------ #
@router.get("/api/chat/stream_status/{session_id}")
@router.get(
"/api/chat/stream_status/{session_id}",
dependencies=[Depends(require_interactive_request)],
)
async def chat_stream_status(request: Request, session_id: str) -> Dict[str, Any]:
require_interactive_request(request)
_verify_session_owner(request, session_id)
# A detached run can still be going even if _active_streams was popped;
# report it as active so the client knows to reconnect via /resume.
@@ -2636,6 +2900,7 @@ def setup_chat_routes(
# ------------------------------------------------------------------ #
@router.post("/api/inject_context/{session_id}")
async def inject_context(request: Request, session_id: str, context: str = Form(...)) -> Dict[str, str]:
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
sess = session_manager.get_session(session_id)
@@ -2655,6 +2920,7 @@ def setup_chat_routes(
q: str = Query("", min_length=0),
limit: int = Query(20, ge=1, le=100),
) -> List[Dict[str, Any]]:
require_chat_scope(request)
if not q or not q.strip():
return []
@@ -2680,6 +2946,8 @@ def setup_chat_routes(
Unlike the full chat pipeline, this does NOT run the agent loop or tools.
It just asks the LLM to rewrite the given text.
"""
require_chat_scope(request)
capability = build_request_capability(request)
try:
body = await request.json()
except Exception:
@@ -2699,6 +2967,11 @@ def setup_chat_routes(
except (KeyError, SessionNotFoundError):
raise HTTPException(404, "Session not found")
if capability.is_bearer:
# Rewrite is a direct streaming LLM consumer, so it must enforce
# the same server-owned session model/endpoint invariant as chat.
_validate_bearer_session_model(sess, owner=effective_user(request))
messages = [
{"role": "system", "content": (
"You are rewriting a previous response. Follow the instruction exactly. "
@@ -2714,6 +2987,9 @@ def setup_chat_routes(
async def stream_rewrite() -> AsyncGenerator[str, None]:
full_response = ""
try:
stream_kwargs = {}
if not capability.allow_live_probes:
stream_kwargs["allow_live_probes"] = False
async for chunk in stream_llm(
sess.endpoint_url,
sess.model,
@@ -2726,6 +3002,7 @@ def setup_chat_routes(
# on "Rewriting...". Same fix as the chat max_tokens cap.
max_tokens=0,
tools=None,
**stream_kwargs,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
+85 -41
View File
@@ -1,8 +1,9 @@
"""Codex integration routes.
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
reuse existing Odysseus helpers and enforce API-token scopes before touching
user data.
reuse existing Odysseus helpers. Owner-scoped data operations support bearer
principals with the matching token scope; the Cookbook/plugin host-control
plane remains interactive-only.
"""
import asyncio
@@ -12,11 +13,16 @@ from io import BytesIO
from pathlib import Path
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from core.middleware import require_admin
from src.auth_helpers import require_authenticated_request, require_user
from src.auth_helpers import (
require_api_token_owner,
require_authenticated_request,
require_non_bearer_request,
require_user,
)
from src.tool_implementations import do_manage_notes
from src.constants import COOKBOOK_STATE_FILE
from routes._validators import validate_remote_host, validate_ssh_port
@@ -61,9 +67,40 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
"""Run an existing route handler with request.state.current_user temporarily
set to ``owner`` so its internal get_current_user/require_user calls see
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."""
Temporarily hide the bearer header as well: nested legacy handlers classify
the raw header independently of ``request.state.api_token``. Restore every
request 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)
missing = object()
scope = getattr(request, "scope", None)
original_scope_headers = missing
original_cached_headers = missing
original_mapping_headers = missing
if isinstance(scope, dict) and "headers" in scope:
original_scope_headers = scope["headers"]
scope["headers"] = [
(name, value)
for name, value in (original_scope_headers or [])
if not (
(isinstance(name, bytes) and name.lower() == b"authorization")
or (isinstance(name, str) and name.casefold() == "authorization")
)
]
request_dict = getattr(request, "__dict__", {})
if "_headers" in request_dict:
original_cached_headers = request_dict["_headers"]
request_dict.pop("_headers", None)
else:
current_headers = getattr(request, "headers", missing)
if isinstance(current_headers, dict):
original_mapping_headers = current_headers
request.headers = {
name: value
for name, value in current_headers.items()
if str(name).casefold() != "authorization"
}
request.state.current_user = owner
request.state.api_token = False
try:
@@ -80,46 +117,49 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
pass
else:
request.state.api_token = orig_api_token
if original_scope_headers is not missing:
scope["headers"] = original_scope_headers
request_dict = getattr(request, "__dict__", {})
request_dict.pop("_headers", None)
if original_cached_headers is not missing:
request_dict["_headers"] = original_cached_headers
if original_mapping_headers is not missing:
request.headers = original_mapping_headers
def _scope_owner(request: Request, allowed: set[str]) -> str:
"""Return the data owner if the caller is allowed for this Codex action."""
if getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is True:
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if not scopes.intersection(allowed):
required = " or ".join(sorted(allowed))
raise HTTPException(403, f"API token missing required scope: {required}")
owner = getattr(request.state, "api_token_owner", None)
if not owner:
raise HTTPException(403, "API token has no owner")
return owner
return require_api_token_owner(request)
return require_user(request)
def _scope_owner_all(request: Request, required: set[str]) -> str:
"""Return owner only when an API token has every required scope."""
if getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is True:
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
missing = required - scopes
if missing:
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
owner = getattr(request.state, "api_token_owner", None)
if not owner:
raise HTTPException(403, "API token has no owner")
return owner
return require_api_token_owner(request)
return require_user(request)
def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
"""Authorize a Codex cookbook route.
For API-token callers, enforce the given scope set.
For cookie-session callers, additionally require admin privileges
because cookbook surfaces expose host topology, task logs, tmux
Bearer callers are rejected by the host-control boundary regardless of
legacy scope labels. Cookie-session callers additionally require admin
privileges because cookbook surfaces expose host topology, task logs, tmux
commands, and model-serving controls.
"""
require_non_bearer_request(request)
owner = _scope_owner(request, allowed)
if not getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is not True:
require_admin(request)
return owner
@@ -151,7 +191,10 @@ def setup_codex_routes(
calendar_router: APIRouter | None = None,
document_router: APIRouter | None = None,
) -> APIRouter:
router = APIRouter(prefix="/api/codex", tags=["codex"])
router = APIRouter(
prefix="/api/codex",
tags=["codex"],
)
email_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}")
email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send")
@@ -167,7 +210,7 @@ def setup_codex_routes(
@router.get("/capabilities")
def capabilities(request: Request):
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
has_token = bool(getattr(request.state, "api_token", False))
has_token = getattr(request.state, "api_token", False) is True
def scoped(allowed):
return bool(token_scopes.intersection(allowed)) if has_token else True
return {
@@ -215,8 +258,9 @@ def setup_codex_routes(
},
}
@router.get("/plugin.zip")
@router.get("/plugin.zip", dependencies=[Depends(require_non_bearer_request)])
def plugin_zip(request: Request):
require_non_bearer_request(request)
require_authenticated_request(request)
root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
if not root.exists():
@@ -513,15 +557,10 @@ def setup_codex_routes(
return await _as_owner(request, owner, documents_create_endpoint, request, req)
# ── Cookbook surface ──
# Lets the agent run the same launch / monitor / kill loop the user
# would do by hand in the Cookbook UI: read the current task list +
# tmux output, launch a serve task, stop one. Two scopes:
# cookbook:read — list tasks + tail output + list servers
# cookbook:launch — also start/stop serves (host shell exec)
# `cookbook:launch` is genuinely powerful: /api/model/serve runs SSH'd
# commands on the user's hosts. The existing _validate_serve_cmd
# allowlist (vllm/python3/sglang/llama-server/etc., no shell metachars)
# keeps the agent inside the same sandbox the UI uses.
# These handlers retain their legacy scope constants for compatibility
# with callers and tests, but the bridge is an interactive-only
# host-control plane. Bearer principals are rejected before any task-list,
# tmux-output, launch, stop, or model-serving operation.
async def _run_shell(cmd: str, timeout: float = 15.0) -> dict:
"""Run a shell command, return {exit_code, stdout, stderr}."""
@@ -565,14 +604,14 @@ def setup_codex_routes(
if k not in ("hf_token", "_secrets")}
return clean
@router.get("/cookbook/tasks")
@router.get("/cookbook/tasks", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_tasks(request: Request):
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
state = _read_cookbook_state()
tasks = state.get("tasks") or []
return {"tasks": [_redact_task(t) for t in tasks]}
@router.get("/cookbook/servers")
@router.get("/cookbook/servers", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_servers(request: Request):
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
state = _read_cookbook_state()
@@ -591,7 +630,7 @@ def setup_codex_routes(
})
return {"servers": cleaned}
@router.get("/cookbook/output/{session_id}")
@router.get("/cookbook/output/{session_id}", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
# Defensive: session_id must be the tmux-style id we issue
@@ -633,7 +672,7 @@ def setup_codex_routes(
"task": _redact_task(task),
}
@router.post("/cookbook/serve")
@router.post("/cookbook/serve", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
# Wraps /api/model/serve with the SAME validation the UI uses.
@@ -672,7 +711,7 @@ def setup_codex_routes(
raise HTTPException(503, "model serve endpoint unavailable")
return await serve_endpoint(request, req)
@router.post("/cookbook/stop/{session_id}")
@router.post("/cookbook/stop/{session_id}", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_stop(request: Request, session_id: str):
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
import re as _re
@@ -689,7 +728,7 @@ def setup_codex_routes(
result = await _run_shell(cmd, timeout=10)
return {"session_id": session_id, "exit_code": result.get("exit_code"), "host": host or "local"}
@router.get("/cookbook/cached")
@router.get("/cookbook/cached", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_cached(request: Request, host: str | None = None):
"""List cached models on a configured server (or local if host is omitted).
Mirrors `list_cached_models` from the chat agent so external agents have
@@ -751,7 +790,7 @@ def setup_codex_routes(
platform=params.get("platform") or None,
)
@router.get("/cookbook/presets")
@router.get("/cookbook/presets", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_presets(request: Request):
"""List saved serve presets (model + host + port + launch cmd).
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
@@ -772,7 +811,7 @@ def setup_codex_routes(
})
return {"presets": out, "default_host": (state.get("env") or {}).get("defaultServer", "")}
@router.post("/cookbook/preset/{name}")
@router.post("/cookbook/preset/{name}", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_serve_preset(request: Request, name: str):
"""Launch a saved preset by name. Reuses the working cmd + host the
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
@@ -822,7 +861,7 @@ def setup_codex_routes(
raise HTTPException(503, "model serve endpoint unavailable")
return await serve_endpoint(request, req)
@router.post("/cookbook/adopt")
@router.post("/cookbook/adopt", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_adopt(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
"""Adopt an existing tmux session (one started via raw ssh+tmux) into
cookbook tracking. Needed when serve_model rejects a cmd and the
@@ -886,10 +925,15 @@ def setup_claude_routes() -> APIRouter:
this router only exists to deliver the skill zip via `/api/claude/plugin.zip`
so the user-facing setup commands stay in the Claude namespace.
"""
router = APIRouter(prefix="/api/claude", tags=["claude"])
router = APIRouter(
prefix="/api/claude",
tags=["claude"],
dependencies=[Depends(require_non_bearer_request)],
)
@router.get("/plugin.zip")
def plugin_zip(request: Request):
require_non_bearer_request(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.
+87 -17
View File
@@ -4,19 +4,24 @@ import json
import uuid
import random
from datetime import datetime
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from typing import List
from pydantic import BaseModel
import logging
from core.database import Comparison, SessionLocal
from core.session_manager import SessionManager
from src.auth_helpers import get_current_user
from src.auth_helpers import effective_user, is_bearer_principal, require_chat_scope
from src.session_provenance import persist_session_endpoint_provenance
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/compare", tags=["compare"])
router = APIRouter(
prefix="/api/compare",
tags=["compare"],
dependencies=[Depends(require_chat_scope)],
)
def _owned_endpoint_by_url(db, base_url, owner):
@@ -64,6 +69,37 @@ class RecordVoteRequest(BaseModel):
is_blind: bool = True
def _validate_bearer_compare_models(models, owner: str) -> list[str]:
"""Validate record-only comparison models against visible endpoint caches."""
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
from routes.model_routes import _validate_bearer_model_selection
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
selected = []
for requested in models:
matches = []
for ep in endpoints:
try:
matches.append(_validate_bearer_model_selection(ep, requested))
except HTTPException:
continue
unique = list(dict.fromkeys(matches))
if len(unique) != 1:
raise HTTPException(
400,
f"Model is not permitted by a visible server endpoint: {requested}",
)
selected.append(unique[0])
return selected
finally:
db.close()
def setup_compare_routes(session_manager: SessionManager):
"""Setup comparison routes."""
@@ -84,7 +120,9 @@ 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)
require_chat_scope(request)
user = effective_user(request)
bearer = is_bearer_principal(request)
comp_id = str(uuid.uuid4())
sid_a = str(uuid.uuid4())
sid_b = str(uuid.uuid4())
@@ -160,6 +198,13 @@ def setup_compare_routes(session_manager: SessionManager):
_reject_raw_endpoint_url_for_non_admin(
request, user, str(ep.id) if ep is not None else None, endpoint
)
selected_model = model
if bearer:
if ep is None:
raise HTTPException(403, "Choose a registered model endpoint")
from routes.model_routes import _validate_bearer_model_selection
selected_model = _validate_bearer_model_selection(ep, model)
# 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
@@ -176,15 +221,24 @@ def setup_compare_routes(session_manager: SessionManager):
# `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))
resolved.append(
(
sid,
selected_model,
session_endpoint_url,
headers,
str(ep.id) if ep is not None else None,
"registered" if ep is not None else None,
)
)
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:
for sid, model, session_endpoint_url, headers, endpoint_id, provenance in resolved:
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
session_manager.create_session(
comparison_session = session_manager.create_session(
session_id=sid,
name=name,
endpoint_url=session_endpoint_url,
@@ -192,6 +246,14 @@ def setup_compare_routes(session_manager: SessionManager):
rag=False,
owner=user,
)
if provenance in {"registered", "direct"}:
persist_session_endpoint_provenance(
session_manager,
sid,
comparison_session,
model_endpoint_id=endpoint_id,
endpoint_provenance=provenance,
)
if headers:
s = session_manager.sessions.get(sid)
if s:
@@ -203,8 +265,8 @@ def setup_compare_routes(session_manager: SessionManager):
comp = Comparison(
id=comp_id,
prompt=prompt,
model_a=model_a,
model_b=model_b,
model_a=resolved[0][1],
model_b=resolved[1][1],
# 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
@@ -241,7 +303,8 @@ def setup_compare_routes(session_manager: SessionManager):
winner: str = Form(...), # "left", "right", or "tie"
):
"""Record the user's vote and reveal model names if blind."""
user = get_current_user(request)
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
@@ -283,15 +346,20 @@ def setup_compare_routes(session_manager: SessionManager):
@router.post("/record")
def record_comparison(request: Request, body: RecordVoteRequest):
"""Lightweight endpoint to record a comparison vote from the frontend."""
user = get_current_user(request)
require_chat_scope(request)
user = effective_user(request)
comp_id = str(uuid.uuid4())
model_a = body.models[0] if len(body.models) > 0 else ""
model_b = body.models[1] if len(body.models) > 1 else ""
models = list(body.models or [])
if is_bearer_principal(request):
models = _validate_bearer_compare_models(models, user)
model_a = models[0] if len(models) > 0 else ""
model_b = models[1] if len(models) > 1 else ""
# For N>2 models, store the full list as JSON in blind_mapping
if len(body.models) > 2:
blind_mapping = json.dumps({"models": body.models})
if len(models) > 2:
blind_mapping = json.dumps({"models": models})
else:
blind_mapping = None
@@ -320,7 +388,8 @@ def setup_compare_routes(session_manager: SessionManager):
@router.get("/history")
def list_comparisons(request: Request):
"""List past comparisons."""
user = get_current_user(request)
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
q = db.query(Comparison)
@@ -346,7 +415,8 @@ def setup_compare_routes(session_manager: SessionManager):
@router.delete("/{comp_id}")
def delete_comparison(request: Request, comp_id: str):
"""Delete a comparison and its ephemeral sessions."""
user = get_current_user(request)
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
+10 -1
View File
@@ -34,7 +34,7 @@ from fastapi import Query, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List
from src.auth_helpers import _auth_disabled, get_current_user
from src.auth_helpers import _auth_disabled, get_current_user, is_bearer_principal
from src.secret_storage import decrypt as _decrypt
logger = logging.getLogger(__name__)
@@ -420,6 +420,15 @@ def _require_auth(request: Request) -> str:
unconfigured mode are only honoured if they're coming from
localhost; everyone else gets 401.
"""
# The legacy email router uses one generic dependency for mailbox reads,
# drafts, AI helpers, and SMTP send. It has no per-route token-scope
# contract, so a bearer must not be allowed to enter it as the ``api``
# pseudo-user. Otherwise owner-scoped lookup can miss the token owner and
# fall through to process-wide legacy settings credentials below.
# Scope-aware integrations must use their dedicated route boundary.
if is_bearer_principal(request):
raise HTTPException(403, "API tokens must use a scope-aware email route")
u = get_current_user(request)
if u:
return u
+92 -38
View File
@@ -10,11 +10,19 @@ import uuid
from pathlib import Path
from typing import Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession
from src.auth_helpers import get_current_user, owner_filter, require_privilege
from src.auth_helpers import (
effective_user,
get_current_user,
is_bearer_principal,
owner_filter,
require_chat_scope,
require_non_bearer_request,
require_privilege,
)
from src.upload_limits import (
read_upload_limited,
GALLERY_UPLOAD_MAX_BYTES,
@@ -33,6 +41,13 @@ _SAM_STATE: Dict[str, Any] = {}
_GROUNDING_STATE: Dict[str, Any] = {}
def _gallery_owner(request: Request) -> Optional[str]:
"""Use the token owner for bearer calls and preserve the legacy seam otherwise."""
if is_bearer_principal(request):
return effective_user(request)
return get_current_user(request)
def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"):
if not image_b64:
raise HTTPException(400, "Missing image")
@@ -346,7 +361,10 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
def setup_gallery_routes() -> APIRouter:
router = APIRouter(tags=["gallery"])
router = APIRouter(
tags=["gallery"],
dependencies=[Depends(require_chat_scope)],
)
# ---- POST /api/gallery/upload ----
@router.post("/api/gallery/upload")
@@ -360,7 +378,7 @@ def setup_gallery_routes() -> APIRouter:
if not file or not hasattr(file, 'filename'):
raise HTTPException(400, "No file provided")
user = get_current_user(request)
user = _gallery_owner(request)
album_id = form.get("album_id") or None
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery upload")
@@ -434,7 +452,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/{image_id}/replace")
async def gallery_replace(request: Request, image_id: str):
"""Replace an existing gallery image file with a new one."""
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -479,7 +497,7 @@ def setup_gallery_routes() -> APIRouter:
"""Rename a gallery photo. Stores the new name in the `prompt`
column (which serves as the user-facing label for uploaded
photos that have no AI prompt)."""
user = get_current_user(request)
user = _gallery_owner(request)
data = await request.json()
new_name = (data.get("name") or "").strip()
if not new_name:
@@ -516,7 +534,7 @@ def setup_gallery_routes() -> APIRouter:
if angle not in (90, -90, 180, 270):
raise HTTPException(400, "Angle must be 90, -90, 180, or 270")
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -557,7 +575,10 @@ def setup_gallery_routes() -> APIRouter:
db.close()
# ---- POST /api/gallery/ai-upscale ----
@router.post("/api/gallery/ai-upscale")
@router.post(
"/api/gallery/ai-upscale",
dependencies=[Depends(require_non_bearer_request)],
)
async def gallery_ai_upscale(request: Request):
"""AI upscale using img2img with the diffusion server."""
import base64, httpx
@@ -601,7 +622,10 @@ def setup_gallery_routes() -> APIRouter:
return {"error": "Upscale request failed"}
# ---- POST /api/gallery/style-transfer ----
@router.post("/api/gallery/style-transfer")
@router.post(
"/api/gallery/style-transfer",
dependencies=[Depends(require_non_bearer_request)],
)
async def gallery_style_transfer(request: Request):
"""Style transfer using img2img with the diffusion server."""
import base64, httpx
@@ -651,7 +675,7 @@ def setup_gallery_routes() -> APIRouter:
@router.get("/api/gallery/tags")
async def gallery_tags(request: Request) -> Dict[str, Any]:
"""Return distinct tags across all active gallery images."""
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
q = db.query(GalleryImage.tags).filter(
@@ -683,7 +707,7 @@ def setup_gallery_routes() -> APIRouter:
offset: int = Query(0, ge=0),
limit: int = Query(24, ge=1, le=100),
) -> Dict[str, Any]:
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
# Distinct tags for filter UI
@@ -811,7 +835,7 @@ def setup_gallery_routes() -> APIRouter:
@router.get("/api/gallery/albums")
async def list_albums(request: Request):
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
q = db.query(GalleryAlbum)
@@ -850,7 +874,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums")
async def create_album(request: Request):
import uuid
user = get_current_user(request)
user = _gallery_owner(request)
data = await request.json()
name = (data.get("name") or "").strip()
if not name:
@@ -870,7 +894,7 @@ def setup_gallery_routes() -> APIRouter:
@router.get("/api/gallery/stats")
async def gallery_stats(request: Request):
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
from sqlalchemy import func
@@ -894,13 +918,16 @@ def setup_gallery_routes() -> APIRouter:
finally:
db.close()
@router.post("/api/gallery/ai-tag-batch")
@router.post(
"/api/gallery/ai-tag-batch",
dependencies=[Depends(require_non_bearer_request)],
)
async def ai_tag_batch(
request: Request,
album_id: Optional[str] = Query(None),
limit: int = Query(200),
):
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
q = db.query(GalleryImage).filter(
@@ -919,7 +946,7 @@ def setup_gallery_routes() -> APIRouter:
# ---- GET /api/gallery/{image_id} ----
@router.get("/api/gallery/{image_id}")
async def get_gallery_image(request: Request, image_id: str) -> Dict[str, Any]:
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
row = (
@@ -940,7 +967,7 @@ def setup_gallery_routes() -> APIRouter:
# ---- PATCH /api/gallery/{image_id} ----
@router.patch("/api/gallery/{image_id}")
async def patch_gallery_image(request: Request, image_id: str, req: GalleryPatch) -> Dict[str, Any]:
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -992,7 +1019,7 @@ def setup_gallery_routes() -> APIRouter:
# of a flood of individual downloads).
@router.post("/api/gallery/download-zip")
async def gallery_download_zip(request: Request):
user = get_current_user(request)
user = _gallery_owner(request)
if not user:
raise HTTPException(401, "Not authenticated")
try:
@@ -1047,7 +1074,7 @@ def setup_gallery_routes() -> APIRouter:
# AI-suggested values you never added.
@router.post("/api/gallery/clear-user-tags")
async def clear_gallery_user_tags(request: Request) -> Dict[str, Any]:
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
@@ -1072,7 +1099,7 @@ def setup_gallery_routes() -> APIRouter:
# "woman" have leaked into the gallery and you want them gone.
@router.post("/api/gallery/clear-ai-tags")
async def clear_gallery_ai_tags(request: Request, image_id: Optional[str] = Query(None)) -> Dict[str, Any]:
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
@@ -1099,7 +1126,7 @@ def setup_gallery_routes() -> APIRouter:
# Returns how many rows were touched + how many tags removed.
@router.post("/api/gallery/dedupe-tags")
async def dedupe_gallery_tags(request: Request) -> Dict[str, Any]:
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
@@ -1135,7 +1162,7 @@ def setup_gallery_routes() -> APIRouter:
# ---- DELETE /api/gallery/{image_id} ----
@router.delete("/api/gallery/{image_id}")
async def delete_gallery_image(request: Request, image_id: str) -> Dict[str, str]:
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -1254,7 +1281,10 @@ def setup_gallery_routes() -> APIRouter:
db.close()
# ---- POST /api/image/inpaint — proxy to diffusion server OR OpenAI ----
@router.post("/api/image/inpaint")
@router.post(
"/api/image/inpaint",
dependencies=[Depends(require_non_bearer_request)],
)
async def inpaint_proxy(request: Request):
"""Forward inpaint request. If the selected endpoint is OpenAI, re-shape
the request for /v1/images/edits (multipart, inverted mask). Otherwise
@@ -1512,7 +1542,10 @@ def setup_gallery_routes() -> APIRouter:
# scratch using the prompt", ignoring the source. Real img2img sends
# the image alongside a `strength` (denoising strength) and the model
# mixes that fraction of new noise into the existing pixels.
@router.post("/api/image/harmonize")
@router.post(
"/api/image/harmonize",
dependencies=[Depends(require_non_bearer_request)],
)
async def harmonize_image(request: Request):
"""Harmonize = img2img. The model preserves (1 - strength) of the
original and regenerates `strength` fraction. With strength ~0.4
@@ -1712,7 +1745,10 @@ def setup_gallery_routes() -> APIRouter:
"/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.")
# ---- POST /api/image/sharpen ----
@router.post("/api/image/sharpen")
@router.post(
"/api/image/sharpen",
dependencies=[Depends(require_non_bearer_request)],
)
async def sharpen_image(request: Request):
"""Apply unsharp-mask sharpening to an image."""
require_privilege(request, "can_generate_images")
@@ -1737,7 +1773,10 @@ def setup_gallery_routes() -> APIRouter:
# AI denoise via Real-ESRGAN with the realesr-general-x4v3 weights at
# outscale=1 + denoise_strength. Falls back to a "package missing"
# error so the client can prompt the user to install via Cookbook.
@router.post("/api/image/denoise")
@router.post(
"/api/image/denoise",
dependencies=[Depends(require_non_bearer_request)],
)
async def denoise_image(request: Request):
require_privilege(request, "can_generate_images")
body = await request.json()
@@ -1788,7 +1827,10 @@ def setup_gallery_routes() -> APIRouter:
# ---- POST /api/image/upscale-local ----
# Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion
# server required. Used by the editor's AI Upscale button.
@router.post("/api/image/upscale-local")
@router.post(
"/api/image/upscale-local",
dependencies=[Depends(require_non_bearer_request)],
)
async def upscale_image_local(request: Request):
require_privilege(request, "can_generate_images")
body = await request.json()
@@ -1834,7 +1876,10 @@ def setup_gallery_routes() -> APIRouter:
return {"error": "AI upscale failed"}
# ---- POST /api/image/remove-bg ----
@router.post("/api/image/mask")
@router.post(
"/api/image/mask",
dependencies=[Depends(require_non_bearer_request)],
)
async def smart_mask(request: Request):
"""Create a neutral segmentation mask from user-provided points or a box.
@@ -1960,7 +2005,10 @@ def setup_gallery_routes() -> APIRouter:
logger.exception("smart_mask failed")
raise HTTPException(500, f"SAM mask failed: {exc}") from exc
@router.post("/api/image/remove-bg")
@router.post(
"/api/image/remove-bg",
dependencies=[Depends(require_non_bearer_request)],
)
async def remove_background(request: Request):
"""Remove background from an image. If the client passes a `hint_mask`
(white-where-the-user-wants-the-subject PNG, same dims as the
@@ -2053,7 +2101,10 @@ def setup_gallery_routes() -> APIRouter:
return {"image": base64.b64encode(buf.getvalue()).decode()}
# ---- POST /api/image/enhance-face ----
@router.post("/api/image/enhance-face")
@router.post(
"/api/image/enhance-face",
dependencies=[Depends(require_non_bearer_request)],
)
async def enhance_face(request: Request):
"""Face/portrait enhancement. Uses GFPGAN if available, falls back to PIL."""
require_privilege(request, "can_generate_images")
@@ -2139,7 +2190,7 @@ def setup_gallery_routes() -> APIRouter:
@router.put("/api/gallery/albums/{album_id}")
async def update_album(request: Request, album_id: str):
user = get_current_user(request)
user = _gallery_owner(request)
data = await request.json()
db = SessionLocal()
try:
@@ -2160,7 +2211,7 @@ def setup_gallery_routes() -> APIRouter:
@router.delete("/api/gallery/albums/{album_id}")
async def delete_album(request: Request, album_id: str):
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
album = _get_or_404_album(db, album_id, user)
@@ -2176,7 +2227,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/add")
async def add_to_album(request: Request, album_id: str):
user = get_current_user(request)
user = _gallery_owner(request)
data = await request.json()
ids = data.get("image_ids", [])
db = SessionLocal()
@@ -2194,7 +2245,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/remove")
async def remove_from_album(request: Request, album_id: str):
user = get_current_user(request)
user = _gallery_owner(request)
data = await request.json()
ids = data.get("image_ids", [])
db = SessionLocal()
@@ -2215,7 +2266,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/{image_id}/favorite")
async def toggle_favorite(request: Request, image_id: str):
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
img = _get_or_404_image(db, image_id, user)
@@ -2227,13 +2278,16 @@ def setup_gallery_routes() -> APIRouter:
# ---- AI auto-tag ----
@router.post("/api/gallery/{image_id}/ai-tag")
@router.post(
"/api/gallery/{image_id}/ai-tag",
dependencies=[Depends(require_non_bearer_request)],
)
async def ai_tag_image(request: Request, image_id: str):
"""Send image to vision model for auto-tagging."""
import base64, httpx
from pathlib import Path
user = get_current_user(request)
user = _gallery_owner(request)
db = SessionLocal()
try:
img = _get_or_404_image(db, image_id, user)
+167 -54
View File
@@ -6,19 +6,31 @@ import logging
import re
from typing import Dict, Any, Optional
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, Depends, Request, HTTPException
from core.models import ChatMessage
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
from src.auth_helpers import effective_user
from src.auth_helpers import (
effective_user,
is_bearer_principal,
request_capability,
require_chat_scope,
)
from src.message_metadata import (
sanitize_client_message_metadata,
sanitize_projected_message_metadata,
normalize_client_message_role,
)
from src.topic_analyzer import analyze_topics
from src.upload_handler import reserve_message_upload_references
from src.session_provenance import persist_session_endpoint_provenance
from routes.session_routes import (
_message_role,
_message_text,
_reject_compact_during_active_run,
_verify_session_owner,
)
from routes.chat_helpers import _validate_bearer_session_model
logger = logging.getLogger(__name__)
@@ -26,6 +38,24 @@ _HISTORY_INLINE_MEDIA_THRESHOLD = 200_000
_DATA_IMAGE_RE = re.compile(r"data:image/[^;,\"]+;base64,[A-Za-z0-9+/=\s]+")
def _metadata_dict(value: Any) -> dict:
"""Return only mapping-shaped message metadata.
Legacy rows and client payloads can contain JSON lists/scalars. They are
display noise, not trusted fields, and must not reach ``dict.update`` or
approval projection code.
"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _history_display_content(content: Any) -> Any:
"""Return a lightweight browser-display copy of stored message content.
@@ -101,7 +131,7 @@ def _merge_continue_rows_to_delete(db_messages, db1, db2):
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
router = APIRouter(tags=["history"])
router = APIRouter(tags=["history"], dependencies=[Depends(require_chat_scope)])
def _reserve_message_uploads(
request: Request,
@@ -123,14 +153,19 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
f"Referenced upload is no longer available: {missing_id}",
)
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
def _display_metadata(value: Any, *, sanitize: bool) -> dict:
meta = _metadata_dict(value)
if sanitize:
return sanitize_projected_message_metadata(meta) or {}
return dict(meta)
def _db_history_entry(
m: DbChatMessage,
*,
sanitize: bool = False,
) -> Dict[str, Any]:
entry = {"role": m.role, "content": _history_display_content(m.content)}
meta = {}
if m.meta_data:
try:
meta = json.loads(m.meta_data) or {}
except (json.JSONDecodeError, ValueError):
meta = {}
meta = _display_metadata(m.meta_data, sanitize=sanitize)
if m.timestamp and "timestamp" not in meta:
meta["timestamp"] = m.timestamp.isoformat() + "Z"
if meta:
@@ -144,6 +179,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> Dict[str, Any]:
require_chat_scope(request)
sanitize_history = is_bearer_principal(request)
_verify_session_owner(request, session_id)
if limit is not None:
page_limit = max(1, min(int(limit), 100))
@@ -171,7 +208,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.all()
)
history_dict = [
entry for entry in (_db_history_entry(m) for m in rows)
entry
for entry in (
_db_history_entry(m, sanitize=sanitize_history)
for m in rows
)
if not (entry.get("metadata") or {}).get("hidden")
]
return {
@@ -197,21 +238,29 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
for msg in session.history:
if isinstance(msg, ChatMessage):
# Skip hidden messages (e.g. compaction summaries for AI context)
if msg.metadata and msg.metadata.get("hidden"):
msg_meta = _display_metadata(
msg.metadata,
sanitize=sanitize_history,
)
if msg_meta.get("hidden"):
continue
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
if msg.metadata:
entry["metadata"] = msg.metadata
if msg_meta:
entry["metadata"] = msg_meta
history_dict.append(entry)
elif isinstance(msg, dict):
if msg.get("metadata", {}).get("hidden"):
msg_meta = _display_metadata(
msg.get("metadata"),
sanitize=sanitize_history,
)
if msg_meta.get("hidden"):
continue
entry = {
"role": msg.get("role", ""),
"content": _history_display_content(msg.get("content", "")),
}
if msg.get("metadata"):
entry["metadata"] = msg["metadata"]
if msg_meta:
entry["metadata"] = msg_meta
history_dict.append(entry)
# Fallback: load from DB if in-memory renders empty. Display only —
@@ -229,7 +278,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
)
# Response excludes hidden messages, matching the in-memory path.
history_dict = [
entry for entry in (_db_history_entry(m) for m in db_messages)
entry
for entry in (
_db_history_entry(m, sanitize=sanitize_history)
for m in db_messages
)
if not (entry.get("metadata") or {}).get("hidden")
]
except Exception as e:
@@ -246,6 +299,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/truncate")
async def truncate_session(request: Request, session_id: str):
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -261,14 +315,15 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/message")
async def add_message(request: Request, session_id: str):
"""Add a message to a session (for slash command persistence)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
role = body.get("role", "assistant")
role = normalize_client_message_role(body.get("role", "assistant"))
content = body.get("content", "")
if not content:
raise HTTPException(400, "content is required")
metadata = body.get("metadata")
metadata = sanitize_client_message_metadata(body.get("metadata"))
_reserve_message_uploads(request, content, metadata)
msg = ChatMessage(role=role, content=content, metadata=metadata)
session_manager.add_message(session_id, msg)
@@ -279,6 +334,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/delete-messages")
async def delete_messages(request: Request, session_id: str):
"""Delete specific messages by DB ID (or legacy index)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -342,6 +398,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/edit-message")
async def edit_message(request: Request, session_id: str):
"""Edit the content of a message by its database ID."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -364,9 +421,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
db_msg.content = content
meta = {}
if db_msg.meta_data:
try: meta = json.loads(db_msg.meta_data)
except (json.JSONDecodeError, ValueError): pass
meta = _metadata_dict(db_msg.meta_data)
meta = dict(meta)
meta['edited'] = True
db_msg.meta_data = json.dumps(meta)
@@ -397,6 +453,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/mark-stopped")
async def mark_stopped(request: Request, session_id: str):
"""Mark the last assistant message as stopped by user."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -405,13 +462,13 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
if isinstance(msg, ChatMessage):
if not msg.metadata:
if not isinstance(msg.metadata, dict):
msg.metadata = {}
msg.metadata['stopped'] = True
if not msg.metadata.get('model'):
msg.metadata['model'] = session.model
else:
if 'metadata' not in msg:
if not isinstance(msg.get('metadata'), dict):
msg['metadata'] = {}
msg['metadata']['stopped'] = True
if not msg['metadata'].get('model'):
@@ -429,11 +486,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
)
if db_messages:
meta = {}
if db_messages.meta_data:
try:
meta = _json.loads(db_messages.meta_data)
except (json.JSONDecodeError, ValueError):
pass
meta = _metadata_dict(db_messages.meta_data)
meta = dict(meta)
meta['stopped'] = True
if not meta.get('model'):
meta['model'] = session.model
@@ -452,10 +506,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/update-last-meta")
async def update_last_meta(request: Request, session_id: str):
"""Merge metadata into the last assistant message (e.g. save variants)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
meta_update = body.get("metadata", {})
meta_update = sanitize_client_message_metadata(body.get("metadata", {})) or {}
session = session_manager.get_session(session_id)
# Update in-memory
@@ -463,11 +518,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
(isinstance(msg, dict) and msg.get('role') == 'assistant'):
if isinstance(msg, ChatMessage):
if not msg.metadata:
if not isinstance(msg.metadata, dict):
msg.metadata = {}
msg.metadata.update(meta_update)
else:
if 'metadata' not in msg:
if not isinstance(msg.get('metadata'), dict):
msg['metadata'] = {}
msg['metadata'].update(meta_update)
break
@@ -483,10 +538,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
.first()
)
if db_msg:
meta = {}
if db_msg.meta_data:
try: meta = _json.loads(db_msg.meta_data)
except (json.JSONDecodeError, ValueError): pass
meta = dict(_metadata_dict(db_msg.meta_data))
meta.update(meta_update)
db_msg.meta_data = _json.dumps(meta)
db.commit()
@@ -503,6 +555,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/merge-last-assistant")
async def merge_last_assistant(request: Request, session_id: str):
"""Merge the last two assistant messages into one (for continue)."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -527,8 +580,12 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
merged_content = content1 + separator + content2
# Merge metadata
meta1 = (msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')) or {}
meta2 = (msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')) or {}
meta1 = dict(_metadata_dict(
msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')
))
meta2 = dict(_metadata_dict(
msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')
))
merged_meta = {**meta1, **meta2}
merged_meta.pop('stopped', None) # no longer stopped after continue
@@ -592,6 +649,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/fork")
async def fork_session(request: Request, session_id: str):
"""Create a new session with messages copied up to keep_count."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
body = await request.json()
@@ -608,6 +666,15 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
if not source:
raise HTTPException(404, "Session not found")
source_provenance = getattr(source, "endpoint_provenance", None)
source_endpoint_id = getattr(source, "model_endpoint_id", None)
if (
is_bearer_principal(request)
and (hasattr(source, "endpoint_provenance") or hasattr(source, "model_endpoint_id"))
and source_provenance not in {"registered", "direct"}
):
raise HTTPException(400, "Session endpoint provenance is unavailable")
# Create new session
new_id = str(uuid.uuid4())
fork_name = f"\u2ADD {source.name}"
@@ -619,6 +686,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
rag=False,
owner=getattr(source, 'owner', None),
)
if source_provenance in {"registered", "direct"}:
persist_session_endpoint_provenance(
session_manager,
new_id,
new_session,
model_endpoint_id=source_endpoint_id,
endpoint_provenance=source_provenance,
)
# Copy messages up to keep_count
msgs_to_copy = source.history[:keep_count]
@@ -629,12 +704,15 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
# in-memory messages, corrupting their _db_id and breaking
# edit/delete-by-id on the original conversation.
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
if is_bearer_principal(request):
meta = sanitize_projected_message_metadata(meta)
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
try:
from src.event_bus import fire_event
fire_event("session_created", getattr(source, 'owner', None))
except Exception:
logger.debug("session_created event dispatch failed", exc_info=True)
if not is_bearer_principal(request):
try:
from src.event_bus import fire_event
fire_event("session_created", getattr(source, 'owner', None))
except Exception:
logger.debug("session_created event dispatch failed", exc_info=True)
return {
"status": "ok",
@@ -650,6 +728,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.get("/api/conversations/topics")
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
require_chat_scope(request)
from src.auth_helpers import require_user
user = require_user(request)
try:
@@ -665,6 +744,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
endpoint estimates the persisted session context so the header can show
when the whole chat is approaching compaction.
"""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -676,16 +757,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
messages = session.get_context_messages()
used = int(estimate_tokens(messages))
ctx_len = int(get_context_length(session.endpoint_url, session.model) or 0)
context_kwargs = {}
if not capability.allow_live_probes:
context_kwargs["allow_live_probes"] = False
ctx_len = int(get_context_length(
session.endpoint_url,
session.model,
**context_kwargs,
) or 0)
pct = round((used / ctx_len) * 100, 1) if ctx_len else 0.0
pct = max(0.0, min(100.0, pct))
visible_messages = sum(
1 for m in session.history
if not (getattr(m, "metadata", None) or {}).get("hidden")
if not _metadata_dict(getattr(m, "metadata", None)).get("hidden")
)
compacted_messages = sum(
1 for m in session.history
if (getattr(m, "metadata", None) or {}).get("compacted")
if _metadata_dict(getattr(m, "metadata", None)).get("compacted")
)
can_compact = used > 0
return {
@@ -709,6 +797,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
@router.post("/api/session/{session_id}/compact")
async def compact_session(request: Request, session_id: str):
"""Manually trigger context compaction for a session."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
from src.auth_helpers import effective_user
owner = effective_user(request)
@@ -721,12 +811,21 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
try:
from src.model_context import estimate_tokens, get_context_length
from src.llm_core import llm_call_async
from src.endpoint_resolver import resolve_endpoint
if len(session.history) < 6:
return {"status": "ok", "message": "Not enough messages to compact"}
ctx_len = get_context_length(session.endpoint_url, session.model)
if capability.is_bearer:
_validate_bearer_session_model(session, owner=owner)
context_kwargs = {}
if not capability.allow_live_probes:
context_kwargs["allow_live_probes"] = False
ctx_len = get_context_length(
session.endpoint_url,
session.model,
**context_kwargs,
)
messages_before = session.get_context_messages()
used_before = estimate_tokens(messages_before)
pct_before = round((used_before / ctx_len) * 100, 1) if ctx_len else 0
@@ -744,15 +843,26 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
for m in older
)
# Use utility model if available
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
compact_url = util_url or session.endpoint_url
compact_model = util_model or session.model
compact_headers = util_headers if util_url else session.headers
# Use the utility model only for interactive/live-capable callers.
# Bearer compaction remains on the selected session route.
if capability.allow_live_probes:
from src.endpoint_resolver import resolve_endpoint
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
compact_url = util_url or session.endpoint_url
compact_model = util_model or session.model
compact_headers = util_headers if util_url else session.headers
else:
compact_url = session.endpoint_url
compact_model = session.model
compact_headers = session.headers
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT, normalize_compaction_summary
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
compact_kwargs = {}
if not capability.allow_live_probes:
compact_kwargs["allow_live_probes"] = False
summary = await llm_call_async(
compact_url, compact_model,
[
@@ -761,6 +871,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
],
temperature=0.2, max_tokens=1024,
headers=compact_headers, timeout=30,
**compact_kwargs,
)
summary = normalize_compaction_summary(summary)
@@ -838,6 +949,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
"after": pct_after,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Manual compact error {session_id}: {e}")
raise HTTPException(500, str(e))
+19 -6
View File
@@ -5,10 +5,11 @@ import shlex
import subprocess
from copy import deepcopy
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from core.platform_compat import run_ssh_command
from routes._validators import validate_remote_host, validate_ssh_port
from src.auth_helpers import require_non_bearer_request
# Backends the manual hardware simulator accepts. Must stay a subset of what
@@ -180,24 +181,32 @@ def _inspect_model_path(model_path: str, host: str = "", ssh_port: str = "") ->
def setup_hwfit_routes():
router = APIRouter(prefix="/api/hwfit", tags=["hwfit"])
router = APIRouter(
prefix="/api/hwfit",
tags=["hwfit"],
dependencies=[Depends(require_non_bearer_request)],
)
@router.get("/system")
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False):
def get_system(host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, request: Request = None):
"""Detect and return current system hardware info. Pass host=user@server for remote.
fresh=true bypasses the per-host cache (the Rescan button)."""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
host, ssh_port = _validate_detection_target(host, ssh_port)
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
@router.get("/models")
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False, request: Request = None):
"""Rank LLM models against detected hardware and return scored results.
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
active group). gpu_group: index into system.gpu_groups (the homogeneous
pools) to target empty/auto = the largest pool. vLLM can only
tensor-parallel across identical GPUs, so we never mix pools.
fresh=true bypasses the hardware-detection cache."""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs
@@ -316,7 +325,7 @@ def setup_hwfit_routes():
return payload
@router.get("/profiles")
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = ""):
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = "", request: Request = None):
"""Compute llama.cpp serve profiles (Quality/Balanced/Speed) for `model`
against the detected hardware on `host` (or local). Returns concrete
flags (n_gpu_layers, n_cpu_moe, cache_type, ctx) the serve UI can apply.
@@ -325,6 +334,8 @@ def setup_hwfit_routes():
catalog (e.g. an ad-hoc HF repo), pass enough hints via a minimal synthetic
entry isn't possible here, so we return [] and the UI keeps manual flags.
"""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
from services.hwfit.models import get_models
from services.hwfit.profiles import compute_serve_profiles
@@ -410,8 +421,10 @@ def setup_hwfit_routes():
}
@router.get("/image-models")
def get_image_models(sort: str = "fit", search: str = "", host: str = "", gpu_count: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False):
def get_image_models(sort: str = "fit", search: str = "", host: str = "", gpu_count: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, request: Request = None):
"""Rank image generation models against detected hardware."""
if request is not None:
require_non_bearer_request(request)
from services.hwfit.hardware import detect_system
from services.hwfit.image_models import rank_image_models
host, ssh_port = _validate_detection_target(host, ssh_port)
+12 -2
View File
@@ -1,5 +1,5 @@
# routes/memory_routes.py
from fastapi import APIRouter, Form, HTTPException, Request, UploadFile, File
from fastapi import APIRouter, Depends, Form, HTTPException, Request, UploadFile, File
from typing import Dict, Any, Optional, List
import json
import os
@@ -53,9 +53,19 @@ def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
"""Set up memory-related routes."""
router = APIRouter(prefix="/api/memory", tags=["memory"])
router = APIRouter(
prefix="/api/memory",
tags=["memory"],
dependencies=[Depends(require_user)],
)
def _owner(request: Request) -> Optional[str]:
# Router dependencies do not run when a handler is called directly
# (including through an integration router), so keep the same bearer
# rejection at the owner-resolution seam. ``None`` is retained only
# for legacy unit callers; real ASGI requests always carry Request.
if request is not None:
require_user(request)
return get_current_user(request)
def _assert_session_owner(session_obj, user):
+111 -18
View File
@@ -29,7 +29,12 @@ from src.endpoint_resolver import (
build_models_url,
build_headers,
)
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
from src.auth_helpers import (
_auth_disabled,
is_bearer_principal,
owner_filter,
require_chat_scope,
)
logger = logging.getLogger(__name__)
@@ -1367,6 +1372,68 @@ def _picker_models_for_endpoint(ep, base_url: str, kind: str):
), pinned
def _validate_bearer_model_selection(
ep,
requested_model: Optional[str],
*,
allow_empty: bool = False,
) -> str:
"""Validate a bearer-selected model against the server-owned picker.
Bearer requests cannot perform a provider model probe. Their model choice
must therefore come from the same endpoint-local cache/pin inventory that
the server exposes to the model picker. ``allow_empty`` is used only by
default-chat, where an explicitly empty inventory has a deterministic empty
result rather than an implicit provider alias.
"""
if ep is None:
if allow_empty:
return ""
raise HTTPException(400, "A registered model endpoint is required")
base_url = _normalize_base(getattr(ep, "base_url", "") or "")
kind = _effective_endpoint_kind(ep, base_url)
models, _ = _picker_models_for_endpoint(ep, base_url, kind)
models = [model for model in models if isinstance(model, str) and model.strip()]
requested = str(requested_model or "").strip()
if not requested:
if models:
return models[0]
if allow_empty:
return ""
raise HTTPException(400, "No permitted model is configured for this endpoint")
# A registered local endpoint may intentionally have no persisted
# catalog: local models are operator-controlled and bearer requests
# must not discover them live. Preserve that documented compatibility
# path, while still enforcing any inventory that the server does own
# and rejecting explicitly hidden entries below.
raw_inventory = _merge_model_ids(
_normalize_model_ids(getattr(ep, "cached_models", None)),
_normalize_model_ids(getattr(ep, "pinned_models", None)),
)
hidden = set(_normalize_model_ids(getattr(ep, "hidden_models", None)))
if (
requested
and not raw_inventory
and _classify_endpoint(base_url, kind) == "local"
and requested not in hidden
):
return requested
if requested in models:
return requested
requested_base = os.path.basename(requested.rstrip("/"))
matches = [
model for model in models
if os.path.basename(model.rstrip("/")) == requested_base
]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise HTTPException(400, "Model selection is ambiguous for this endpoint")
raise HTTPException(400, f"Model is not permitted for this endpoint: {requested}")
def _api_key_fingerprint(api_key: Optional[str]) -> str:
"""Stable, non-secret label for distinguishing same-URL credentials."""
key = (api_key or "").strip()
@@ -1536,7 +1603,12 @@ def setup_model_routes(model_discovery):
_refresh_inflight["v"] = False
threading.Thread(target=_do, daemon=True).start()
def _fetch_models(owner: str = "", is_admin: bool = False):
def _fetch_models(
owner: str = "",
is_admin: bool = False,
*,
read_only: bool = False,
):
"""Return model list from cached data (instant). Background refresh keeps caches fresh.
SECURITY: filters endpoints by `owner` without this the picker
@@ -1551,7 +1623,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal()
try:
if _disable_stale_cookbook_local_endpoints(db):
if not read_only and _disable_stale_cookbook_local_endpoints(db):
_invalidate_models_cache()
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner and not is_admin:
@@ -1622,13 +1694,7 @@ def setup_model_routes(model_discovery):
# Require auth; "" is the unconfigured single-user mode, treated as
# "see everything" by _fetch_models.
try:
if getattr(request.state, "api_token", False):
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes:
raise HTTPException(403, "API token is not scoped for chat")
if not getattr(request.state, "api_token_owner", None):
raise HTTPException(403, "API token has no owner")
owner = effective_user(request) or ""
owner = require_chat_scope(request) or ""
# Reject anonymous in configured deployments — no leaking the model
# list to unauthenticated callers.
@@ -1640,6 +1706,17 @@ def setup_model_routes(model_discovery):
except Exception as e:
logger.error("Auth gate error in GET /api/models, failing closed: %s", e)
raise HTTPException(status_code=500, detail="Internal error")
bearer = is_bearer_principal(request)
if bearer and (refresh or background):
raise HTTPException(
403,
"API tokens may only read the owner-scoped cached model list",
)
if bearer:
# The bearer-compatible path is deliberately read-only: no global
# admin view, stale-row cleanup, process cache writes, background
# probes, stored endpoint credentials, or refresh state changes.
return _fetch_models(owner=owner, is_admin=False, read_only=True)
# Admins see every endpoint (they manage the global pool); regular
# users get the owner-scoped view.
_is_admin = False
@@ -2419,11 +2496,11 @@ def setup_model_routes(model_discovery):
# no per-user default yet, we resolve via the owner-scoped endpoint
# lookup below (last-resort: first enabled endpoint THIS user owns).
# Unauthenticated single-user mode keeps the old behavior.
from src.auth_helpers import get_current_user as _gcu
try:
_user = _gcu(request) or ""
except Exception:
_user = ""
# Resolve through the same owner/scope gate as the model picker. In an
# auth-disabled process there is no middleware to stamp token state, so
# raw bearer detection must still prevent a token from resolving
# global/admin defaults.
_user = require_chat_scope(request) or ""
# Admins resolve via the global defaults (they own them, and the
# scoped resolution was making the picker disappear for them).
# Regular users get per-user prefs with NO global fallback for the
@@ -2433,7 +2510,12 @@ def setup_model_routes(model_discovery):
_is_admin = False
try:
auth_mgr = getattr(request.app.state, "auth_manager", None)
if _user and auth_mgr is not None and getattr(auth_mgr, "is_admin", None):
if (
_user
and not is_bearer_principal(request)
and auth_mgr is not None
and getattr(auth_mgr, "is_admin", None)
):
_is_admin = bool(auth_mgr.is_admin(_user))
except Exception:
_is_admin = False
@@ -2481,7 +2563,13 @@ def setup_model_routes(model_discovery):
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
base = _normalize_base(ep.base_url)
chat_url = build_chat_url(base)
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
if is_bearer_principal(request):
model = _validate_bearer_model_selection(
ep,
model,
allow_empty=True,
)
elif not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
try:
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
if visible:
@@ -2692,8 +2780,13 @@ def setup_model_routes(model_discovery):
# ── Tool management ──
@router.get("/tools")
def list_tools():
def list_tools(request: Request):
"""List all available tools with their enabled/disabled status."""
# Tool inventory is an interactive/agent capability description, not
# part of the narrow bearer chat contract. Cookie/local callers retain
# the historical response.
from src.auth_helpers import require_non_bearer_request
require_non_bearer_request(request)
from src.agent_tools import TOOL_TAGS
settings = _load_settings()
disabled = set(settings.get("disabled_tools", []))
+7 -4
View File
@@ -9,12 +9,12 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field
from core.middleware import INTERNAL_TOOL_USER
from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user
from src.auth_helpers import _auth_disabled, require_interactive_request
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.constants import DEEP_RESEARCH_DIR
@@ -207,14 +207,17 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
router = APIRouter(tags=["research"])
router = APIRouter(
tags=["research"],
dependencies=[Depends(require_interactive_request)],
)
def _require_user(request: Request) -> str:
"""All research endpoints require an authenticated user. Research
data isn't owner-scoped in the on-disk JSON yet, so we at least
block anonymous access. Multi-tenant deploys should additionally
verify the session belongs to this user."""
user = get_current_user(request)
user = require_interactive_request(request)
if not user:
if _auth_disabled():
return ""
+12 -4
View File
@@ -3,13 +3,14 @@
import logging
from typing import Dict, Any
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Request
import time
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
from services.search.core import _call_provider
from services.search.providers import _get_provider_key, _get_search_instance
from src.auth_helpers import require_interactive_request
logger = logging.getLogger(__name__)
@@ -37,10 +38,14 @@ async def _request_values(request: Request) -> Dict[str, Any]:
def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
router = APIRouter(
tags=["search"],
dependencies=[Depends(require_interactive_request)],
)
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
async def get_search_settings(request: Request) -> Dict[str, Any]:
require_interactive_request(request)
return get_search_config()
@router.post("/api/search")
@@ -49,6 +54,7 @@ def setup_search_routes(config) -> APIRouter:
Used by Compare mode to pre-search once and share results across panes.
"""
require_interactive_request(request)
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
@@ -66,8 +72,9 @@ def setup_search_routes(config) -> APIRouter:
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/search/providers")
async def list_search_providers():
async def list_search_providers(request: Request):
"""Return available search providers with config status."""
require_interactive_request(request)
providers = []
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
if pid == "disabled":
@@ -87,6 +94,7 @@ def setup_search_routes(config) -> APIRouter:
@router.post("/api/search/query")
async def search_with_provider(request: Request) -> Dict[str, Any]:
"""Search using a specific provider. Used by compare search mode."""
require_interactive_request(request)
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
+195 -69
View File
@@ -4,17 +4,30 @@ import html
import json
import uuid
from datetime import datetime
from fastapi import APIRouter, Form, HTTPException, Response, Request
from fastapi import APIRouter, Depends, Form, HTTPException, Response, Request
import logging
from core.session_manager import SessionManager
from core.models import ChatMessage
from src.request_models import SessionResponse
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
from src.auth_helpers import (
effective_user,
_auth_disabled,
is_bearer_principal,
owner_filter,
request_capability,
require_chat_scope,
require_interactive_request,
)
from src.message_metadata import (
normalize_client_message_role,
sanitize_client_message_metadata,
)
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
from src.session_actions import is_session_recently_active
from src.upload_handler import reserve_message_upload_references
from src.session_provenance import persist_session_endpoint_provenance
def _sanitize_export_filename(name: str) -> str:
@@ -124,7 +137,11 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["sessions"])
router = APIRouter(
prefix="/api",
tags=["sessions"],
dependencies=[Depends(require_chat_scope)],
)
def _current_user_is_admin(request: Request, user: str | None) -> bool:
if not user:
@@ -153,7 +170,10 @@ def _reject_raw_endpoint_url_for_non_admin(
# Raw URLs make the server dial whatever host the request supplies. For
# non-admin users, require a saved endpoint row so normal owner scoping and
# endpoint validation have already happened.
if user and not _current_user_is_admin(request, user):
# A bearer may be attributed to an admin owner for storage and endpoint
# visibility, but it is still not an interactive admin principal. Raw
# endpoint URLs therefore remain unavailable to every bearer request.
if is_bearer_principal(request) or (user and not _current_user_is_admin(request, user)):
raise HTTPException(403, "Choose a registered model endpoint")
@@ -220,6 +240,7 @@ def setup_session_routes(
@router.get("/sessions")
def list_sessions(request: Request):
require_chat_scope(request)
user = effective_user(request)
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
@@ -231,32 +252,37 @@ def setup_session_routes(
# session is current and won't delete the live one — this server-side
# purge exists only to catch ghosts the frontend missed (tab close,
# crash). Only clean up rows old enough to be definitely orphaned.
try:
from datetime import timedelta as _td
_cutoff = utcnow_naive() - _td(minutes=10)
_purge_db = SessionLocal()
# Listing is an owner-scoped read for bearer integrations. The legacy
# incognito cleanup query has no owner predicate and would otherwise
# let a chat token mutate another user's stale sessions before the
# owner-filtered result is assembled. Browser cleanup remains intact.
if not is_bearer_principal(request):
try:
from core.database import ChatMessage as _DbMsg
_ghosts = _purge_db.query(DbSession).filter(
DbSession.name.in_(("Nobody", "Incognito")),
DbSession.created_at < _cutoff,
).all()
for _g in _ghosts:
if active_incognito_id and _g.id == active_incognito_id:
continue
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
_purge_db.delete(_g)
if hasattr(session_manager, "delete_session"):
try:
session_manager.delete_session(_g.id)
except Exception:
pass
if _ghosts:
_purge_db.commit()
finally:
_purge_db.close()
except Exception:
pass
from datetime import timedelta as _td
_cutoff = utcnow_naive() - _td(minutes=10)
_purge_db = SessionLocal()
try:
from core.database import ChatMessage as _DbMsg
_ghosts = _purge_db.query(DbSession).filter(
DbSession.name.in_(("Nobody", "Incognito")),
DbSession.created_at < _cutoff,
).all()
for _g in _ghosts:
if active_incognito_id and _g.id == active_incognito_id:
continue
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
_purge_db.delete(_g)
if hasattr(session_manager, "delete_session"):
try:
session_manager.delete_session(_g.id)
except Exception:
pass
if _ghosts:
_purge_db.commit()
finally:
_purge_db.close()
except Exception:
pass
user_sessions = session_manager.get_sessions_for_user(user)
# Fetch folder info from DB for each session
db = SessionLocal()
@@ -338,10 +364,14 @@ def setup_session_routes(
api_key: str = Form(""),
endpoint_id: str = Form(""),
):
require_chat_scope(request)
capability = request_capability(request)
probe_kwargs = {} if capability.allow_live_probes else {"allow_live_probes": False}
skip_val = str(skip_validation).lower() == "true"
user = effective_user(request)
endpoint_api_key = ""
endpoint_base_url = ""
endpoint_row = None
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
if endpoint_id and endpoint_id.strip():
from core.database import ModelEndpoint
@@ -375,7 +405,14 @@ def setup_session_routes(
from src.endpoint_resolver import build_headers
validation_headers = build_headers(effective_api_key, endpoint_base_url or endpoint_url)
if skip_val:
if is_bearer_principal(request) and endpoint_row is not None:
# Bearer requests are cache-only, but cache-only does not mean
# caller-authorized. Validate explicit selections and choose an
# empty selection deterministically from the server-owned picker.
from routes.model_routes import _validate_bearer_model_selection
model_to_use = _validate_bearer_model_selection(endpoint_row, model_to_use)
elif skip_val:
# skip_validation = trust the caller and do NOT probe /v1/models.
# Used for custom endpoints AND for bare placeholder sessions with no
# model at all (e.g. an email reply draft just needs a session to live
@@ -389,6 +426,7 @@ def setup_session_routes(
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
**probe_kwargs,
)
if not ids:
raise HTTPException(400, "Cannot reach /v1/models")
@@ -400,28 +438,35 @@ def setup_session_routes(
chat_ids = [m for m in ids if not any(p in m.lower() for p in _NON_CHAT)]
model_to_use = (chat_ids or ids)[0]
else:
from src.llm_core import list_model_ids
import os as _os
req_base = _os.path.basename(model_to_use.rstrip("/"))
avail = list_model_ids(
endpoint_url,
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
if not avail:
raise HTTPException(400, "Cannot reach /v1/models")
if model_to_use not in avail:
found = None
for a in avail:
if _os.path.basename(a.rstrip("/")) == req_base:
found = a
break
if not found:
raise HTTPException(400,
f"Model not found at server. Available: {', '.join(avail)}")
model_to_use = found
# A bearer with an explicit model is already using an owner-scoped
# registered endpoint (raw URLs are rejected above). Do not turn
# that synchronous session-creation request into a live catalog
# probe merely to validate a value the caller supplied. Interactive
# requests retain the existing catalog-backed validation.
if capability.allow_live_probes:
from src.llm_core import list_model_ids
import os as _os
req_base = _os.path.basename(model_to_use.rstrip("/"))
avail = list_model_ids(
endpoint_url,
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
**probe_kwargs,
)
if not avail:
raise HTTPException(400, "Cannot reach /v1/models")
if model_to_use not in avail:
found = None
for a in avail:
if _os.path.basename(a.rstrip("/")) == req_base:
found = a
break
if not found:
raise HTTPException(400,
f"Model not found at server. Available: {', '.join(avail)}")
model_to_use = found
sid = str(uuid.uuid4())
user = effective_user(request)
@@ -433,6 +478,20 @@ def setup_session_routes(
rag=str(rag).lower() == "true" if rag else False,
owner=user,
)
if endpoint_row is not None or request_api_key:
try:
persist_session_endpoint_provenance(
session_manager,
sid,
session,
model_endpoint_id=getattr(endpoint_row, "id", None),
endpoint_provenance=(
"registered" if endpoint_row is not None else "direct"
),
)
except Exception as exc:
logger.error("Failed to persist session endpoint provenance for %s: %s", sid, exc)
raise HTTPException(500, "Failed to persist session endpoint provenance") from exc
# Set auth headers for custom API-key endpoints
resolved_key = request_api_key
resolved_base = endpoint_url
@@ -443,14 +502,17 @@ def setup_session_routes(
from src.endpoint_resolver import build_headers
session.headers = build_headers(resolved_key, resolved_base)
_persist_session_headers(sid, session.headers)
# Fire webhook (sync-safe)
if webhook_manager:
webhook_manager.fire_and_forget("session.created", {
"session_id": sid, "name": session.name, "model": model_to_use,
})
# Fire event for automation tasks
from src.event_bus import fire_event
fire_event("session_created", user)
# A bearer can create owner-attributed chat data, but must not cause
# owner lifecycle automation or webhook delivery as a side effect.
if not is_bearer_principal(request):
# Fire webhook (sync-safe)
if webhook_manager:
webhook_manager.fire_and_forget("session.created", {
"session_id": sid, "name": session.name, "model": model_to_use,
})
# Fire event for automation tasks
from src.event_bus import fire_event
fire_event("session_created", user)
return SessionResponse(
id=sid,
name=session.name,
@@ -465,6 +527,7 @@ def setup_session_routes(
model: str = Form(None), endpoint_url: str = Form(None),
endpoint_id: str = Form(None),
):
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
session = session_manager.get_session(sid)
@@ -492,6 +555,7 @@ def setup_session_routes(
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
endpoint_api_key = ""
endpoint_base_url = ""
endpoint_row = None
if endpoint_id:
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
@@ -507,13 +571,23 @@ def setup_session_routes(
ep = q.first()
if not ep:
raise HTTPException(400, "Model endpoint no longer exists")
endpoint_row = ep
endpoint_base_url = ep.base_url or ""
endpoint_api_key = ep.api_key or ""
endpoint_url = build_chat_url(normalize_base(endpoint_base_url))
finally:
_db.close()
if is_bearer_principal(request) and endpoint_row is not None:
from routes.model_routes import _validate_bearer_model_selection
# Validate before mutating either the in-memory or durable
# session. The same server-owned inventory is enforced again
# immediately before each bearer LLM consumer.
model = _validate_bearer_model_selection(endpoint_row, model)
session.model = model
session.endpoint_url = endpoint_url
session.model_endpoint_id = getattr(endpoint_row, "id", None)
session.endpoint_provenance = "registered" if endpoint_row is not None else None
# Update auth headers from the endpoint's stored API key
if endpoint_api_key:
from src.endpoint_resolver import build_headers
@@ -528,6 +602,8 @@ def setup_session_routes(
db_session.model = model
db_session.endpoint_url = endpoint_url
db_session.headers = session.headers or {}
db_session.model_endpoint_id = getattr(endpoint_row, "id", None)
db_session.endpoint_provenance = "registered" if endpoint_row is not None else None
db_session.updated_at = utcnow_naive()
db.commit()
finally:
@@ -539,6 +615,7 @@ def setup_session_routes(
@router.post("/session/{sid}/inject_messages")
async def inject_messages(request: Request, sid: str):
"""Bulk-inject messages into a session's history (for group chat sync)."""
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
sess = session_manager.get_session(sid)
@@ -554,7 +631,7 @@ def setup_session_routes(
upload_handler,
owner,
message.get("content"),
message.get("metadata"),
sanitize_client_message_metadata(message.get("metadata")),
)
if missing_id:
raise HTTPException(
@@ -564,18 +641,24 @@ def setup_session_routes(
except (AttributeError, TypeError, ValueError) as exc:
raise HTTPException(400, "Invalid message attachment metadata") from exc
for m in messages:
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
sess.add_message(ChatMessage(
normalize_client_message_role(m.get("role", "user"), default="user"),
m["content"],
metadata=sanitize_client_message_metadata(m.get("metadata")),
))
session_manager.save_sessions()
return {"ok": True, "count": len(messages)}
@router.post("/session/{sid}/delete")
def delete_session_beacon(request: Request, sid: str):
"""Delete session via POST (for navigator.sendBeacon on page close)."""
require_chat_scope(request)
return delete_session(request, sid)
@router.post("/sessions/bulk-delete")
async def bulk_delete_sessions(request: Request):
"""Delete multiple sessions (for compare cleanup via sendBeacon)."""
require_chat_scope(request)
from core.database import ChatMessage as _CM
try:
body = await request.json()
@@ -605,6 +688,7 @@ def setup_session_routes(
@router.delete("/session/{sid}")
def delete_session(request: Request, sid: str):
"""Permanently delete a session and all its messages."""
require_chat_scope(request)
_verify_session_owner(request, sid, session_manager)
try:
# Block deletion of starred/favorited sessions
@@ -639,6 +723,7 @@ def setup_session_routes(
@router.delete("/sessions/all")
def delete_all_sessions(request: Request):
"""Admin only: permanently delete ALL sessions and their messages."""
require_chat_scope(request)
from core.middleware import require_admin
require_admin(request)
@@ -692,6 +777,7 @@ def setup_session_routes(
@router.post("/session/{sid}/archive")
def archive_session(request: Request, sid: str):
"""Archive a session, keeping its data but removing it from active sessions."""
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
# First check if session exists
@@ -730,6 +816,7 @@ def setup_session_routes(
@router.post("/session/{sid}/unarchive")
def unarchive_session(request: Request, sid: str):
"""Restore an archived session back to the active session list."""
require_chat_scope(request)
_verify_session_owner(request, sid)
db = SessionLocal()
try:
@@ -760,6 +847,7 @@ def setup_session_routes(
@router.get("/sessions/archived")
def list_archived_sessions(request: Request, search: str = "", offset: int = 0, limit: int = 20, sort: str = "recent", model: str = ""):
"""List archived sessions for the archive browser."""
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
@@ -807,6 +895,7 @@ def setup_session_routes(
Supported formats: md (markdown), txt (plain text), json, html
"""
require_chat_scope(request)
_verify_session_owner(request, sid)
try:
session = session_manager.get_session(sid)
@@ -893,6 +982,7 @@ def setup_session_routes(
@router.post("/sessions/save")
def sessions_save_now(request: Request):
require_chat_scope(request)
user = effective_user(request)
if not user:
raise HTTPException(401, "Not authenticated")
@@ -906,6 +996,15 @@ def setup_session_routes(
model: str = Form("gpt-4o"),
rag: str = Form(None)
):
require_chat_scope(request)
# This legacy alias uses the server-owned OPENAI_API_KEY rather than a
# caller-selected, owner-visible ModelEndpoint. A bearer must not turn
# that credential into an owner-attributed session whose provenance
# cannot be represented as either a registered endpoint or a direct
# caller-supplied key. Registered bearer chat remains available through
# POST /api/session with endpoint_id.
if is_bearer_principal(request):
raise HTTPException(403, "Bearer callers must choose a registered model endpoint")
if not OPENAI_API_KEY:
raise HTTPException(400, "Server missing OPENAI_API_KEY")
sid = str(uuid.uuid4())
@@ -920,13 +1019,15 @@ def setup_session_routes(
)
session.headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
session_manager.save_sessions()
from src.event_bus import fire_event
fire_event("session_created", user)
if not is_bearer_principal(request):
from src.event_bus import fire_event
fire_event("session_created", user)
return {"id": sid, "name": "", "model": model}
@router.post("/session/{session_id}/important")
async def mark_session_important(request: Request, session_id: str, important: bool = Form(True)):
"""Mark a session as important to protect it from automatic cleanup."""
require_chat_scope(request)
_verify_session_owner(request, session_id)
try:
# Validate session exists
@@ -964,6 +1065,8 @@ def setup_session_routes(
@router.post("/session/{session_id}/compact")
async def compact_session(request: Request, session_id: str):
"""Summarize older messages into one compacted history entry."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
try:
session = session_manager.get_session(session_id)
@@ -983,13 +1086,22 @@ def setup_session_routes(
if not older:
raise HTTPException(400, "Nothing old enough to compact")
if capability.is_bearer:
from routes.chat_helpers import _validate_bearer_session_model
_validate_bearer_session_model(session, owner=effective_user(request))
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
owner = getattr(session, "owner", None) or effective_user(request)
url, model, headers = resolve_endpoint("utility", owner=owner)
if not url or not model:
if capability.allow_live_probes:
from src.endpoint_resolver import resolve_endpoint
url, model, headers = resolve_endpoint("utility", owner=owner)
if not url or not model:
url, model, headers = session.endpoint_url, session.model, session.headers
else:
url, model, headers = session.endpoint_url, session.model, session.headers
if not url or not model:
raise HTTPException(400, "No model configured for compaction")
@@ -1008,6 +1120,9 @@ def setup_session_routes(
for m in older
)
try:
compact_kwargs = {}
if not capability.allow_live_probes:
compact_kwargs["allow_live_probes"] = False
summary = await llm_call_async(
url,
model,
@@ -1016,6 +1131,7 @@ def setup_session_routes(
max_tokens=1024,
headers=headers,
timeout=60,
**compact_kwargs,
)
except Exception as e:
logger.error("Manual compaction failed: %s", e)
@@ -1041,7 +1157,10 @@ def setup_session_routes(
"message_count": len(new_history),
}
@router.post("/sessions/auto-sort")
@router.post(
"/sessions/auto-sort",
dependencies=[Depends(require_interactive_request)],
)
def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders.
@@ -1050,6 +1169,8 @@ def setup_session_routes(
after Phase 1 used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens.
"""
require_chat_scope(request)
require_interactive_request(request)
from src.llm_core import llm_call
user = effective_user(request)
single_user_mode = not user and _auth_disabled()
@@ -1330,6 +1451,8 @@ def setup_session_routes(
@router.get("/session/{session_id}/context_info")
async def get_context_info(request: Request, session_id: str):
"""Get the real context length for a session's model from the endpoint."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id)
session = session_manager.get_session(session_id)
if not session:
@@ -1338,7 +1461,10 @@ def setup_session_routes(
return {"context_length": None}
try:
from src.model_context import get_context_length
ctx = get_context_length(session.endpoint_url, session.model)
context_kwargs = {}
if not capability.allow_live_probes:
context_kwargs["allow_live_probes"] = False
ctx = get_context_length(session.endpoint_url, session.model, **context_kwargs)
return {"context_length": ctx, "model": session.model}
except Exception:
return {"context_length": None}
+6
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Dict, Any
from core.platform_compat import IS_APPLE_SILICON, which_tool
from core.middleware import INTERNAL_TOOL_USER
from src.auth_helpers import is_bearer_principal
from src.host_docker_access import (
HOST_DOCKER_ACCESS_HINT,
host_docker_access_enabled as _host_docker_access_enabled,
@@ -53,6 +54,11 @@ from core.platform_compat import (
def _require_admin(request: Request):
"""Reject non-admin callers. Shell exec is admin-only — never expose to
regular users; that's RCE-after-signup."""
# This route predates the shared middleware helper and is also called
# directly by a few integration paths. Reject the credential class before
# trusting a caller-supplied current_user that might look administrative.
if is_bearer_principal(request):
raise HTTPException(403, "API tokens cannot use admin host-control surfaces")
auth_manager = getattr(request.app.state, "auth_manager", None)
if not auth_manager:
# No auth at all — only safe in fully-trusted localhost dev mode
+8 -4
View File
@@ -13,11 +13,11 @@ from typing import List, Optional
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user
from src.auth_helpers import require_interactive_request
from src.prompt_security import untrusted_context_message
from core.middleware import require_admin
@@ -1181,10 +1181,14 @@ async def run_scheduled_skill_audit(skills_manager: SkillsManager,
def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
router = APIRouter(prefix="/api/skills", tags=["skills"])
router = APIRouter(
prefix="/api/skills",
tags=["skills"],
dependencies=[Depends(require_interactive_request)],
)
def _owner(request: Request) -> Optional[str]:
return get_current_user(request)
return require_interactive_request(request)
def _verify_owner(skill: dict, user: Optional[str]):
if user is None:
+11 -3
View File
@@ -7,12 +7,12 @@ import uuid
from datetime import datetime
from typing import Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from core.database import SessionLocal, ScheduledTask, TaskRun
from core.constants import internal_api_base
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, require_interactive_request
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
from src.task_action_policy import (
ADMIN_ONLY_TASK_ACTIONS,
@@ -296,9 +296,17 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
def setup_task_routes(task_scheduler) -> APIRouter:
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
router = APIRouter(
prefix="/api/tasks",
tags=["tasks"],
dependencies=[Depends(require_interactive_request)],
)
def _owner(request: Request):
# Keep the route-local user lookup injectable for direct handler tests
# and legacy callers, but always run the centralized bearer-principal
# rejection first.
require_interactive_request(request)
return get_current_user(request)
async def _generate_task_name(prompt: str, owner: Optional[str] = None) -> str:
+28 -4
View File
@@ -6,7 +6,7 @@ import asyncio
import shutil
import uuid
from pathlib import Path
from fastapi import APIRouter, Request, File, UploadFile, HTTPException, Form
from fastapi import APIRouter, Depends, Request, File, UploadFile, HTTPException, Form
from typing import List, Optional
import logging
from core.middleware import require_admin
@@ -21,7 +21,12 @@ from core.database import (
Note,
Session as DbSession,
)
from src.auth_helpers import effective_user
from src.auth_helpers import (
effective_user,
is_bearer_principal,
require_chat_scope,
require_non_bearer_request,
)
from src.attachment_refs import attachment_refs_from_metadata
from src.constants import GENERATED_IMAGES_DIR
from src.upload_handler import (
@@ -32,7 +37,11 @@ from src.upload_handler import (
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/upload", tags=["upload"])
router = APIRouter(
prefix="/api/upload",
tags=["upload"],
dependencies=[Depends(require_chat_scope)],
)
UPLOAD_RESPONSE_HEADERS = {"X-Content-Type-Options": "nosniff"}
def _upload_ids_from_persisted_text(value: object) -> set[str]:
@@ -261,6 +270,7 @@ def setup_upload_routes(upload_handler):
session_id: Optional[str] = Form(None),
):
"""Upload files with enhanced security and organization."""
require_chat_scope(request)
if not isinstance(session_id, str):
session_id = None
if not files:
@@ -320,6 +330,7 @@ def setup_upload_routes(upload_handler):
@router.post("/cleanup")
async def manual_cleanup(request: Request):
"""Manually trigger cleanup of old uploads."""
require_chat_scope(request)
require_admin(request)
try:
cleaned_count = await asyncio.to_thread(
@@ -343,6 +354,7 @@ def setup_upload_routes(upload_handler):
@router.get("/stats")
async def upload_stats(request: Request):
"""Get statistics about uploaded files."""
require_chat_scope(request)
require_admin(request)
try:
return upload_handler.get_upload_stats()
@@ -355,6 +367,7 @@ def setup_upload_routes(upload_handler):
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
JPEG thumbnail for images (used by chat attachment previews) so the
client isn't downloading the full-resolution photo just to show it tiny."""
require_chat_scope(request)
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
import mimetypes as _mt
@@ -371,7 +384,14 @@ def setup_upload_routes(upload_handler):
auth_configured = bool(auth_mgr and auth_mgr.is_configured)
current_user = effective_user(request)
file_owner = info.get("owner") if info else None
if auth_configured:
if is_bearer_principal(request):
# A token owner is an owner-bound data principal, even when that
# owner is an administrator. Do not reuse the browser admin
# fallback for bearer downloads or an admin token can read another
# user's upload by ID.
if not current_user or file_owner != current_user:
raise HTTPException(404, "File not found")
elif auth_configured:
if not current_user:
raise HTTPException(403, "Access denied")
if file_owner != current_user and not auth_mgr.is_admin(current_user):
@@ -453,6 +473,8 @@ def setup_upload_routes(upload_handler):
"""Return the vision-model OCR/description for an uploaded image.
Cached under UPLOAD_DIR/.vision/{file_id}.txt first call computes,
subsequent loads are instant. Pass force=1 to recompute."""
require_chat_scope(request)
require_non_bearer_request(request)
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
info = _load_upload_info(file_id)
@@ -497,6 +519,8 @@ def setup_upload_routes(upload_handler):
async def put_vision_text(request: Request, file_id: str):
"""Persist a user-edited vision/OCR text for an attachment. Stored in
the same cache file so the chat send picks it up as the override."""
require_chat_scope(request)
require_non_bearer_request(request)
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
info = _load_upload_info(file_id)
+157 -41
View File
@@ -2,6 +2,7 @@
import uuid
import logging
import json
from typing import Optional
import httpx
@@ -9,9 +10,15 @@ from fastapi import APIRouter, HTTPException, Request, Form
from pydantic import BaseModel, Field
from core.database import SessionLocal, Webhook, ModelEndpoint
from src.auth_helpers import owner_filter
from src.auth_helpers import (
is_bearer_principal,
owner_filter,
request_capability,
require_chat_scope,
)
from src.url_security import validate_public_http_url
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
from src.session_provenance import persist_session_endpoint_provenance
logger = logging.getLogger(__name__)
@@ -32,14 +39,15 @@ def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
let a chat-scoped token fall back onto another user's private endpoint and
silently spend that owner's API key/quota. Prefer owner rows before shared
rows. Fails closed to null-owner rows only when token_owner is absent.
rows. Fails closed when token_owner is absent; the sync endpoint requires
an owner-scoped bearer before this helper is reached.
Does not validate base_url admin-configured local/LAN endpoints remain allowed.
"""
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
if token_owner:
query = owner_filter(query, ModelEndpoint, token_owner)
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
if not token_owner:
return None
query = owner_filter(query, ModelEndpoint, token_owner)
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
def _caller_owns_session(sess_owner, caller) -> bool:
@@ -61,6 +69,89 @@ def _caller_owns_session(sess_owner, caller) -> bool:
return sess_owner == caller
def _cached_endpoint_model_ids(endpoint) -> list[str]:
"""Return model IDs already stored for a configured endpoint.
The synchronous bearer integration may use a cached model or the provider's
``auto`` alias, but it must not turn an ordinary chat request into a remote
catalog probe. Malformed/legacy cache shapes are treated as empty.
"""
try:
from routes.model_routes import _effective_endpoint_kind, _picker_models_for_endpoint
base_url = getattr(endpoint, "base_url", "") or ""
kind = _effective_endpoint_kind(endpoint, base_url)
models, _ = _picker_models_for_endpoint(endpoint, base_url, kind)
return models
except Exception:
raw = getattr(endpoint, "cached_models", None)
pinned_raw = getattr(endpoint, "pinned_models", None)
hidden_raw = getattr(endpoint, "hidden_models", None)
if not raw and not pinned_raw:
return []
try:
value = json.loads(raw) if isinstance(raw, str) else raw
pinned = json.loads(pinned_raw) if isinstance(pinned_raw, str) else pinned_raw
hidden = json.loads(hidden_raw) if isinstance(hidden_raw, str) else hidden_raw
except (TypeError, ValueError):
return []
if isinstance(value, dict):
value = value.get("data") or value.get("models") or []
if isinstance(pinned, dict):
pinned = pinned.get("data") or pinned.get("models") or []
if isinstance(hidden, dict):
hidden = hidden.get("data") or hidden.get("models") or []
if not isinstance(value, list):
value = []
if not isinstance(pinned, list):
pinned = []
if not isinstance(hidden, list):
hidden = []
raw_ids = value + pinned
hidden_ids = {str(item).strip() for item in hidden if str(item).strip()}
ids = []
for item in raw_ids:
if isinstance(item, str) and item.strip():
model_id = item.strip()
elif isinstance(item, dict):
model_id = item.get("id") or item.get("name") or item.get("model")
if not isinstance(model_id, str) or not model_id.strip():
continue
model_id = model_id.strip()
else:
continue
if model_id not in hidden_ids and model_id not in ids:
ids.append(model_id)
return ids
def _validate_bearer_sync_model(endpoint, requested_model: str) -> str:
"""Validate a configured sync model without probing its provider."""
try:
from routes.model_routes import _validate_bearer_model_selection
return _validate_bearer_model_selection(endpoint, requested_model)
except ImportError:
# Keep the lightweight webhook test/import seam usable when optional
# route modules are deliberately stubbed. Production uses the
# central picker validator above; this fallback remains cache-only.
models = _cached_endpoint_model_ids(endpoint)
requested = str(requested_model or "").strip()
if requested and requested in models:
return requested
if not requested and models:
return models[0]
if (
requested
and not models
and not getattr(endpoint, "cached_models", None)
and not getattr(endpoint, "pinned_models", None)
and "localhost" in str(getattr(endpoint, "base_url", "")).lower()
):
return requested
raise HTTPException(400, "Model is not permitted for this endpoint")
def setup_webhook_routes(
webhook_manager: WebhookManager,
auth_manager,
@@ -236,16 +327,16 @@ def setup_webhook_routes(
@router.post("/v1/chat")
async def sync_chat(request: Request, body: SyncChatRequest):
if not getattr(request.state, "api_token", False):
if getattr(request.state, "api_token", False) is not True:
raise HTTPException(403, "This endpoint requires an API token")
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if "chat" not in scopes:
raise HTTPException(403, "API token is not scoped for chat")
token_owner = getattr(request.state, "api_token_owner", None)
token_owner = require_chat_scope(request)
capability = request_capability(request)
if not token_owner:
raise HTTPException(403, "API token has no owner")
from core.models import ChatMessage
from src.llm_core import llm_call_async
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, normalize_base
message = body.message.strip()
if not message:
@@ -275,6 +366,12 @@ def setup_webhook_routes(
_sess_owner = getattr(sess, "owner", None)
if not _caller_owns_session(_sess_owner, _tok_user):
raise HTTPException(404, "Session not found")
if is_bearer_principal(request):
from routes.chat_helpers import _validate_bearer_session_model
# Existing-session resume is an LLM boundary too; ownership
# alone must not authorize the persisted endpoint/model.
_validate_bearer_session_model(sess, owner=token_owner)
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
if not sess and body.api_key:
@@ -307,6 +404,12 @@ def setup_webhook_routes(
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
model=model, owner=token_owner,
)
persist_session_endpoint_provenance(
session_manager,
sid,
sess,
endpoint_provenance="direct",
)
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid
@@ -326,39 +429,27 @@ def setup_webhook_routes(
base_url = normalize_base(ep.base_url)
endpoint_url = build_chat_url(base_url)
model = body.model or "auto"
model = body.model or ""
api_key = ep.api_key
if getattr(ep, "provider_auth_id", None):
try:
from src.endpoint_resolver import resolve_endpoint_runtime
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
runtime_kwargs = {}
if not capability.allow_live_probes:
runtime_kwargs["allow_live_probes"] = False
base_url, api_key = resolve_endpoint_runtime(
ep,
owner=token_owner,
**runtime_kwargs,
)
endpoint_url = build_chat_url(base_url)
except Exception:
raise HTTPException(500, "Could not resolve endpoint credentials")
if model == "auto":
try:
async with httpx.AsyncClient(timeout=5) as client:
models_url = build_models_url(base_url)
hdrs = build_headers(api_key, base_url)
if models_url:
resp = await client.get(models_url, headers=hdrs)
resp.raise_for_status()
data = resp.json()
items = data if isinstance(data, list) else (data.get("data") or [])
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
if not ids and isinstance(data, dict):
ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
import json as _json
ids = _json.loads(ep.cached_models or "[]")
model = ids[0] if ids else "auto"
except Exception:
raise HTTPException(500, "Could not discover models from endpoint")
# This route is bearer-only. Explicit and empty selections both
# use the same server-owned, cache-only picker inventory; an empty
# inventory is an error rather than an implicit provider alias.
model = _validate_bearer_sync_model(ep, model)
if not session_manager:
raise HTTPException(500, "Session manager not available")
@@ -368,27 +459,52 @@ def setup_webhook_routes(
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
model=model, owner=token_owner,
)
endpoint_id = getattr(ep, "id", None)
if endpoint_id:
persist_session_endpoint_provenance(
session_manager,
sid,
sess,
model_endpoint_id=endpoint_id,
endpoint_provenance="registered",
)
if api_key:
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid
# --- Send message and get response ---
if is_bearer_principal(request):
from routes.chat_helpers import _validate_bearer_session_model
# The fallback branch has just created the session, so it did not
# pass through the existing-session gate above. Recheck the
# durable endpoint identity immediately before the LLM boundary
# for every bearer path, including malformed endpoint rows.
_validate_bearer_session_model(sess, owner=token_owner)
sess.add_message(ChatMessage("user", message))
messages = [{"role": m.role, "content": m.content} for m in sess.history]
llm_kwargs = {}
if not capability.allow_live_probes:
llm_kwargs["allow_live_probes"] = False
reply = await llm_call_async(
sess.endpoint_url, sess.model, messages,
headers=sess.headers, timeout=120,
**llm_kwargs,
)
sess.add_message(ChatMessage("assistant", reply))
session_manager.save_sessions()
webhook_manager.fire_and_forget("chat.completed", {
"session_id": session_id, "model": sess.model,
"user_message": message[:2000], "response": reply[:2000],
})
# /api/v1/chat remains a synchronous bearer integration: the response
# is returned normally, but the token must not fan that content out to
# an owner-configured asynchronous callback after authorization ends.
if not is_bearer_principal(request):
webhook_manager.fire_and_forget("chat.completed", {
"session_id": session_id, "model": sess.model,
"user_message": message[:2000], "response": reply[:2000],
})
return {"response": reply, "session_id": session_id, "model": sess.model}
+3 -1
View File
@@ -2,7 +2,7 @@
import os
from fastapi import APIRouter, Request, HTTPException, Query
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, require_non_bearer_request
from src.tool_security import owner_is_admin_or_single_user
# Cap entries returned per directory (mirrors filesystem_tools._CODENAV_MAX_HITS).
@@ -24,6 +24,7 @@ def setup_workspace_routes():
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
be able to map the host's directory tree either.
"""
require_non_bearer_request(request)
owner = get_current_user(request)
if not owner_is_admin_or_single_user(owner):
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
@@ -75,6 +76,7 @@ def setup_workspace_routes():
instead of being stored client-side and silently dropped at chat time.
Admin-gated like /browse: it confirms path existence on the host.
"""
require_non_bearer_request(request)
owner = get_current_user(request)
if not owner_is_admin_or_single_user(owner):
raise HTTPException(status_code=403, detail="Workspace selection is admin-only")
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Encode a source screen-recording (.mkv) into web-optimized preview clips for
# the landing page: docs/<name>.webm (VP9) + docs/<name>.mp4 (H.264).
# the landing page: website/<name>.webm (VP9) + website/<name>.mp4 (H.264).
#
# ./encode_previews.sh <input> <name> [max_secs]
#
@@ -13,7 +13,7 @@ set -euo pipefail
IN="${1:?input file}"
NAME="${2:?output basename}"
MAX="${3:-30}"
OUT_DIR="$(cd "$(dirname "$0")/../docs" && pwd)"
OUT_DIR="$(cd "$(dirname "$0")/../website" && pwd)"
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$IN" | cut -d. -f1)
dur=${dur:-0}
+88
View File
@@ -0,0 +1,88 @@
# Specs DocumentMap
Last updated: dev@e71f8ce | 2026-08-25
This folder is the compact implementation-truth map for humans and coding agents working on Odysseus. Read this file first, then open only the subsystem specs that match the work.
Specs are living notes about current code shape and intended contracts. They are not product marketing, not PR planning, not templates, and not a replacement for source inspection or tests.
This `_readme.md` is the DocumentMap and control document. It is intentionally exempt from subsystem `Scope` and `Current Gaps` sections; keep it limited to the quality contract, working rules, subsystem map, and cross-cutting update triggers.
## Quality Contract
Each subsystem spec should stay compact and useful under context pressure:
- Start with `Last updated: dev@<short-sha> | YYYY-MM-DD`, using the
upstream `dev` commit the spec text was inspected against.
- Use a concrete `Scope` section that names real files, route surfaces, frontend modules, data stores, and integration points.
- Use domain-specific sections. Do not force every spec into the same headings when the subsystem needs `Streaming`, `Tool Results`, `Optional Dependencies`, `Current Gaps`, or another focused section.
- State ownership clearly: which file owns a mapping, which layer only forwards state, and which caller requests behavior without owning implementation.
- Include runtime behavior bullets for flows that matter.
- Include "Current call sites include" when behavior is spread across many files.
- Record transitional compatibility notes, especially `src/` versus `services/` duplication.
- Record degraded, optional, or platform behavior where it changes runtime expectations.
- Record policy/provenance where relevant: untrusted context, encrypted secrets, API token scopes, optional dependency/license implications, generated media, or user data.
- End with `Current Gaps` only when there is a real known gap, not as filler.
If code and specs disagree, treat code as ground truth. Update specs only when
the current task explicitly includes spec maintenance or the PR intentionally
includes specs; otherwise report the drift in the relevant issue, PR review, or
project documentation.
## Working Rules
- Start here before substantial work.
- Read the related subsystem spec before changing code in that area. For cross-cutting work, include the owning domain spec plus route/runtime, auth/security, persistence, frontend, tool/context, integration, and testing/devops specs as applicable.
- Treat specs as read-only context during ordinary project work, PR review, and code review. Do not edit specs unless the user explicitly asks for spec work or the current PR intentionally includes spec changes.
- During explicit spec-maintenance work, update the related spec when source inspection shows behavior, ownership, security boundaries, data shape, import paths, or implementation contracts have changed.
- During ordinary work, record source/spec drift in the relevant issue, PR review, or project documentation instead of mutating specs.
- Keep specs dense but readable. Prefer current facts and invariants over broad explanation.
- Every non-index `specs/*.md` file should appear exactly once in the Subsystem Map with a one-line description and no dead link.
- Specs contain implementation truth. Planning, research, branch notes, and decisions belong in tracked project docs. Drafts, audit reports, raw exports, and exploratory gap lists are not authoritative until promoted into tracked docs or specs.
- Use repo source and these specs as the authority for Odysseus architecture. Do not treat global skill registries or external agent metadata as repo ground truth.
## Subsystem Map
- [runtime.md](runtime.md): FastAPI startup, router registration, static serving, lifespan, app-wide middleware.
- [auth-security.md](auth-security.md): auth, privileges, API tokens, security headers, untrusted data, SSRF and admin boundaries.
- [persistence.md](persistence.md): SQLite models, startup migrations, encrypted columns, ownership columns, data directory rules.
- [chat.md](chat.md): chat routes, sessions, streaming, uploads-in-chat, compare handoff, research/chat mode dispatch.
- [compare.md](compare.md): model A/B comparison runs, voting/history, compare frontend panes, compare ownership.
- [llm-models.md](llm-models.md): LLM provider calls, endpoint discovery, model context length, fallbacks, model endpoints.
- [model-capability-canonical.md](model-capability-canonical.md): canonical provider/model capability shapes, evidence, payload resolution, and safe fallback.
- [model-quirks.md](model-quirks.md): model-specific behavior observations, evidence, and promotion gates.
- [model-providers/_readme.md](model-providers/_readme.md): provider-by-provider API/catalog shape index and compatibility status.
- [agent-tools.md](agent-tools.md): agent loop, tool schemas, tool execution, tool retrieval, tool security, MCP tool exposure.
- [context-building.md](context-building.md): URL/search/RAG/memory/skills/YouTube/email/tool-output context, untrusted wrapping, unavailable context, intent boundaries.
- [search.md](search.md): web search providers, ranking, cache/analytics, URL fetch/content extraction, `src.search`/`services.search` split.
- [documents-rag-uploads.md](documents-rag-uploads.md): uploads, documents, PDF/form handling, personal docs, RAG/vector stores.
- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill extraction/formatting, owner isolation.
- [research.md](research.md): deep research jobs, synthesis, sources, research library, research UI panel.
- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, notes/todos.
- [email-contacts.md](email-contacts.md): IMAP/SMTP email, email library, scheduled mail, contacts/CardDAV.
- [gallery-editor-media.md](gallery-editor-media.md): gallery, generated media, image editor drafts, signatures, emoji/font helpers.
- [cookbook-hwfit.md](cookbook-hwfit.md): model downloads, local/remote model serving, hardware detection, fit ranking.
- [speech.md](speech.md): STT and TTS services, routes, settings, optional dependencies.
- [frontend.md](frontend.md): static SPA, module loading, UI conventions, major JS areas, no-build frontend shape.
- [integrations.md](integrations.md): Codex/Claude scoped APIs, companion pairing, webhooks, external agent access.
- [shell-mcp.md](shell-mcp.md): shell execution, background jobs, MCP manager, built-in MCP servers.
- [settings-admin.md](settings-admin.md): settings, preferences, presets, backup/import/export, diagnostics, admin wipe.
- [testing-devops.md](testing-devops.md): pytest, JS tests, Docker, scripts, requirements, local dev expectations.
## Cross-Cutting Spec Update Triggers
Use these triggers only during explicit spec-maintenance work or a PR that
intentionally includes specs. For ordinary work and code review, use the same
list to choose which specs to read and where to report drift.
- New route file or route prefix: update [runtime.md](runtime.md) and the owning subsystem spec.
- New SQLAlchemy model, column migration, durable JSON/local store, data directory, backup/import domain, or non-SQL persistence behavior: update [persistence.md](persistence.md) and the owning subsystem spec.
- New tool, tool schema, agent prompt rule, or tool security behavior: update [agent-tools.md](agent-tools.md) and [context-building.md](context-building.md) if it adds model context.
- New MCP runtime/config/built-in behavior: update [shell-mcp.md](shell-mcp.md), [agent-tools.md](agent-tools.md), and [context-building.md](context-building.md) when MCP tool results enter model context.
- New external content source, tool result, MCP/app API result, or integration result shown to an LLM: update [context-building.md](context-building.md) and [auth-security.md](auth-security.md).
- New API-token scope, scoped external API, webhook, companion/pairing route, generic integration provider, or external-agent helper bundle: update [integrations.md](integrations.md), [auth-security.md](auth-security.md), and the owning subsystem spec.
- New secret store, decrypted-secret return path, settings backup/import/export behavior, diagnostics/log output, vault/tool secret flow, `.env*` policy change, or credential-bearing CLI output: update [auth-security.md](auth-security.md), [settings-admin.md](settings-admin.md), [testing-devops.md](testing-devops.md), and the owning subsystem spec.
- New optional dependency, degraded fallback, platform/Docker/native/launcher difference, GPU overlay behavior, or retired compatibility shim: update [testing-devops.md](testing-devops.md) and the owning subsystem spec; also update [runtime.md](runtime.md), [llm-models.md](llm-models.md), [shell-mcp.md](shell-mcp.md), [cookbook-hwfit.md](cookbook-hwfit.md), or [persistence.md](persistence.md) when that layer owns the behavior.
- New frontend module or modal/tool surface: update [frontend.md](frontend.md) and the owning subsystem spec.
- New static/PWA/service-worker/cache/CSP behavior: update [frontend.md](frontend.md), [runtime.md](runtime.md), and [auth-security.md](auth-security.md) when headers or trust boundaries change.
- New CLI script: update [testing-devops.md](testing-devops.md) and the owning subsystem spec.
+157
View File
@@ -0,0 +1,157 @@
# Agent Tools
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers agent/tool behavior in:
- `src/agent_loop.py`;
- `src/llm_core.py`;
- `src/tool_schemas.py`;
- `src/tool_execution.py`;
- `src/tool_policy.py`;
- `src/tool_index.py`;
- `src/tool_parsing.py`;
- `src/tool_security.py`;
- `src/tool_capabilities.py`;
- `src/tool_approval_scopes.py`;
- `src/tool_approvals.py`;
- `src/attachment_refs.py` and shared upload lifecycle helpers in
`src/upload_handler.py` / `src/tool_utils.py`;
- `src/tool_implementations.py`;
- `src/tools/*.py`;
- `src/builtin_actions.py`;
- `src/ai_interaction.py`;
- `src/action_intents.py`;
- `src/goal_based_extractor.py`;
- `src/teacher_escalation.py`;
- `src/agent_tools/` modules and compatibility facade;
- `src/mcp_manager.py`;
- `src/builtin_mcp.py`;
- `src/bg_jobs.py` and `src/bg_monitor.py`;
- `routes/chat_routes.py`, `routes/chat_helpers.py`, `routes/model_routes.py`, `routes/skills_routes.py`, canonical `routes/mcp/mcp_routes.py` plus its shim, and `routes/workspace_routes.py`;
- `mcp_servers/*.py`;
- frontend stream/admin/settings files that display tool events, workspaces, and disabled tools;
- `tests/test_agent_loop.py`, `tests/test_tool_*`, and focused MCP/public-policy/schema tests.
## Agent Loop
`src.agent_loop` owns agent prompt assembly, request-local current date/time insertion, tool retrieval, prompted tool-block handling, native tool-call consumption after `llm_core` normalizes provider events, multi-round execution, tool result insertion, final metrics, and fallback responses. It requests context from documents, skills, tool retrieval, and messages; it should not own domain-specific business logic for every tool. Its prompt rules now bias structured/long-form writing toward living documents, route active compose/email drafts back into existing email documents, and prefer first-class `web_search`/`web_fetch` tools over shell/Python/curl for current web lookups when web tools are enabled.
`src.llm_core` owns provider payloads, native tool-schema emission, and provider stream parsing. `agent_loop` consumes normalized tool-call events and decides whether and how to execute them.
Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering.
Guide-only/no-tools turns are runtime policy, not prompt advice. `src.tool_policy` detects strong latest-turn directives such as guide-only mode, no-tools mode, and explicit requests not to use tools; it builds a `ToolPolicy` that hides schemas, disables known native tools, disables MCP for that turn, skips tool retrieval, suppresses local/workspace context injection, blocks document streaming/teacher escalation, and gives `tool_execution` a final execution backstop.
Plan mode is a read-only investigation path inside the same loop. It adds a denylist for known mutating tools, filters write/unknown MCP tools, prepends plan-mode instructions, and uses the `update_plan` tool only after a plan is approved for execution. The backend path still exists for compatibility, but current browser chat forces incoming `plan_mode` off and the old plan-window UI module is gone.
Workspace mode is request-scoped. Admin chat can send a workspace directory selected through `static/js/workspace.js`; `agent_loop` injects that fact early in the prompt and `tool_execution` confines bash, python, read/write/edit-file, and code-navigation tools to that root. `routes.workspace_routes` owns admin-only browse/vet APIs, skips hidden/symlink directory traversal, caps listings, and rejects sensitive/root paths before a workspace reaches chat.
## Tool Registry
Tool registration is split:
- `src.agent_tools` is now a package/facade. `TOOL_HANDLERS` maps native tool names to handler functions across filesystem, subprocess, web, document, interaction, model-interaction, background-job, session, and admin modules, while `TOOL_TAGS` keeps compatibility metadata and the global MCP manager handle;
- `src.tools` owns domain do_* implementations for calendar, contacts, Cookbook, image, notes, research, search, system, and vault tools. `src.tool_implementations` is now a compatibility facade that re-exports those symbols and lazy-loads admin manage_* symbols to avoid circular imports;
- `src.agent_tools.admin_tools` owns admin manage_* tools for endpoints, MCP, webhooks, tokens, and settings, including command validation for `manage_mcp`;
- `src.tool_parsing._TOOL_NAME_MAP` owns aliases and prompted-block parsing;
- `src.tool_schemas.FUNCTION_TOOL_SCHEMAS` and `function_call_to_tool_block()` own native schema and native-call conversion;
- `src.tool_index.BUILTIN_TOOL_DESCRIPTIONS` owns retrieval text;
- `src.tool_execution.execute_tool_block()` owns dispatch and hard execution gates;
- `routes.model_routes.py` and frontend settings/admin surfaces expose global disabled-tool controls.
When adding, removing, or renaming a tool, update the registry chain, execution dispatch, retrieval text, prompt wording, disabled-tool UI, and tests together.
`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Current prompt/schema assembly preserves only selected base tools unconditionally, then adds intent-, skill-, and retrieval-relevant tools so unrelated schemas do not flood small contexts.
## Tool Retrieval And Execution
`src.tool_index.ToolIndex` owns candidate retrieval using embeddings/keywords and cached index data. Security filtering is not its hard boundary: `agent_loop` hides unavailable schemas, and `tool_execution` blocks disabled, admin-only, and public-restricted calls before dispatch.
`src.tool_execution` owns built-in tool execution, MCP dispatch, path confinement, background markers, output truncation, internal HTTP loopback, owner/admin checks, policy-blocked execution results, and formatting tool results for the model/UI. File tools support exact edit diffs, full-file writes, read line ranges, and workspace confinement. Code-navigation tools (`grep`, `glob`, `ls`) prefer `rg`/structured filesystem traversal over ad hoc shell commands. Uploaded-file context uses stable `attachment_ref` manifests and owner-checked URIs; a compatibility local path is exposed only after upload-root and tool-root confinement. Shared truncation, upload-handler registration, and MCP manager compatibility helpers live in `src.tool_utils`.
Tool retrieval has domain-specific hooks beyond generic similarity: contact queries can surface `resolve_contact`/`manage_contact`; matched skills can add `manage_skills` and their required toolsets to the relevant tool set; explicit admin intents can include admin schemas so prompt text and native schema emission match.
Interaction/session/model helper tools are native first-class tools, not prompt-only conventions. `ask_user` and `update_plan` live in `src.agent_tools.interaction_tools`, model delegation/listing helpers live in `model_interaction_tools`, session creation/list/send/manage helpers live in `session_tools`, and `manage_bg_jobs` lives in `bg_job_tools`.
Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `<function_model><function_call>...</function_call><parameters>...</parameters></function_model>` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn, common `tex` typos normalize to `text`, and delimiter scans are forward-only so unterminated tool markup cannot drive quadratic rescans. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser.
Current call sites include:
- agent mode tool calls from `src.agent_loop`;
- MCP route configuration and built-in MCP registration;
- background job monitoring and auto-continue;
- skill tests, teacher escalation, scheduled tasks, and background follow-up loops;
- UI-control and AI interaction helpers.
## Streaming And Continuations
Agent streaming emits normal content plus tool progress/output, document stream/update, ask-user choices, plan updates, budget, round exhaustion, loop-breaker, intent-nudge exhaustion, metrics, teacher escalation, research anchor, and finish/error events. Frontend chat stream code and detached replay depend on stable event names. If the stream generator closes while awaiting an in-flight tool, the loop cancels and awaits that tool task so subprocess-backed work is not left orphaned.
Long-running bash jobs can be detached with background markers. `src.bg_jobs` owns persistent job state/result files; `src.bg_monitor` owns auto-continuation when jobs finish. Detached chat runs are in-memory and do not survive server restart, while background job state is disk-backed.
Loop-breaker final-answer rounds, explicit repeated-tool/intent-nudge guard events, round-cap continuation signals, optional verifier retries, and teacher escalation are recovery behavior owned by `agent_loop` and `src.teacher_escalation`.
Approval replay injects the sealed first tool result before the resumed model round. If that replay round has neither assistant prose nor reasoning, `_append_tool_results()` omits the empty assistant spacer so Anthropic-compatible payloads do not contain a rejected non-final empty assistant message; reasoning-only carriers remain a documented compatibility edge.
## Security And Policy
- `src.tool_security` owns non-admin blocked-tool decisions.
- Non-admin users must not reach admin tools through agent mode, MCP, retrieval, or loopback calls.
- Agent owner is passed from chat route `get_current_user(request)`. In `AUTH_ENABLED=false` mode this is `None`, not the `""` value returned by route dependencies. `blocked_tools_for_owner()`, schema hiding, and `execute_tool_block()` all use that owner.
- Current dev tool security treats explicit `AUTH_ENABLED=false` as single-user even when an auth store exists, while auth-enabled pre-setup callers remain non-admin.
- Path-based tools must remain confined to allowed roots and reject sensitive paths. Sensitive-path checks are case-insensitive and apply to direct file tools and code-navigation tools; `grep`/`glob`/`ls` must not become existence or content oracles for `.env`, SSH/GPG material, `id_rsa`, and similar denylisted paths.
- Tool output is bounded/truncated where native execution owns the path, including displayed agent-tool output through the shared truncation helper. MCP output must be treated as untrusted; central MCP-output truncation before model re-entry remains a gap.
- Provider-emitted native tool calls are requests, not authorization. `tool_execution` and route-level policy remain the authority.
- `src.tool_capabilities` classifies each tool's effects and result integrity. Once external/workspace-untrusted content becomes model-visible, the request/session security context permits only explicitly low-impact tools without interruption and requires exact approval for high-impact, unknown, and arbitrary MCP calls.
- `src.tool_approvals` seals an opaque, expiring exact first action plus server-only selected tools and continuation query to owner, session, origin run, tool content, workspace, capability snapshot, and—when relevant—document id/version/content digest. Chat choices grant the resumed task or the same chat session; both consume the exact first action, task scope bypasses the gate only during that resumed run, and chat scope is reconstructed only from a resolved card bound to the exact session id. The browser never receives selected tools/query and submits only task/chat/deny. Non-chat callers retain single-action behavior; new normal turns and superseding actions retire unresolved approvals without clearing taint.
- Tool results that expose remote or stored untrusted content arm the gate even when their tool status is failed. Content-free failures and server-generated policy/approval placeholders do not. Native/provider tool messages and fenced results carry model-visible untrusted metadata/wrapping instead of relying on prompt wording alone.
- Attachment-bearing document, note, and calendar tools owner-reserve internal
upload references before durable writes and fail without mutation when the
referenced upload is unavailable.
- Guide-only/no-tools mode blocks tools before prompt assembly, before execution, and in chat preprocessing paths that would otherwise fetch context or start tool-backed research.
- Plan mode is policy, not prompt advice: mutating native tools are disabled through schema-derived detection plus a static backstop, and write/unknown MCP tools are hidden and runtime-blocked for that turn.
## Internal Loopback
`do_app_api()` is implemented in `src.tools.system` and re-exported by `src.tool_implementations`. It owns generic app API loopback, OpenAPI discovery, method/path blocklists, and fixed local target behavior. `_internal_headers()` adds the process-secret internal-tool token and optional `X-Odysseus-Owner`; `core.middleware.require_admin()` and auth middleware own the corresponding bypass and owner-stamping rules. Route-specific owner handling must still be audited.
## MCP
`src.mcp_manager` owns configured MCP server lifecycle, discovered tool state, qualified MCP names, OpenAI schema conversion, call routing, generation invalidation, and connect/disconnect status. It supports stdio, SSE, and Streamable HTTP transports; Streamable HTTP can publish a `needs_auth` state and uses `src.mcp_oauth` for OAuth/OIDC-style authorization, token refresh, and encrypted token storage. Arbitrary MCP tools classify fail-high for approvals. `src.builtin_mcp` owns built-in server registration and the native-vs-MCP split. `mcp_servers/` owns server-specific tools for email, image generation, memory, RAG, and optional browser tooling.
Native bash, python, file, web search, and web fetch tools continue through native fallback even when MCP is unavailable. Browser MCP is optional and can be skipped when cached Playwright/NPX packages are missing. Public users get no MCP schemas, and any `mcp__*` execution attempt must be blocked.
MCP prompt/schema rendering includes server-provided input schemas, but names, types, and parameter hint text are sanitized and length-capped before entering the prompt. Per-server disabled tools filter listings, prompt descriptions, and function schemas; execution-time disabled-tool enforcement remains a separate hardening item.
## Intent And Recovery Helpers
`src.action_intents` owns deterministic chat-to-agent promotion hints and returns a category/reason so route logs can explain auto-escalation decisions. Explicit web-search language is category `web`; it can promote the turn into agent mode and narrow tools toward web search/fetch, but route policy requires explicit web-search enablement and honors explicit denial. It must avoid promoting explanatory questions into agent mode. `src.builtin_actions` owns scheduler/background actions outside the normal live agent loop. `src.teacher_escalation` owns recovery/escalation and skill-creation flows. `src.goal_based_extractor` is research-adjacent and should stay cross-referenced from research behavior rather than treated as ordinary tool execution.
When an email reader is active, browser chat passes active email metadata and the agent loop injects it as protected, untrusted context so default reply/draft behavior targets the selected message. Active email compose documents are handled as existing email drafts rather than generic new-document requests.
## Degraded Behavior
- ToolIndex can degrade to keyword selection when embeddings, Chroma, index
warmup, or vector retrieval timeouts fail.
- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Local Ollama `/v1` and native `/api` endpoints default to text tools unless the endpoint explicitly advertises `supports_tools`; `gpt-oss` remains text-tool by default unless the endpoint opts in.
- MCP startup failure is non-critical; route/status surfaces expose per-server errors.
- `ODYSSEUS_DISABLE_MCP`, missing `mcp`, uncached browser MCP packages, and per-server disabled tools can remove tools without blocking the app.
- Global `builtin_browser` disable behavior may not currently match qualified `mcp__builtin_browser__*` tool names.
## Current Gaps
- Tool descriptions are duplicated across `FUNCTION_TOOL_SCHEMAS`, agent prompt sections, and `BUILTIN_TOOL_DESCRIPTIONS`.
- Agent prompts remain heavy for small local context windows.
- Some AI-control helpers are still globally wired from app startup rather than a narrower service layer.
- Tool registry consistency is manual across handler maps, tags, aliases, schemas, retrieval descriptions, execution dispatch, settings/model routes, and frontend toggles.
- MCP disabled-tool changes can stale-cache tool retrieval because disabled maps are not always an index generation input.
- External MCP output still needs a single central size cap before model re-entry; untrusted-result metadata and the post-external-context action gate now cover the prompt-injection/authorization boundary.
- Auth-disabled/no-login owner propagation is inconsistent between route dependencies and chat/agent execution, so tool-security and native tool storage behavior need dedicated regression coverage.
- Agent tests mostly cover helpers and targeted regressions, including round-cap
and disconnect cancellation paths, but not an end-to-end fake-LLM
`stream_agent_loop` path with retrieval, native schemas, prompted blocks,
disabled/admin hiding, MCP tools, plan/workspace state, user-time context, and
tool-result SSE.
-412
View File
@@ -1,412 +0,0 @@
# Architecture Runtime Inventory
> **Purpose**: Phase 0 planning baseline for codebase readability improvements (#4071).
> **Parent issue**: [#4082](https://github.com/odysseus-dev/odysseus/issues/4082)
> **Last updated**: dev@b58af42 | 2026-06-16
> **Status**: Draft — to be reviewed before follow-up slices open.
> **Snapshot basis**: Importer / file / import-line counts are refreshed to `dev@b58af42` (2026-06-16) and are recomputable via the commands in §3.4. **Line counts** in §2.1 / §2.2 are a snapshot from an earlier baseline and drift as `dev` moves — recompute any of them with `wc -l <file>`. This inventory tracks structure and risk, not live metrics.
This document maps the current runtime module structure, identifies high-risk boundaries, and recommends safe first refactor slices. It does **not** move files, change imports, or alter runtime behavior.
---
## 1. Current Structure Overview
### 1.1 Top-Level Layout
```
odysseus/
├── app.py # FastAPI app entrypoint (1,145 lines)
├── conf/ # Configuration (config.py, settings.py, settings_scrub.py)
├── src/ # 95 flat .py files + 2 subdirectories
│ ├── agent_tools/ # Tool helpers: document, filesystem, subprocess, web
│ └── search/ # Search subsystem
├── routes/ # 54 flat .py files — HTTP route handlers
├── core/ # 10 files — database models, auth, middleware, session
├── mcp_servers/ # 5 files — MCP server implementations
├── scripts/ # CLI tools and one-shot scripts
├── static/ # Frontend HTML/CSS/JS
├── tests/ # 583 test files (~54,800 lines)
└── services/ # (exists as needed)
```
### 1.2 Directory Flatness Metric
| Directory | Flat `.py` Files | Subdirectories | Concern |
|-----------|-----------------|----------------|---------|
| `src/` | **95** | 2 (`agent_tools/`, `search/`) | No domain grouping; 95 files in one directory |
| `routes/` | **54** | 0 | All route handlers in one flat directory |
| `core/` | 10 | 0 | Manageable, but `database.py` is oversized |
---
## 2. Largest Runtime Modules
### 2.1 Python Backend
| Rank | File | Lines | Classes | Functions | Risk |
|------|------|-------|---------|-----------|------|
| 1 | `src/tool_implementations.py` | **4,032** | 0 | ~48 | **HIGH** |
| 2 | `routes/email_routes.py` | **3,245** | — | — | **MEDIUM** |
| 3 | `routes/cookbook_routes.py` | **2,969** | — | — | **MEDIUM** |
| 4 | `src/agent_loop.py` | **2,961** | 0 | ~24 | **HIGH** |
| 5 | `src/task_scheduler.py` | **2,330** | — | 5 | MEDIUM |
| 6 | `routes/model_routes.py` | **2,266** | — | — | MEDIUM |
| 7 | `core/database.py` | **2,265** | 28 | ~59 helpers | **HIGH** |
| 8 | `src/builtin_actions.py` | **2,262** | 2 | ~24 | MEDIUM |
| 9 | `src/llm_core.py` | **2,164** | — | — | MEDIUM |
| 10 | `mcp_servers/email_server.py` | 2,197 | — | — | LOW (separate process) |
| 11 | `src/visual_report.py` | 1,918 | — | — | LOW |
| 12 | `routes/gallery_routes.py` | 1,896 | — | — | LOW |
| 13 | `src/ai_interaction.py` | 1,846 | — | — | MEDIUM |
| 14 | `routes/document_routes.py` | 1,717 | — | — | LOW |
| 15 | `routes/skills_routes.py` | 1,648 | — | — | LOW |
**Heuristic**: Files > 2,000 lines with 20+ public symbols and many importers are the highest-risk splits. Files 1,0002,000 lines are medium-risk if tightly coupled.
### 2.2 Frontend
| File | Lines | Concern |
|------|-------|---------|
| `static/style.css` | **36,653** | Entire app CSS in one file (tracked separately in #2617) |
| `static/js/document.js` | **9,776** | Single JS file for document functionality |
| `static/js/slashCommands.js` | 6,498 | |
| `static/js/settings.js` | 5,266 | |
| `static/js/emailLibrary.js` | 5,217 | |
| `static/js/notes.js` | 5,124 | |
| `static/js/chat.js` | 4,985 | |
| `static/app.js` | 4,090 | |
**Note**: Frontend modularization is tracked separately in #2617 (CSS) and is not the focus of this Phase 0 inventory. Frontend is listed here for completeness but follow-up slices should target Python backend boundaries first.
---
## 3. Import Dependency Graph
### 3.1 Who Depends on `core/database.py`
**102 files** import from `core.database` — this is the most depended-upon module:
- All route handlers (`routes/*.py`)
- Most `src/*.py` files
- `core/session_manager.py`, `core/auth.py`
- Multiple test files
**Implication**: Any split of `core/database.py` is the highest-risk refactor. It should be tackled **last**, never first.
### 3.2 Who Depends on `src/tool_implementations.py`
**17 files** import from `src.tool_implementations`:
- `src/agent_loop.py`, `src/builtin_actions.py`, `src/tool_index.py`
- `src/task_scheduler.py`, `src/tool_policy.py`
- Various tests
### 3.3 Who Depends on `src/agent_loop.py`
**22 files** import from `src.agent_loop`:
- `src/tool_policy.py`, `src/teacher_escalation.py`, `src/bg_monitor.py`
- `src/task_scheduler.py`
- Multiple test files
### 3.4 Cross-Layer Import Violations
**`src/` importing from `routes/`** (backwards dependency — domain logic depending on HTTP layer):
```
src/tool_implementations.py ──→ routes/calendar_routes.py
src/tool_implementations.py ──→ routes/cookbook_helpers.py
src/tool_implementations.py ──→ routes/email_helpers.py
src/tool_implementations.py ──→ routes/email_pollers.py
src/tool_implementations.py ──→ routes/email_routes.py
src/tool_implementations.py ──→ routes/model_routes.py
src/tool_implementations.py ──→ routes/note_routes.py
src/tool_implementations.py ──→ routes/prefs_routes.py
```
> These are **runtime imports** (inside function bodies, not at module top), which mitigates circular import risk but indicates fuzzy layer boundaries. Function-level inline imports from the HTTP layer into business logic are a code smell.
**Import counts (top-level)**:
| Direction | Count | Notes |
|-----------|-------|-------|
| `routes/``src/` | **374** | Expected: HTTP handlers call domain logic |
| `routes/``core/` | **126** | Expected: handlers access DB models |
| `src/``routes/` | **31** | **Unexpected**: domain logic reaching into HTTP layer (direct grep of import lines referencing `routes/`) |
| `src/``core/` | **106** | Acceptable but could be reduced with a data-access layer |
> **How the metrics in this document are computed** — recompute against current `dev` before treating any count as authoritative (the tree drifts; these numbers are a snapshot, not a live value):
> - `src/` flat `.py` files: `find src -maxdepth 1 -name '*.py' | wc -l`
> - `tests/` test files: `find tests -name 'test_*.py' | wc -l`
> - `core.database` importers: `grep -rlE '(from|import) +core\.database' --include='*.py' . | grep -v core/database.py | wc -l`
> - `src.agent_loop` importers: `grep -rlE '(from|import) +src\.agent_loop' --include='*.py' . | grep -v src/agent_loop.py | wc -l`
> - Cross-layer import lines: `grep -rhE '(from|import) +<pkg>' --include='*.py' <dir>/ | wc -l` (e.g. `(from|import) +routes` over `src/`)
---
## 4. Route Ownership Map
Routes can be grouped into logical feature domains. Current flat structure obscures these boundaries:
| Domain | Route Files | Total Lines | Review Complexity |
|--------|-------------|-------------|-------------------|
| **Email** | `email_routes.py`, `email_helpers.py`, `email_pollers.py` | 5,936 | HIGH — most complex domain |
| **Chat / Agent** | `chat_routes.py`, `chat_helpers.py`, `shell_routes.py`, `codex_routes.py`, `skills_routes.py` | 6,365 | HIGH — core interaction surface |
| **Cookbook** | `cookbook_routes.py`, `cookbook_helpers.py`, `cookbook_output.py` | 4,110 | MEDIUM |
| **Model / LLM** | `model_routes.py`, `assistant_routes.py`, `copilot_routes.py` | 2,764 | MEDIUM |
| **Calendar / Contacts** | `calendar_routes.py`, `contacts_routes.py` | 2,336 | MEDIUM |
| **Documents** | `document_routes.py`, `document_helpers.py` | 1,954 | LOW |
| **Auth** | `auth_routes.py`, `api_token_routes.py`, `device_flow.py` | 1,171 | LOW |
| **Tasks** | `task_routes.py` (standalone) | 1,157 | LOW |
| **Session** | `session_routes.py` (standalone) | 1,287 | LOW |
| **Gallery** | `gallery_routes.py`, `gallery_helpers.py` | 1,896 | LOW |
| **Memory** | `memory_routes.py` | — | LOW |
| **Research** | `research_routes.py` | — | LOW |
| **MCP** | `mcp_routes.py` | — | LOW |
| **Notes** | `note_routes.py` | — | LOW |
| **Other** | `prefs_routes.py`, `upload_routes.py`, `vault_routes.py`, `webhook_routes.py`, `workspace_routes.py`, `search_routes.py`, `history_routes.py`, `hwfit_routes.py`, `preset_routes.py`, `signature_routes.py`, `backup_routes.py`, `cleanup_routes.py`, `diagnostics_routes.py`, `embedding_routes.py`, `emoji_routes.py`, `font_routes.py`, `stt_routes.py`, `tts_routes.py`, `compare_routes.py`, `personal_routes.py`, `editor_draft_routes.py`, `admin_wipe_routes.py`, `chatgpt_subscription_routes.py` | 2,000+ | LOW individual, HIGH cumulative |
---
## 5. Tool Registry & Implementation Boundaries
### 5.1 Current Tool Architecture
| Component | File | Lines | Role |
|-----------|------|-------|------|
| Tool schemas | `src/tool_schemas.py` | 1,392 | JSON Schema tool definitions (Duck-TypedDict) |
| Tool index | `src/tool_index.py` | 542 | RAG-based tool retrieval from ChromaDB |
| Tool implementations | `src/tool_implementations.py` | 4,032 | 33 `do_*` functions — all tool execution logic |
| Tool security | `src/tool_security.py` | — | Owner-scoped tool blocking |
| Tool policy | `src/tool_policy.py` | — | Guide-only directive, plan-mode disabled tools |
| Tool utils | `src/tool_utils.py` | — | Shared tool helpers |
### 5.2 Tool Implementation Categories
The 33 `do_*` functions in `tool_implementations.py` fall into natural domain groups — the basis for slice 1's split in §6.2:
| Category | `do_*` functions | Count |
|----------|------------------|-------|
| **System / config** | `do_manage_skills`, `do_manage_tasks`, `do_manage_endpoints`, `do_manage_mcp`, `do_manage_webhooks`, `do_manage_tokens`, `do_manage_settings`, `do_api_call`, `do_app_api` | 9 |
| **Cookbook / model serving** | `do_download_model`, `do_serve_model`, `do_list_served_models`, `do_stop_served_model`, `do_tail_serve_output`, `do_list_downloads`, `do_cancel_download`, `do_search_hf_models`, `do_adopt_served_model`, `do_list_cookbook_servers`, `do_list_serve_presets`, `do_serve_preset`, `do_list_cached_models` | 13 |
| **Notes** | `do_manage_notes` | 1 |
| **Calendar** | `do_manage_calendar` | 1 |
| **Search** | `do_search_chats` | 1 |
| **Research** | `do_manage_research`, `do_trigger_research` | 2 |
| **Contacts** | `do_resolve_contact`, `do_manage_contact` | 2 |
| **Vault** | `do_vault_search`, `do_vault_get`, `do_vault_unlock` | 3 |
| **Image** | `do_edit_image` | 1 |
| | **Total** | **33** |
> Low-level tools (filesystem, subprocess, web fetch, document parsing) live in `src/agent_tools/`, **not** in `tool_implementations.py` — out of scope for this split.
---
## 6. Risk Assessment & Candidate Slice Ranking
> **Candidate proposals, not a committed plan.** The rankings, package shapes (e.g. `src/pkg/`, `src/domain/`, `src/infra/`, `src/api/`), split ordering, and route-grouping strategy below are **options for maintainer discussion**. Per #4082/#4071, slice ownership and order are settled by maintainers before any follow-up PR. §1–§3 above are the factual current-state inventory.
### 6.1 Risk Scale
| Level | Criteria |
|-------|----------|
| **LOW** | File has ≤3 importers AND ≤500 lines, OR is a pure refactor with clear boundaries |
| **MEDIUM** | File has 415 importers OR 5001,500 lines |
| **HIGH** | File has 16+ importers OR >2,000 lines, OR has cross-layer import violations |
### 6.2 Ranked Split Candidates
| Priority | Target | Risk | Rationale |
|----------|--------|------|-----------|
| **1** | `src/tool_implementations.py``src/tools/*.py` | **MEDIUM** | 4,032 lines → ~10 files by tool category. Already has natural boundaries. 17 importers, tracked in #3629. Use `__init__.py` shim to keep existing imports working. |
| **2** | `routes/` → domain subdirectories (one domain per PR) | **MEDIUM** | 54 flat files. Done **one domain at a time** (e.g. a standalone PR for the email domain, then chat, …), not a broad reorganization — route modules carry helper imports, registration assumptions, and test import paths. |
| **3** | `src/agent_loop.py``src/agent/loop.py` + submodules | **MEDIUM-HIGH** | 2,961 lines, 24 functions. Can extract prompt building, classification, verification, and runaway detection. Tracked in #3266. |
| **4** | `src/``src/pkg/`, `src/domain/`, `src/infra/`, `src/api/` | **MEDIUM** | Structural reorganization. Split flat `src/` into layered packages. Must come after routes and tools are stable. |
| **5** | `routes/email_*.py` consolidation | **LOW** | Already grouped by filename prefix. Low-risk cleanup within the email domain. |
| **6** | `core/database.py``src/infra/database/models/*.py` | **HIGH** | 28 classes, 102 importers. Highest-risk split. Must be **last** in any sequence. Requires careful import shim strategy. |
| **7** | Frontend CSS modularization | **MEDIUM** | 36,653 lines. Tracked in #2617. Separate timeline from backend work. |
| **8** | Frontend JS modularization | **MEDIUM** | 9,776 lines in `document.js`. Introduce ES modules at minimum. |
### 6.3 Candidate First 3 Behavior-Preserving Slices
**Slice 1: Split `tool_implementations.py`** (Lowest-risk high-impact)
- Create `src/tools/` package with one file per tool category
- Add `src/tools/__init__.py` re-exporting all symbols with current names
- Update 17 importers to use new paths (can be deferred via shim)
- Validation: `python -m pytest tests/ -x -q` + manual smoke test of tool execution
- Reference: #3629
**Slice 2: Group `routes/` by domain** (one domain per PR, not a broad sweep)
Route modules carry helper imports, router registration assumptions, and test import paths, so this must be done **one domain at a time** rather than as a single reorganization PR. Example sequence (each its own PR):
- PR 2a: move the **email** domain (`email_routes.py`, `email_helpers.py`, `email_pollers.py`) → `routes/email/` + shim
- PR 2b: move the **chat/agent** domain → `routes/chat/` + shim
- PR 2c: move the **cookbook** domain → `routes/cookbook/` + shim
- …and so on per domain from §4
Each PR: add `__init__.py` re-exporting old names, update `app.py` router imports, validation `python app.py` starts clean. **No behavior change** — pure file reorganization.
**Slice 3: Extract `agent_loop.py` submodules** (Improve reviewability)
- Move prompt assembly → `src/agent/prompt.py`
- Move request classification → `src/agent/classifier.py`
- Move sub-agent verification → `src/agent/verifier.py`
- Move runaway detection → `src/agent/runaway.py`
- Move context management → `src/agent/context.py`
- Keep `src/agent/loop.py` as the main orchestration module
- Validation: `python -m pytest tests/test_agent_loop.py tests/test_loop_breaker_runaway.py -v`
---
## 7. Safety Guardrails for Follow-Up Work
Per maintainer guidance in #4082 and #4071:
- [ ] **One domain/slice per PR** — never mix multiple reorganizations
- [ ] **No behavior changes** mixed with file moves — pure reorganization only
- [ ] **Keep compatibility shims**`__init__.py` re-exports for all existing import paths
- [ ] **Add or identify focused tests** before risky splits
- [ ] **Do not start with `core/database.py`** or broad route movement unless this inventory shows a safe boundary
- [ ] **Prefer small, reviewable slices** over large restructures
- [ ] **No packaging/runtime/tooling migration** mixed into file moves
- [ ] **No frontend framework migration** inside this stabilization lane
- [ ] **Validate with `python -m compileall`** — every PR must pass CI checks
- [ ] **Validate with `pytest`** — run the full test suite before opening each PR
---
## 8. Validation Commands
Each follow-up PR should be verifiable with these commands before submission:
```bash
# Syntax check — must pass with zero errors
python -m compileall src/ routes/ core/ conf/
# Full test suite — must match baseline pass rate
python -m pytest tests/ -x -q
# Import shim verification — existing import paths must still work
python -c "from src.tool_implementations import do_search_chats; print('OK')"
# App startup smoke test (if backend touched)
timeout 5 python app.py 2>&1 | head -5 || true
```
---
## 9. Open Questions
1. Is `#2538` (specs ground truth) the canonical behavior map baseline, and should this inventory be kept in sync with those specs once merged?
2. Should route grouping follow the domain map proposed here, or is there a different taxonomy preferred by maintainers?
3. For the `tool_implementations.py` split (#3629), is the tool categorization in §5.2 acceptable, or should it follow a different grouping?
4. Should compatibility shims (`__init__.py`) be temporary (removed in a follow-up wave) or permanent?
5. Should an ADR (Architecture Decision Record) document be started to track decisions made during this process?
---
## 10. Future Direction (NOT current state)
The following are **future refactor targets** (candidate directions **pending maintainer agreement**, not committed), recorded here so this inventory does not imply they exist today. None of them are present in the current `dev` tree:
- `main.py` — proposed rename of the `app.py` entrypoint. Today the app boots via `app.py`.
- `src/agent/` — proposed package to hold `agent_loop.py` submodules (prompt/classifier/verifier/runaway/context). Today `agent_loop.py` is a single flat file in `src/`.
- `src/infra/`, `src/domain/`, `src/pkg/`, `src/api/` — proposed layered reorganization of the flat `src/` directory (slice 4 in §6).
These become real only when the corresponding slices land.
---
## Appendix A: File Listing
### `src/` (95 files — 61 shown; run `ls src/*.py` for the full list)
```
agent_loop.py tool_implementations.py tool_schemas.py
tool_index.py tool_security.py tool_policy.py
tool_utils.py builtin_actions.py task_scheduler.py
llm_core.py model_context.py model_discovery.py
session_search.py context_budget.py context_compactor.py
ai_interaction.py action_intents.py agent_runs.py
app_helpers.py app_initializer.py config.py
database.py memory.py memory_provider.py
secret_storage.py prompt_security.py url_security.py
url_safety.py rate_limiter.py cleanup_service.py
readiness.py service_health.py exceptions.py
request_models.py assistant_log.py bg_monitor.py
builtin_mcp.py chat_helpers.py chroma_client.py
document_processor.py embedding_lanes.py deep_research.py
research_handler.py research_utils.py personal_docs.py
rag_manager.py rag_singleton.py topic_analyzer.py
visual_report.py youtube_handler.py pdf_forms.py
pdf_form_doc.py pdf_runtime.py caldav_writeback.py
email_thread_parser.py text_helpers.py user_time.py
teacher_escalation.py cookbook_serve_lifecycle.py
chatgpt_subscription.py mcp_manager.py
```
### `routes/` (54 files)
```
__init__.py _validators.py
auth_routes.py api_token_routes.py device_flow.py
chat_routes.py chat_helpers.py shell_routes.py
codex_routes.py skills_routes.py
email_routes.py email_helpers.py email_pollers.py
cookbook_routes.py cookbook_helpers.py cookbook_output.py
model_routes.py assistant_routes.py copilot_routes.py
calendar_routes.py contacts_routes.py
document_routes.py document_helpers.py
gallery_routes.py gallery_helpers.py
task_routes.py session_routes.py
note_routes.py memory_routes.py research_routes.py
mcp_routes.py search_routes.py history_routes.py
webhook_routes.py workspace_routes.py upload_routes.py
vault_routes.py prefs_routes.py preset_routes.py
signature_routes.py personal_routes.py hwfit_routes.py
backup_routes.py cleanup_routes.py diagnostics_routes.py
embedding_routes.py emoji_routes.py font_routes.py
stt_routes.py tts_routes.py compare_routes.py
editor_draft_routes.py chatgpt_subscription_routes.py admin_wipe_routes.py
```
### `core/` (10 files)
```
__init__.py constants.py database.py models.py
auth.py middleware.py session_manager.py exceptions.py
atomic_io.py platform_compat.py
```
---
## Appendix B: Key Import Relationships
```
core/database.py ←── 102 importers (routes/*, src/*, core/*, tests/*)
├── routes/auth_routes.py
├── routes/email_routes.py
├── src/builtin_actions.py
├── src/task_scheduler.py
├── src/tool_implementations.py (inline)
└── ...97 more
src/tool_implementations.py ←── 17 importers
├── src/agent_loop.py
├── src/builtin_actions.py
├── src/tool_index.py
├── src/task_scheduler.py
├── src/tool_policy.py
└── ...12 more (mostly tests)
src/agent_loop.py ←── 22 importers
├── src/tool_policy.py
├── src/teacher_escalation.py
├── src/bg_monitor.py
├── src/task_scheduler.py
└── 18 more (incl. tests)
```
+169
View File
@@ -0,0 +1,169 @@
# Auth And Security
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers current security and trust-boundary behavior in:
- `core/auth.py`;
- `core/middleware.py`;
- `core/log_safety.py`;
- `core/database.py`;
- `app.py` auth middleware and token cache;
- `src/auth_helpers.py`;
- `src/owner_identity.py`;
- `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`;
- `src/tool_security.py`;
- `src/tool_execution.py`;
- `src/task_action_policy.py`;
- `src/prompt_security.py`;
- `src/url_safety.py` and `src/url_security.py`;
- `src/host_docker_access.py`;
- `src/attachment_refs.py` and upload lifecycle enforcement in
`src/upload_handler.py` / `routes/upload_routes.py`;
- `src/secret_storage.py`;
- `src/api_key_manager.py`;
- `src/integrations.py`;
- `src/webhook_manager.py`;
- `src/generated_images.py`;
- `scripts/diffusion_server.py`;
- `scripts/mlx_image_server.py`;
- `companion/routes.py` and `companion/pairing.py`;
- `routes/auth_routes.py`, `routes/api_token_routes.py`, and canonical `routes/vault/vault_routes.py` plus its top-level compatibility shim;
- admin-gated call sites in route files;
- `THREAT_MODEL.md` and `SECURITY.md`.
## Trust Boundary
Odysseus is a trusted-user private-network app. Admins intentionally have powerful local capabilities: shell, files, email, calendar, MCP, model serving, vault, settings, and API token management. The security model prevents unauthenticated access, non-admin escalation, prompt-injection through untrusted content, and accidental exposure of internal services.
`THREAT_MODEL.md` owns high-level security framing, but implementation claims here should be verified against current code when the threat model is stale. This spec records the implementation map that contributors should check before changing auth or untrusted-context flows. Security-header runtime details live in `runtime.md`.
## Auth Ownership
- `core.auth.AuthManager` owns users, password hashing, TOTP/backup codes, reserved usernames, privilege defaults, admin promote/demote state, and auth settings stored in `data/auth.json`. Auth config/setup mutations are lock-guarded, and session tokens are persisted separately in `data/sessions.json` behind their own lock.
- `app.py` owns request-time auth middleware, token-cache rebuild/invalidation, auth exemptions, API-token verification, and internal-tool identity stamping.
- `routes/auth_routes.py` owns HTTP endpoints for setup, signup/login/logout, 2FA, users, privileges, auth features, and integration settings.
- `core.middleware.require_admin()` owns the normal admin gate. Local wrappers must document and test any intentional divergence from that boundary.
- `src.auth_helpers.effective_user()` owns cookie/API-token owner attribution for selected route code. `require_user()` owns route-level degraded user resolution, `require_privilege()` owns privilege checks, and `owner_filter()` owns shared/null-owner query compatibility.
Reserved usernames include request-only sentinels `internal-tool`, `api`, `demo`, and `system`, plus the storage-only Default/Local owner `__odysseus_local__`. Loaded auth data drops reserved user records, and create/rename flows must reject real users with those names. `src.owner_identity` is the canonical owner vocabulary and `auth_disabled()` parser.
## Auth Runtime Flow
`AuthMiddleware` is the outer request gate because FastAPI middleware executes in reverse add order. It can return API `401` JSON or browser `/login` redirects before timeout/security-header middleware reaches the route.
Public/auth-exempt surfaces are limited to setup, signup/login/logout/status, feature/settings/integration preset reads, health/version/login, `/static/*`, and task webhook trigger paths. `routes/task/task_routes.py` owns validation of `POST /api/tasks/{task_id}/webhook/{token}` path credentials.
Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py:89`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact.
Deleting a user revokes that user's browser sessions and API-token rows, then the admin delete route invalidates the in-memory bearer-token cache so already-cached tokens stop authenticating.
Rename first changes the auth username, then migrates owner-bearing DB rows and disk-backed stores. Current rename coverage includes user preferences, active/disk research state, `memory.json`, upload metadata and owner-qualified upload index keys, skills frontmatter/usage state, cached browser sessions, and API-token cache invalidation. If owner migration fails after the auth rename, the route attempts to roll auth back to the old username instead of leaving a split identity.
Admin promotion/demotion is a live auth flag change through `AuthManager.set_admin()` and `PUT /api/auth/users/{username}/admin`. Demotion refuses to remove the last admin, permits self-demotion when another admin remains, restores the pre-admin privilege map when available, and does not revoke sessions or API tokens because later admin checks read the current `is_admin` flag.
## Owner Attribution
Cookie requests use the real username. Bearer-token requests are stamped as `request.state.current_user = "api"` plus `api_token_owner`, `api_token_scopes`, and token id. Routes that support API-token access must explicitly use `effective_user()` or route-local scope helpers instead of treating `"api"` as an owner.
Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`; `require_admin()` trusts the stamped sentinel or raw internal header and should be used behind equivalent middleware control.
Missing-owner values remain state-dependent at legacy call sites, but new storage-facing code has one normalization contract:
- Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies.
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode.
- Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state.
- SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller.
- `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior.
- `__odysseus_local__` is a valid storage owner but never a login or request sentinel. Adoption is incremental: callers that do not use the storage-owner helper can still expose older `None`/empty/null compatibility behavior.
Authenticated `manage_tasks` mutations require an exact stored task-owner
match and reject both cross-owner and legacy null-owner rows. The `owner=None`
agent path keeps deliberate auth-disabled single-user compatibility, including
unscoped list/create/mutation behavior.
Owner-scoped route code should use `require_user()` or equivalent policy before querying per-owner data. Current note CRUD/reorder/reminder routes do this so an auth-enabled request that reaches the route without identity returns `401` instead of falling into single-user/null-owner compatibility behavior.
Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py:1231`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py:865`), the `manage_tasks` agent tool (`src/tools/system.py:469`), webhook triggers (`routes/task/task_routes.py:1045`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py:1063-1074`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path.
## API Tokens And Scoped Integrations
`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` caches active token prefix rows and verifies bearer tokens with bcrypt. API-token requests set `request.state.current_user = "api"` plus token owner/scopes.
Current call sites include Codex/Claude scoped APIs, `/api/v1/chat`, webhooks, selected session routes, companion pairing, and external integrations. `/api/codex/*` and `/api/v1/chat` enforce route-local scopes; companion and selected session routes use owner attribution. `companion/pairing.py` can mint chat-scoped tokens outside normal token CRUD.
Admin token CRUD is cookie/admin gated. Update/delete operations check token ownership, and cache rebuild ignores active tokens whose owner no longer maps to a known auth user. Scoped route code must use the token owner and declared scopes instead of falling back to cookie-user assumptions.
## Internal Tool Loopback
Agent tools call admin-gated HTTP routes through an in-process loopback. `core.middleware.INTERNAL_TOOL_TOKEN` owns the random per-process secret. `app.py` only accepts this bypass from direct loopback clients without proxy-forwarding headers.
`src.tool_security` owns non-admin tool blocking. Non-admin users must not reach admin tools through agent mode, MCP tools, or loopback calls.
`src.tool_security.owner_is_admin_or_single_user()` treats explicit `AUTH_ENABLED=false` as intentional single-user mode even when an auth store already exists, while keeping pre-setup auth-enabled callers non-admin.
Current admin gates include `require_admin()` call sites across admin wipe, backup, contacts, Cookbook, diagnostics, embeddings, MCP, model, personal docs, presets, skills, uploads, vault, webhook, and companion routes. Local wrappers also exist in auth routes, shell routes, and task action policy; changes to those wrappers need the same trust-boundary review as `require_admin()`. Scheduled task action policy treats `run_local`, `run_script`, `ssh_command`, and `cookbook_serve` as admin-only action tasks across create/update/manual-run/webhook/scheduler execution.
`tidy_research` can remove only empty or unparseable research JSON. Because a broken file has no trustworthy owner stamp, the action checks `owner_is_admin_or_single_user()` before enumerating files; regular users and the pre-setup window cannot run that global unattributable-file sweep.
## Untrusted Context Policy
`src.prompt_security` owns the model-facing untrusted data contract:
- `UNTRUSTED_CONTEXT_POLICY` states the policy in system prompt text.
- `untrusted_context_message(label, content)` wraps external content as user-role data with `metadata.trusted = False`, provenance metadata, and a default `tool_gate_untrusted` marker. Guard-like labels/content are escaped so source text cannot counterfeit the wrapper boundary.
Current untrusted surfaces include fetched URLs, web results, emails, memories, skills, notes, documents, active editor content, and tool output sourced from outside the server. Injecting those as trusted system instructions is a security bug.
`src.tool_capabilities` classifies native and MCP tools by effects and result integrity. After external/workspace-untrusted context becomes model-visible, `ToolRunSecurityContext` keeps a server-owned taint for the session turn: only explicitly low-impact tools can run immediately, while write, execute, network-egress, UI/external-side-effect, admin, destructive, unknown, and arbitrary MCP actions require exact approval. Failed tools can still arm the gate when their result carries remote or stored payload; content-free failures and server-generated blocked/approval placeholders do not.
`src.tool_approvals` owns opaque approvals sealed to the owner, session, origin run, exact first tool name/content, workspace, capability effects/result integrity, selected continuation tool set/query, and expiry. Document actions additionally seal document id, version, content digest, and workspace. Chat cards offer task scope, chat-session scope, or deny: both allow choices consume and execute the exact sealed first action after current-policy/freshness checks, task scope bypasses the gate only for the resumed task, and chat-session scope persists a resolved session-bound grant for later turns in that same chat. The browser submits only the opaque decision and cannot replace the sealed action, selected tools, query, composer text, or attachments. Non-chat callers retain single-action scope. A new ordinary turn or superseding action retires an unresolved approval without clearing taint.
## URL, Path, And Secret Policy
- `src/url_security.py` owns public HTTP(S) validation for integration/API-token supplied URLs. It should fail closed for private IP, loopback, invalid scheme, and unsafe redirect targets.
- `src/url_safety.py` owns local-first outbound URL safety for model endpoints and similar local services. Loopback/LAN can be allowed by default, and private-IP blocking is an explicit caller policy. Strict `block_private=True` also rejects RFC 6598 shared/CGNAT space (`100.64.0.0/10`) explicitly because Python does not classify that range as private.
- `core.log_safety.redact_url()` strips URL userinfo, query strings, and fragments before endpoint URLs enter logs. Model, chat/research endpoint, contact/CardDAV, and similar diagnostics should use this helper instead of logging raw admin-configured URLs.
- `src.webhook_manager` validates webhook URLs at create and delivery time,
rejects private/internal targets, disables redirects, and pins delivery to
the public IP set that passed validation immediately before the request.
- `src.integrations` owns admin-configured integration base URLs and secret
masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity.
- `src.outbound_fetch` owns reusable public-URL classification, validates every redirect hop, rejects private/local resolved addresses, and pins the HTTP connection to the validated public IP while preserving original URL/SNI/Host semantics. `services.search.content` adapts that transport for extraction and caching.
- Path-based tools, upload/document/gallery/signature/generated-image routes, embedding cache paths, and research JSON helpers must stay confined to allowed roots and owner-scoped files. Native file/code-navigation tools also apply a case-insensitive sensitive-path denylist so `grep`, `glob`, `ls`, direct reads, and writes cannot reveal `.env`, SSH/GPG material, private-key filenames, or similar secret paths.
- Durable upload references are owner-reserved before chat/session, document,
note, or calendar writes. Cleanup scans every current durable reference
surface and fails closed on incomplete discovery or inconsistent upload-index
state rather than deleting a possibly live upload.
- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM
sidecars to `0600` on POSIX after resolving the real path from the parsed
engine URL. Windows, in-memory, and non-SQLite databases are excluded, and
failed POSIX restriction is logged as a secret-file warning.
- Secret-like DB columns use `EncryptedText` or `src.secret_storage`. Email passwords and Google OAuth mail tokens are encrypted manually in `EmailAccount` string columns; Google OAuth state is HMAC-signed and callback writes are owner-checked before token storage. `src.api_key_manager` keeps provider API keys encrypted in `data/api_keys.json`, writes by loading the raw encrypted dict so saving one provider does not rewrite other providers' keys as plaintext, and restricts local key-file permissions where the platform supports chmod. Vault state in `data/vault.json` is a chmod-restricted JSON secret store, not Fernet-encrypted DB storage. Do not log or return decrypted secrets except for intentional admin vault retrieval flows with audit/reason checks.
- `.env` files are secrets-only inputs and should not be read or printed during agent work.
`scripts/diffusion_server.py` is a local model-serving helper with its own web surface. It defaults CORS to deny, installs a trusted-host allowlist for loopback/bind addresses, and only extends Host/CORS through explicit CLI flags.
`scripts/mlx_image_server.py` serves exactly the model selected when the process starts. OpenAI-compatible request `model` fields are accepted but ignored for generation and edits, so an unauthenticated caller cannot select another local directory or Hugging Face repository and drive model-specific script/bridge execution.
Host Docker socket access is a high-trust admin/deployment choice, not a normal container capability. Default Docker Compose does not mount `/var/run/docker.sock`; `src.host_docker_access` only reports local Docker available inside a container when `ODYSSEUS_ENABLE_HOST_DOCKER=true` and the socket exists. Remote SSH Docker/Cookbook workflows remain the safer default.
## Degraded And Compatibility Behavior
- `AUTH_ENABLED=false` skips `AuthMiddleware` and `src.auth_helpers.require_user()` returns `""` from any host. This preserves local single-user/no-login operation; it is not permission for auth-enabled logged-out callers. Storage code that adopts `storage_owner_for_request()` receives the reserved Default/Local owner; direct `get_current_user()` readers still receive `None`. Owner-scoped routes that tolerate no-login mode should call the appropriate route or storage helper so auth-enabled anonymous requests fail closed.
- First-run setup mode redirects browser requests to `/login`, returns API `401 Setup required`, and keeps setup/status/login surfaces auth-exempt. Setup/signup/login are rate-limited; status is exempt but not rate-limited. Route helper fallbacks only tolerate unconfigured anonymous access from loopback.
- User privilege checks distinguish legacy empty `allowed_models=[]` from explicit no-model access through `allowed_models_restricted=True`.
- `LOCALHOST_BYPASS` in `app.py` only applies to direct loopback clients and excludes proxy/tunnel headers. Helper fallback code is weaker and should not be treated as the primary bypass boundary.
- Legacy migrations claim null-owner SQL/JSON data for the primary admin when possible, and startup repeats a null-owner sweep hourly. Remaining null-owner rows are surface-specific compatibility data that must be deliberately included, no-oped for single-user mode, or rejected for strict ownership gates.
- `.env` is loaded with `utf-8-sig`, so Windows BOM auth flags still parse.
## Current Gaps
- There is no shell/filesystem sandbox for admin tools.
- Token scopes remain coarse for some surfaces.
- `app.py` AuthMiddleware lacks direct regression coverage for bearer-token state/cache behavior, trusted-loopback proxy-header rejection, and internal-tool owner stamping.
- Codex/Claude scoped route enforcement still needs stronger regression coverage.
- `THREAT_MODEL.md` still has stale token-scope and `/api/v1/chat` SSRF gap text that should be reconciled with current route validation.
- The Default/Local owner contract is canonical but only incrementally adopted; route helper `""`, chat/agent `None`, SQL/JSON null-owner compatibility, and calendar fallback owner behavior still need domain-by-domain migration decisions.
+186
View File
@@ -0,0 +1,186 @@
# Calendar, Tasks, And Notes
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers calendar, reminders, tasks, assistant runs, and notes in:
- app route wiring, auth exemptions, and scheduler startup in `app.py`;
- canonical database models in `core/database.py`, with `src/database.py` as a compatibility re-export;
- `routes/calendar_routes.py`, `src/caldav_sync.py`, and `src/caldav_writeback.py`;
- canonical `routes/task/task_routes.py`, compatibility shim `routes/task_routes.py`, `src/task_scheduler.py`, `src/task_endpoint.py`, `src/event_bus.py`, and `src/interactive_gate.py`;
- shared privileged task-action policy in `src/task_action_policy.py`;
- `routes/assistant_routes.py`;
- canonical `routes/note/note_routes.py`, compatibility shim
`routes/note_routes.py`, `src/builtin_actions.py`, and `src/action_intents.py`;
- agent/tool call sites in `src/tool_index.py` and `src/tool_implementations.py`;
- scoped Codex wrappers in `routes/codex_routes.py`;
- database models `CalendarCal`, `CalendarEvent`, `ScheduledTask`, `TaskRun`, `Note`, and `CrewMember`;
- direct DB CLIs `scripts/odysseus-calendar`, `scripts/odysseus-notes`, and `scripts/odysseus-tasks`;
- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, `static/js/notes.js`, and `static/js/assistant.js`;
- tests covering calendar routes/utilities, CalDAV, recurrence, timezone handling, scheduler behavior, task webhooks, notes CLI/tool behavior, and task CLI behavior.
## Calendar
`routes/calendar_routes.py` owns `/api/calendar` behavior: config, multi-account CalDAV CRUD, connection test, sync, local calendar CRUD, event CRUD, recurrence expansion, ICS import/export, quick parse, and user timezone offset handling.
`src.caldav_sync` owns CalDAV fetch/sync. `src.caldav_writeback` owns pushing local changes back to remote calendars. Calendar routes request those behaviors; they do not own CalDAV protocol details.
Runtime behavior:
- local default calendars are created lazily per owner with stable UUID5 candidates. Default creation remains inside the caller's transaction so a failed event write cannot leave an orphaned calendar; SQLite serializes the absent-row check with `BEGIN IMMEDIATE`, other backends recover insert races inside a savepoint, and renamed-owner ID collisions advance through deterministic slots. List-only callers explicitly commit the lazy default.
- route-level no-login calendar access normalizes empty owner values to `ODYSSEUS_FALLBACK_OWNER` or `owner@localhost`, so route-created calendar rows do not use the empty string as their storage owner;
- CalDAV account config lives in per-user prefs as `caldav_accounts`, with the legacy `/api/calendar/config` route reading/upserting the first account;
- recurring rules are expanded server-side, including compound recurrence IDs;
- RRULE expansion is capped and marks truncated responses;
- event datetimes preserve UTC/local metadata through `CalendarEvent.is_utc` where supported;
- CalDAV pull uses a bounded sync window, scopes existing UID lookups to the synced calendar, stamps account ids and remote metadata on local calendars, maps Google principal URLs to event collections, preserves locally-created or writeback-pending events that are not yet remote-owned, and deletes stale in-window remote events only when remote object parsing did not fail;
- CalDAV writeback stores `remote_href`/`remote_etag`, clears `caldav_sync_pending` only after successful remote writes, and leaves create/update/delete pending markers for retry on failure;
- pull and writeback paths always close their `DAVClient`, including discovery,
database, and remote-write failure paths;
- sync direction can be pull, push, or both, and pending local writeback rows are included even before remote href metadata exists;
- ICS import is per-owner, capped, creates fresh local IDs in the target import calendar, and preserves zero-duration events as visible imported rows rather than dropping them as empty ranges;
- writeback is best-effort and local SQLite remains source of truth when remote writes fail.
Calendar credentials are encrypted at rest and are not returned to clients. CalDAV URL validation rejects unsafe schemes, credentials, fragments, localhost names, bad ports, unsafe IP literals, and hostnames resolving to disallowed addresses, with `ODYSSEUS_ALLOW_PRIVATE_CALDAV=1` as the explicit private-IP escape hatch. CalDAV sync/writeback clients disable redirects so credentials are not followed to another origin. The connection-test client keeps proxy/environment trust disabled but explicitly loads an operator `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` when the file exists so private/self-signed deployments use the same CA trust intent as real sync.
## Tasks And Assistant Runs
`src.task_scheduler.TaskScheduler` owns scheduled task execution, next-run computation, strict single-slot execution, queued/running cleanup at startup, overdue next-run advancement, webhook-triggered tasks, notifications, run records, chained tasks, and event-triggered actions.
Cookbook serve scheduling crosses this domain. The Cookbook UI creates `cookbook_serve` scheduled tasks, can mirror them as Cookbook calendar events with `cookbook_event_uid`, and task deletion cleans up the linked event when present, falling back to exact-summary matching for legacy events without a stored UID. Cookbook command execution/lifecycle details stay in `cookbook-hwfit.md`.
`routes.task.task_routes` owns task CRUD, status, manual run/stop/cancel, pause/resume, owner-scoped run/activity history, metadata, onboarding defaults, cache clearing, parse endpoints, and webhook-token regeneration. `app.py` imports the canonical package path; `routes/task_routes.py` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Chained-task `then_task_id` values are validated as same-owner relationships on create/update, and scheduler execution also rejects cross-owner or cyclic chains.
Task webhook paths are auth-exempt at the app middleware layer only for `/api/tasks/{task_id}/webhook/{token}`. The route still validates active task state plus task-specific webhook token before dispatch.
Task runtime behavior:
- task runs move through queued/running/success/error/skipped/aborted states;
- scheduler/background execution can wait for `src.interactive_gate` to report a quiet foreground window, and running background work can use browser heartbeat/chat-stream activity as a cancellation/defer signal where implemented;
- output targets include chat sessions, notifications, email, and MCP delivery paths;
- LLM and research tasks can carry a built-in `character_id` persona prompt that the scheduler prepends at execution time;
- task-created chat sessions can be foldered under `Tasks`, and startup migration backfills task/research folders for legacy sessions;
- event-bus triggers persist counters and `next_run` before scheduler handoff;
- the in-process scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`, and multiple enabled app processes can double-run work.
- action tasks with `run_local`, `run_script`, `ssh_command`, or
`cookbook_serve` are admin-only. `routes.task_routes` enforces this on
create/update/manual run and hides those actions from `/meta/actions` for
non-admin owners; webhook and scheduler execution pause the task and clear
`next_run` if an admin-only action belongs to a non-admin owner.
- background LLM task execution uses the background workload path, and the
scheduler can abort/cancel active in-process task runs when foreground browser
activity appears.
- `tidy_research` scans all persisted research files because broken JSON has no trustworthy owner stamp, so it runs only for admins or the explicit auth-disabled single-user operator and refuses regular/pre-setup callers before enumeration.
`routes.assistant_routes.py` owns crew/assistant settings and run-status surfaces that use the scheduler. `TaskScheduler.ensure_assistant_defaults()` currently seeds the personal assistant crew member and pinned assistant session, but no longer auto-creates Morning/Midday/Evening check-in tasks. Existing crew-linked check-in tasks are still rendered and managed when present.
## Notes And Reminders
`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that
canonical path. `routes.note_routes` replaces its module entry with the
canonical module for legacy import and monkeypatch compatibility. Notes are
SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat
state, AI classification, source/session provenance, and agent session
linkage.
Notes CRUD/reorder/reminder routes resolve the acting owner through `require_user()`: auth-enabled anonymous requests fail closed before hitting owner-scoped queries, while documented no-login/single-user modes still resolve to the compatibility owner path.
Reminder policy:
- "remind me at 5pm" should become a todo/note with a due date;
- calendar event alarm/reminder UI writes reminder Notes;
- calendar events are for scheduled time blocks, meetings, appointments, or explicit calendar requests;
- creating a calendar event named "Reminder" does not create notification behavior.
Built-in reminder/persona prompt text is mirrored server-side for reminder synthesis and scheduled task execution; frontend persona selectors are UI over that server-owned id map, not the authority.
Reminder dispatch is Note-owned:
- `dispatch_reminder()` owns browser, email, ntfy, generic webhook, in-app notification, optional LLM reminder text, and dedupe behavior;
- the scheduler note scanner calls note-ping actions for backend due-note delivery with per-owner notification state, and calendar-event reminders are treated as Note-owned reminders rather than separate scheduler event pings;
- the notes frontend has a browser-tab fallback for visible sessions;
- calendar frontend reminder UI stores reminder records as Notes, not calendar-event notification jobs.
Email/ntfy failures degrade into channel result fields rather than blocking every reminder path. ntfy and generic webhook reminder URLs run through outbound URL safety checks, with `REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS` controlling whether private/LAN targets are allowed. ntfy notification titles are converted to ASCII with replacement and capped at 200 characters before entering HTTP headers. Reminder dedupe uses owner-scoped cache files under `data/`.
## Agent, Codex, And CLI Surfaces
`do_manage_tasks`, `do_manage_notes`, and `do_manage_calendar` own agent-side writes. `do_manage_calendar` supports batch event creation plus list range aliases (`start`, `start_time`, `start_date`, `range_start`, `from`, `dtstart`, `since`, and matching end aliases), calendar name/short-id lookup, importance/tag aliases, and reminder offsets expressed as numbers, minute/hour words, or common abbreviations such as `min`/`mins`/`hr`/`hrs`. If a model supplies a loose `query`, `date_range`, or `range` without explicit start/end datetimes, `list_events` returns an error asking the caller to resolve the range and call again instead of guessing. Event classification reads `Memory.text` for personal context before LLM classification. `src.tool_index` encodes the reminder policy that notes/todos own reminders while calendar events own time blocks.
Agent native tool owner handling is not uniform today. `do_manage_tasks()` filters lists only when `owner` is truthy and creates tasks with the passed owner, so `owner=None` can create legacy/null-owner tasks. For authenticated/non-empty owners, edit/delete/pause/resume/run require an exact stored owner match and reject both cross-owner and null-owner rows; `owner=None` retains single-user compatibility. `do_manage_notes()` list/query behavior distinguishes `None` from `""`, with `None` acting as broader single-user compatibility while `""` filters to empty-owner rows in some paths. `do_manage_calendar()` query helpers filter only when owner is not `None`, while calendar creation routes through the calendar fallback owner for default calendars. These are compatibility behaviors, not a cross-user sharing model.
Note and calendar route/tool writers owner-reserve any canonical internal upload
references in content, checklist/color/image fields, descriptions, and
locations before their database writes. Missing or wrong-owner uploads fail the
write instead of creating a dangling durable reference; reservations serialize
with upload cleanup.
Chat forwards browser timezone offset and IANA timezone name so natural-language note/calendar tools can anchor dates to the user clock. A valid IANA zone wins over the fixed offset for current-time/DST reasoning; invalid or absent names fall back to the offset and then server-local/UTC compatibility behavior. Chat can auto-promote note/calendar/reminder intents to agent mode.
Codex todo/calendar wrappers enforce bearer-token owner and `todos:*` or `calendar:*` scopes, then delegate to note/calendar behavior as the token owner. Normal calendar/task/note routes are current-user/cookie routes and should not be treated as scoped bearer-token APIs unless they explicitly use token owner/scope policy.
Direct DB CLIs are local compatibility tools. They bypass HTTP route behavior, CalDAV writeback, and some owner/timezone parsing policy.
## Event Bus
`src.event_bus` owns event-triggered task counters and scheduler handoff. Current emitters include chat/session/document/memory/research/email/skill paths. Ownerless events resolve to a primary configured user instead of broadcasting to every owner.
The current event bus is not a calendar-event emitter despite the adjacent calendar/task/reminder domain.
## Timezone And Date Semantics
- calendar events store offset-aware input as UTC/naive fields plus `is_utc`;
- note `due_date` uses ISO-like strings interpreted through note/tool parsers;
- chat forwards browser UTC offset into `routes.calendar_routes` request-local state for natural-language date anchoring in calendar/note tool parsing;
- generic scheduled task clock times are stored as UTC values after local conversion;
- assistant check-ins can use an IANA timezone on `CrewMember`, with UTC fallback.
Dateutil fallbacks strip timezone-aware parser results back to the naive-UTC contract before recurrence/window comparisons. Calendar agent list tools accept current range aliases implemented by `src.tool_implementations`, and equal/same-day start/end ranges are normalized to a one-day window instead of silently returning no rows.
Natural-language parsers prefer time-first interpretations for short reminder/event phrases where the user supplies a clock time before a date phrase.
Calendar frontend week-start preference is browser-local (`cal-week-start`) with Monday/Sunday controls; it is not persisted as a server preference.
Natural-language date parsing and timezone behavior are compatibility-sensitive and need route/tool/frontend regression coverage when changed. Request-local timezone context is ephemeral and must not be persisted as user state. A valid browser IANA timezone is authoritative over a possibly stale or wrong-sign fixed offset because it carries daylight-saving rules.
## Degraded And Optional Behavior
- CalDAV sync no-ops with shaped errors when unconfigured, invalid, offline, or missing the optional `caldav` dependency.
- CalDAV writeback failures are non-fatal to local calendar writes and are mostly visible through logs.
- Missing or invalid `croniter` rejects cron schedules or yields no next run.
- Missing timezone support falls back to UTC or legacy behavior.
- ICS import depends on `icalendar`; missing dependency can fail before route-shaped error handling today.
- Notes reminders can still use local browser fallback when backend email/ntfy channels fail.
- App backup import/export does not currently include calendar events, scheduled tasks, task runs, or notes; calendar ICS import/export is separate and calendar-only.
## Security And Provenance
Calendar, task, note, and assistant routes are owner-scoped for normal users. Legacy null-owner behavior is compatibility-sensitive and should not silently grant authenticated owners broad mutation rights.
Because auth-disabled chat owners can arrive as `None`, tool-created rows may not use the same owner value as route-created rows. Multi-user or owner-model changes must audit both route and agent paths.
Task creation/update/manual run/webhook/scheduler execution blocks shell-like and Cookbook serve action types for non-admin users through `src.task_action_policy`, and tool security blocks privileged task/calendar tools for non-admin use. Assistant defaults reject synthetic owners such as `api` and `internal-tool`.
Note routes store caller-provided `source`, `session_id`, `image_url`, and agent-session provenance. Canonical internal upload references in persisted note/calendar fields are owner-reserved before writes, and upload-backed bytes remain protected when fetched through upload routes. Arbitrary non-upload image/provenance URLs are not otherwise normalized or validated by note storage.
## Testing Coverage
Existing coverage is strongest around CalDAV URL hardening/writeback, client cleanup and operator CA handling, bidirectional/pending CalDAV sync markers, CalDAV UID calendar scoping, calendar recurrence/timezone helpers, owner-scoped calendar basics, exact-owner task-tool mutations, scheduler restart/cancel/next-run behavior, webhook auth-exemption source shape, canonical/legacy note-module identity, note-route unauthenticated fail-closed behavior, note/calendar attachment reservations, notes CLI/tool due-date behavior, calendar reminder abbreviation parsing, task CLI preview, task persona fields, and same-owner chained task validation.
Route-level coverage is thinner for full calendar route behavior, task CRUD/security/run controls, live webhook token dispatch, notes owner CRUD/reminder delivery, assistant defaults/run status, event-bus triggers, Codex todo/calendar scopes, and frontend panel wiring.
## Current Gaps
- CardDAV still needs URL hardening parity with CalDAV; CalDAV now resolves hostnames during validation and revalidates writeback URLs.
- `do_manage_notes()` should match HTTP note-route owner behavior for legacy null-owner notes.
- Auth-disabled agent tools can produce or read broader owner scopes than route handlers because they receive `owner=None`; tasks, notes, and calendar need aligned policy/tests.
- Task webhook tests should keep exercising live route token behavior and
admin-only action blocking, not only middleware/source strings.
- Reminder delivery needs tests across frontend `/fire-reminder`, backend `dispatch_reminder()`, scheduler note pings, channel degradation, and dedupe.
- Codex todo/calendar scope and owner mapping needs dedicated regression coverage.
- Direct DB CLIs need either documented route-bypassing support status or shared helpers to avoid owner/timezone/writeback drift.
- `scripts/odysseus-webhook` builds the live `/api/tasks/{task_id}/webhook/{token}` path with percent-encoded path segments; its direct DB token rotation/revocation behavior remains a local compatibility surface.
- Assistant default documentation/code comments still mention check-ins that are no longer auto-seeded.
- App backup import/export does not cover the calendar/task/note rows described by this spec.
+154
View File
@@ -0,0 +1,154 @@
# Chat
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers current chat behavior in:
- `routes/chat_routes.py` and `routes/chat_helpers.py`;
- `routes/session_routes.py` and canonical `routes/history/history_routes.py`,
with `routes/history_routes.py` as a compatibility shim;
- `src/chat_helpers.py`;
- `src/agent_runs.py`;
- `src/chat_handler.py` and `src/chat_processor.py`;
- `core/session_manager.py` and `core/models.py`;
- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment
references and write reservations;
- `src/context_budget.py`, `src/context_compactor.py`, and `src/topic_analyzer.py`;
- `src/foreground_model_routing.py`, `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`;
- `routes/workspace_routes.py` for workspace selection support;
- frontend modules `static/js/chat.js`, `static/js/chatStream.js`, `static/js/chatRenderer.js`, `static/js/sessions.js`, `static/js/search-chat.js`, `static/js/compare/stream.js`, `static/js/workspace.js`, `static/js/composerArrowUpRecall.js`, `static/js/streamingSegmenter.js`, `static/js/group.js`, and `static/js/notes.js`;
- integration points with uploads, documents, compare, research, agent tools, memory, RAG, search, and model endpoints.
## Session Ownership
`core.session_manager.SessionManager` owns session persistence and message writes. `routes/session_routes.py` owns session list/create/update/archive/delete/folder/importance behavior for the sidebar. `routes.history.history_routes` owns history/topic surfaces, with `routes/history_routes.py` kept as a compatibility shim.
`core.models.Session` and `ChatMessage` are pure data containers. They do not own persistence; `Session.add_message()` delegates to the configured session manager when present.
Startup session discovery selects non-archived sessions by the existence of persisted `ChatMessage` rows rather than trusting the denormalized `Session.message_count`. It computes authoritative counts only for the bounded discovery set, then keeps full message hydration lazy.
## Streaming
`routes/chat_routes.py` owns `/api/chat`, `/api/chat_stream`, detached stream resume/stop/status, injected context, chat-message search, and rewrite routes. Streaming is the main UI path.
`static/js/chat.js` owns send/abort/continue UI state, the main fetch/read loop, SSE parsing, rendering dispatch, workspace form wiring, and background/resumable stream tracking. `static/js/chatStream.js` owns UI-control event handling and stream/research notification helpers. `static/js/sessions.js` polls server stream status after refresh or session switch. `static/js/composerArrowUpRecall.js` owns prompt recall from the composer when the caret is at the top of an empty input.
Runtime behavior:
- the `/api/chat*` prefix is exempt from the global request hard timeout;
- browser chat sends `X-Tz-Offset` and an IANA timezone name; request-local helpers prefer a valid IANA zone for DST-aware current-time reasoning, then fall back to the fixed offset;
- browser chat can send a selected workspace path; route code only resolves it for admin/single-user flows, validates it as an existing directory, and forwards it so agent file/shell tools are confined by `src.tool_execution`;
- stream callbacks can outlive a deleted session, so persistence must fail closed instead of recreating orphan messages;
- message metadata carries timestamps, metrics, tool events, sources, hidden
thinking/reasoning text when providers expose it separately, context-trim
metrics, structured attachment references, and related UI state;
- metadata preserves requested and actual reply models and endpoints, per-round route transitions, and answering-route cost attribution; stable session ids remain available so prompt/sequence-memory and KV-cache paths can address the same conversation consistently;
- multimodal content can be a list of content blocks for the live provider call,
while persistence collapses raw media into readable text and stable
attachment-reference lines;
- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop,
and intent-without-action guard events so the frontend can distinguish a
controlled stop from a stalled response.
`src.agent_runs` owns detached in-memory stream runs, replay buffers, replacement cancellation, resume subscribers, explicit stop, and terminal-buffer eviction. Closing the SSE connection does not necessarily stop generation. `static/js/chat.js` can live-resume a still-running detached stream through `/api/chat/resume/{session_id}`; rich responses reload from DB for canonical rendering. Detached runs are process-local and do not survive server restart.
Provider adapters live below chat in `src.llm_core`. Chat consumes normalized SSE output, fallback/error events, reasoning/tool deltas, and metrics. Foreground chat is strict to the selected route by default. Only the selected owner can opt in through `foreground_fallback_enabled` plus ordered `foreground_model_fallbacks`; the retired `default_model_fallbacks` key is ignored. Eligible pre-content availability failures can advance through at most ten owner-visible exact model candidates, while missing configuration/endpoints, provider/schema errors, clean empty completions, and post-content failures remain on the selected route and surface an error. Once a route produces substantive text/reasoning or a tool call it is pinned as the answering route.
Fallback candidates receive route-neutral context shaping. Only compaction performed for the answering route is persisted. Chat and agent metadata record requested/actual model and endpoint identity, round-by-round route transitions, and costs against the route that actually answered; the browser renders same-model endpoint changes as well as model changes.
## Context Preface
`routes.chat_helpers.build_chat_context()` owns the shared route pipeline: preset extraction, preprocessing, user-message persistence, incognito/no-memory/RAG/skills flags, prefetched compare search, YouTube transcript context, research-spinoff grounding, model normalization, and compaction.
`src.chat_processor.ChatProcessor.build_context_preface()` owns source preface construction. It can add memory, RAG, web search, URL page content, and skills index context before the model call.
Chat preface enhances the model's context. It must not rewrite the user message or force literal-vs-fetch interpretation before the model sees the request. See [context-building.md](context-building.md).
Chat-owned external context must enter the model through `untrusted_context_message()` unless a different treatment is explicitly documented. This includes memory, RAG, web search, URL fetches, prefetched search context, YouTube transcripts, research injection, and manual context injection.
## Modes And Handoffs
Chat can dispatch to normal LLM calls, agent mode, research mode, or compare-related flows. Session mode is stored on `sessions.mode`.
Legacy plan-mode backend plumbing still exists below chat, but `routes/chat_routes.py` currently forces browser/form `plan_mode` input off and the old visible plan window frontend module is not part of the current SPA. Treat plan-mode changes as compatibility work unless the UI contract is intentionally reintroduced.
Current call sites include:
- chat/research dispatch in `routes/chat_routes.py`;
- agent execution in `src/agent_loop.py`;
- deep research orchestration in `src/research_handler.py`;
- compare entry points in canonical `routes/compare/compare_routes.py` and frontend compare modules.
Agent-mode tool access is gated in layers. Chat route toggles and privileges
build a disabled-tool set; incognito and compare mode remove persistence-heavy
or UI-breaking tools; `src.action_intents.message_needs_tools()` provides
conservative regex auto-escalation hints; `src.agent_loop`,
`src.tool_security`, `src.tool_execution`, and internal loopback validation
remain server-side enforcement owners.
`allow_bash` and `allow_web_search` can be read from the JSON request body for browser chat posts that do not submit traditional form fields.
Web search tools are per-turn explicit opt-in. Either `allow_web_search=true`
or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit
`allow_web_search=false` wins over `use_web=true` and keeps those tools
disabled. Explicit latest-turn web-search intent can still auto-escalate into
agent mode and narrows the available tool set toward `web_search`/`web_fetch`,
but it no longer re-enables web tools after an explicit denial or global
disable.
Guide-only/no-tools requests build an effective tool policy before preprocessing and agent dispatch. That policy suppresses tool-backed preprocessing/background extraction/research, disables schemas and MCP for the turn, and is still enforced by `src.tool_execution` if a model emits a tool call anyway.
When route context is trimmed without full compaction, chat emits a
`context_trimmed` SSE event and carries before/after message/token counts into
metrics. Provider reasoning/thinking deltas are streamed for live UI handling
but kept out of the visible saved assistant content and stored in metadata when
available.
## Attachments
`src.chat_handler.ChatHandler.preprocess_message()` owns owner-scoped upload-id resolution, attachment metadata, YouTube transcript/comment preprocessing, image/VL behavior, and enhanced text used by chat. `src.document_processor.build_user_content()` owns conversion of uploaded/chat-attached files into model-ready text or multimodal blocks. `src.attachment_refs` owns persisted text/reference normalization, and `SessionManager` owner-reserves attachment ids before appending or replacing durable message rows. `static/js/fileHandler.js` owns frontend pending-file state.
Attachment-only sends are valid. Missing or unauthorized ids are skipped during preprocessing, while a missing/wrong-owner durable reference aborts a message/history replacement before existing transcript rows are removed. Upload failures keep pending files for retry, unsupported media can degrade to text markers, optional Office/PDF/VL dependencies can emit extraction banners, Office attachments can create markdown documents when extracted server-side, and fillable-PDF auto-document failures fall back to normal PDF extraction. `chat_messages.content` and FTS do not retain provider data URLs; structured references stay in metadata for reloads. Chat does not own upload bytes or durable document storage; it requests document/upload behavior from those subsystems.
Frontend chat distinguishes normal resend from regenerate-from-here: normal resend appends a fresh user copy and carries upload IDs where available, while regeneration truncates from the selected point. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used.
Native document tool outputs can open or refresh the document editor from
tool-result metadata, so the UI can recover if a later `doc_update` stream event
is missed. The chat renderer also hides raw/incomplete leaked tool JSON and
document fences from normal transcript text.
When untrusted external/workspace content has entered the agent context, high-impact tool calls pause as exact approval cards instead of executing. The browser can allow the rest of the interrupted task, allow this chat session, or deny; it submits only the opaque id/decision with an empty control-plane message and does not mutate the composer. The server restores the sealed first action plus private selected tools/query, revalidates policy and document freshness, consumes the first action, and resumes without persisting a synthetic user message. Task scope ends with that resumed run. Chat scope persists the resolved card and marks later context only for that exact session; forks do not inherit it. A normal message retires an unresolved card while preserving taint.
## Security And Provenance
`/api/chat` and `/api/chat_stream` verify session ownership before loading the session. Chat privilege gates enforce allowed models and daily message caps before LLM work. Active document injection, session auth/header recovery, endpoint repair, upload-id resolution and reservation, memory/RAG retrieval, and post-response work must stay owner-scoped.
The scoped API-token chat surface is `/api/v1/chat`. Browser chat routes can receive bearer-auth state from middleware, but route code must not assume `"api"` is a durable owner; API-token support requires explicit scope checks and token-owner attribution.
Incognito disables memory, skill, and chat-history tools and skips assistant DB persistence, but current user-message persistence and later cleanup are not a strict no-write guarantee. Treat incognito changes as security-sensitive until that contract is clarified.
## Search Boundary
`GET /api/search` in `routes/chat_routes.py` is chat-message search for the UI and slash commands. Web search routes are owned by canonical `routes/search/search_routes.py`; chat and agent web context call through `src.search`, compatibility shims, and search content fetchers. Do not confuse chat-history search with external web retrieval.
## Degraded And Compatibility Behavior
- Missing ChromaDB, embeddings, memory vectors, RAG managers, or skills indexes should remove injected context or fall back to keyword/text behavior without failing chat.
- Direct URL prefetch failures become compact untrusted context stating that the page was not read, with only transport-owned HTTP/size/rate-limit status where recognized; raw URLs, exception text, and response-controlled diagnostics are not echoed into logs or model context.
- Sessions hydrate legacy string headers and multimodal JSON-array content, export text/HTML/Markdown after flattening non-string blocks, can lazy-load from DB when cached state is empty, and preserve old history/index delete behavior where needed.
- Initial shell/session loading is non-blocking: the sidebar can render before a selected transcript is hydrated, and full transcript hydration is deferred until display or a model send requires it.
- Chat repairs empty selected models and orphaned endpoint references before provider calls when possible.
- Deleted-session stream writes fail closed.
- Docker/native endpoint differences are owned by runtime/model setup, but chat sessions depend on the saved endpoint URLs and headers.
- Copying a response from the UI copies the displayed answer text and omits hidden reasoning/thinking segments.
## Current Gaps
- Chat, agent, research, and compare orchestration still meet in a large route file.
- Context preface behavior is spread across `routes/chat_helpers.py`, `src/chat_processor.py`, route injections, and agent/tool paths.
- Detached stream lifecycle spans `routes/chat_routes.py`, `src/agent_runs.py`, `static/js/chat.js`, `static/js/sessions.js`, and non-chat callers.
- Some frontend stream state is still global/module-level in `static/js/chat.js` and needs careful session isolation when adding background or resumable flows.
- Chat lacks route-level SSE regression tests for `/api/chat_stream`, live resume/stop/status, mode handoff, persistence metadata, partial-save behavior, attachment/doc-update events, browser timezone offset/workspace handling, and literal URL context intent.
- Bearer-token behavior on browser chat routes and incognito persistence need explicit contract decisions and regression coverage.
+79
View File
@@ -0,0 +1,79 @@
# Compare
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model A/B comparison behavior in:
- canonical `routes/compare/compare_routes.py`, with `routes/compare_routes.py` as a compatibility shim;
- `routes/session_routes.py`;
- `routes/chat_routes.py` and `routes/chat_helpers.py`;
- `routes/model_routes.py`;
- canonical `routes/search/search_routes.py`, with `routes/search_routes.py` as a compatibility shim;
- `core/database.py` model `Comparison`;
- `src/llm_core.py` and `src/endpoint_resolver.py`;
- frontend modules under `static/js/compare/`;
- `static/js/chat.js`, `static/js/sessions.js`, `static/js/models.js`, and `static/js/slashCommands.js`;
- `tests/test_compare_*` and focused blind-compare redaction tests.
## Runtime Behavior
The active text compare UI creates ordinary `[CMP]` sessions through `/api/session`, then streams each pane through `/api/chat_stream` with `compare_mode=true`. Search compare is a separate branch: it can query `/api/search/query` directly and its synthesis sessions use ordinary chat streaming without `compare_mode=true`. `static/js/compare/index.js` owns compare orchestration, session creation, execution order, search-mode branching, and export actions. `static/js/compare/panes.js` owns pane add/remove/swap/reroll lifecycle. `static/js/compare/stream.js` owns pane streaming and event rendering.
`routes/compare/compare_routes.py` owns the `/api/compare` HTTP surface for alternate/legacy start/vote/history/delete behavior and the active `/api/compare/record` vote-summary endpoint. The top-level module is a compatibility alias. Legacy `/api/compare/start` uses neutral helper-session names and withholds model identities/mapping from the start response while blind mode is active. It does not own provider-specific payload behavior.
Current call sites include:
- `/api/session` compare session creation and cleanup in compare frontend modules;
- `/api/chat_stream` pane execution through chat routes and detached stream infrastructure, streamed directly into panes so upstream generation stops promptly when panes are stopped;
- `/api/models` and probe routes for model/endpoint selection;
- search-provider compare mode through `routes/search/search_routes.py`;
- `/api/compare/record` as a fire-and-forget backend vote summary, while active scoreboard state is localStorage-backed.
`Comparison` rows currently persist vote/history metadata: prompt, first model identifiers, winner, blind flag, optional N-model JSON in `blind_mapping`, vote timestamp, and owner. Response and metric columns exist in the schema but are not populated by the active compare UI flow. Compare history must be owner-scoped.
Frontend compare behavior is split by responsibility:
- `state.js` owns local compare state;
- `selector.js`, `models.js`, and `probe.js` own endpoint/model selection and probe UI;
- `panes.js` and `stream.js` own paired response rendering;
- `vote.js` and `scoreboard.js` own voting and history display.
Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` routes those controls into the main chat renderer/control plane, pauses pane completion/autograding while a choice is pending, and can resume the pane after the user decision; compare orchestration keeps its busy state until those continuations settle.
Mobile compare layout collapses multi-pane grids to a single column so panes
remain readable on narrow screens while the desktop grid still uses the
selected column count.
## Ownership Boundaries
Compare owns paired evaluation flow and pane state. Chat routes own the actual stream execution path for compare panes. LLM provider code owns model-call mechanics. Session/model routes own endpoint-id resolution, owner-filtered endpoint/model visibility, header copying, and deleted-endpoint failures.
`compare_mode` in chat strips compare-breaking tools, disables document tools for `[CMP]` sessions, skips some research clarification, and suppresses memory, skill, and webhook side effects after pane responses.
Compare frontend code is part of the app DOM security surface. Current stream/search rendering sanitizes probe labels and tool labels, constrains search-result links to HTTP(S), uses safe generated-image display sources, and opens compare export/image popups with opener isolation.
## Policy Notes
- Current blind compare is UI/API masking until vote/reveal, not a full confidentiality boundary. `[CMP]` session names and session-list model fields are redacted for helper sessions, and legacy `/api/compare/start` withholds model identity/mapping while blind. Client-side selected model state and privileged/local inspection can still expose identity.
- Compare endpoint lists and secondary endpoint lookups use owner filtering so users see and resolve only shared or owned endpoints.
- Non-admin compare session creation must use registered owner-visible endpoints; compare must not allow arbitrary raw endpoint URLs to bypass session-route endpoint policy.
- Prefetched search, URL, RAG, and research context entering compare panes must use the untrusted-context wrapper.
- Compare panes use chat's foreground routing contract: selected routes are strict unless that owner explicitly enabled ordered foreground fallbacks. Verify each pane still reaches its intended route and that any opt-in route transition or error is visible.
## Degraded And Compatibility Behavior
- Missing/offline endpoints are surfaced by model/session routes; chat can clear orphaned endpoint references and recover empty models when possible.
- Compare streams inherit chat's opt-in, eligible-pre-output-only foreground fallback and provider-normalized SSE events, but compare frontend handling for errors and model/endpoint route transitions is thinner than chat's stream path.
- Shared legacy `ModelEndpoint.owner == NULL` rows remain visible through owner filters. Legacy `Comparison.owner == NULL` rows are not treated as shared for authenticated vote/delete/history flows.
- `/api/compare/start` and `/{comp_id}/vote` remain implemented but are not the active frontend path.
## Current Gaps
- Blind mode is not a confidentiality boundary; client/local state can still expose model identity before vote.
- `/api/compare/start` accepts raw endpoint URLs and can diverge from `/api/session` endpoint-owner/raw-endpoint policy.
- `src/agent_loop.py` advertises stale compare app API endpoints.
- Compare streaming and chat streaming are separate frontend paths but share model/provider infrastructure; regressions can happen when provider event shape changes.
- Compare frontend needs explicit fallback/error event handling parity with chat streaming.
- Compare tests cover endpoint owner helper behavior, blind compare redaction, ask-user/tool-approval routing, and portable JS helpers, but not full active `/api/session` pane creation, frontend pane lifecycle, or complete SSE fallback/error handling.
+113
View File
@@ -0,0 +1,113 @@
# Context Building
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model-context construction in:
- `src/chat_processor.py`;
- `src/chat_handler.py` and `src/youtube_handler.py`;
- `routes/chat_helpers.py` and context injection in `routes/chat_routes.py`;
- `src/agent_loop.py`;
- `src/tool_execution.py`;
- `src/attachment_refs.py` and uploaded-file manifest construction in
`routes/chat_helpers.py`;
- `src/tool_policy.py`;
- `src/prompt_security.py`;
- `src/tool_capabilities.py`, `src/tool_approval_scopes.py`, and `src/tool_approvals.py`;
- transport primitives in `src/outbound_fetch.py` plus fetch/extraction adapters in `src/search/content.py` and `services/search/content.py`;
- search orchestration in `services/search/core.py` and the compatibility wrapper in `src/search/core.py`;
- RAG and personal docs in `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, and `src/personal_docs.py`;
- research flows in `src/deep_research.py`, `src/research_handler.py`, and `services/research/research_handler.py`;
- memory and skills in `src/memory.py` and `services/memory/*`;
- related policy in `THREAT_MODEL.md`.
## Contract
Context-building tools gather evidence. They do not own user-intent routing.
Runtime rules:
- if external context is available, add it as compact untrusted source data;
- if an attempted source is unavailable and relevant, represent the unavailable state explicitly with source and reason when known;
- preserve the user's original message for the model;
- do not use regex preprocessing to force literal-vs-fetch intent;
- do not disable tools or force a reply style solely because preprocessing found a URL.
## Untrusted Data
`src.prompt_security` owns the untrusted wrapper:
- `UNTRUSTED_CONTEXT_POLICY` states global model policy;
- `untrusted_context_message(label, content)` wraps source content as user-role data with `metadata.trusted = False`, provenance origin, and an `arm_tool_gate`/`tool_gate_untrusted` signal that defaults to arming the server-owned tool gate.
Current untrusted context sources include:
- fetched URLs and web search results;
- webpage content passed into deep-research extraction;
- YouTube transcripts/comments;
- RAG/personal document chunks;
- memories and skills;
- notes and active editor documents;
- emails and attachments;
- tool output from external/user-controlled data.
Live multimodal provider blocks can contain data URLs, but persisted and
tool-facing context uses stable attachment references. Tool manifests carry an
`odysseus://attachment/<id>` URI and owner-checked read policy; local paths are
compatibility data added only after owner and root-confinement checks. Persisted
chat context keeps readable text/reference lines rather than reinserting raw
media bytes into later turns or search state.
## URL, Search, And Tool-Derived Context
Chat URL prefetch and agent `web_fetch` are different paths. Chat prefetch happens before the model call; `web_fetch` is a tool the model may choose later. Both should converge on the same intent: enrich context when content is available, represent unavailable content when it is not, and let the model interpret the user request.
Search results and fetched pages are evidence. `web_search` should not force a page fetch unless its explicit contract says it does. Failed fetches should not crash chat or silently imply content was read. Canonical search content fetchers can extract readable text from HTML, `text/*`, Markdown, `.txt`, `.json`, and `.jsonl` responses and should return shaped error results for HTTP status failures. URL fetches validate every redirect hop and pin the outbound connection to a public IP resolved during validation, so context-building callers do not need a second DNS-rebinding guard.
Current behavior is not yet unified:
- successful chat URL prefetch is wrapped as untrusted context; failed prefetch now adds a compact untrusted statement that the page was not read, recognizes only transport-owned HTTP/size/rate-limit categories, and suppresses raw exception/response text;
- agent `web_fetch` returns explicit URL-specific tool errors for timeout, unsupported scheme, fetch failure, or no readable text;
- comprehensive search reports provider-chain failures, but individual page-fetch failures can be logged and omitted;
- YouTube fetching is owned by `ChatHandler`/`youtube_handler`, while `routes.chat_helpers` only wraps the resulting transcript/comment strings.
`src.outbound_fetch` owns reusable synchronous public-URL classification, per-hop DNS resolution/pinning, redirect handling, and body budgets. `services/search/core.py` owns `comprehensive_web_search()` orchestration. `services.search.content` owns content extraction and adapts the shared transport; `src/search/core.py` and `src/search/content.py` preserve compatibility imports without a second implementation.
## Tool Result Envelope
`src.tool_execution` executes and formats tools. Tool output caps live in `src.constants` and are re-exported through older facades; shared native-tool truncation lives in `src.tool_utils`. `src.agent_loop._append_tool_results()` owns model re-entry: native tool calls return as provider-style `role: "tool"` messages with untrusted metadata, while fenced-tool results use the untrusted wrapper. Classification considers both the requested tool and the result payload, so remote or stored model-visible content can arm the session gate even on a failed tool status.
Taint is server-owned continuation state, not a model instruction. After untrusted external/workspace context, low-impact reads can continue, but high-impact, unknown, and arbitrary MCP actions become proposals that produce an exact approval card. The server seals the exact first action plus private continuation tool/query state; document actions also bind the current document version and digest. A chat decision can allow the resumed task or persist a grant for later turns in that exact chat, while non-chat callers remain single-action. Blocked/approval placeholders and content-free failures do not recursively arm the gate.
Context budgeting uses known model context windows when available. `src.context_budget` treats the default 6000-token value as an automatic sentinel, scales to a capped fraction of known context length for non-explicit budgets, and leaves unknown windows on conservative defaults.
Side-effect enforcement lives outside context building. Chat route disabled-tool policy, `src.tool_security`, `src.tool_execution`, and `do_app_api()` block unsafe tool execution; prompt wording alone is not the authority.
Guide-only/no-tools policy can suppress context acquisition before the model call. `src.tool_policy` feeds chat route preprocessing and agent-loop assembly so tool-backed search/research/memory/RAG/skills/local-context paths are skipped when the latest user turn explicitly forbids tools.
## Degraded And Optional Dependencies
- ChromaDB, HTTP embeddings, and FastEmbed are installed/expected in normal setups but must degrade cleanly when a service, package, or embedding backend is unavailable.
- `src.rag_singleton.get_rag_manager()` owns RAG startup retry throttling; `src.rag_vector.VectorRAG` is the live owner-filtered path; `src.rag_manager.RAGManager` is compatibility/backward-compat behavior.
- Memory-vector and tool-index retrieval can fall back to keyword/text behavior when vector stores or embeddings fail.
- Docker compose and native installs use different Chroma host defaults; model endpoint loopback rewriting is owned by model/runtime specs.
## Current Call Sites Include
- `ChatProcessor.build_context_preface()` for memory, RAG, web search, URL content, and skills index;
- `ChatHandler.preprocess_message()` and the canonical `services.youtube.youtube_handler` import path for YouTube fetch/format, then `routes/chat_helpers.py` for wrapping prefetched search/Youtube context;
- `routes/chat_routes.py` research context injection;
- `src.agent_loop` for active editor document, skill context, and tool-result reinsertion;
- uploaded-file manifest/reference context for agent tools and later chat turns;
- `src.tool_execution` for `web_search`, `web_fetch`, file, shell, MCP, and other tool outputs;
- `src.deep_research` and research handlers for search/fetch/extract flows used by research jobs, with fetched webpage text wrapped before extraction and analyzed URLs tracked separately from source snippets.
## Current Gaps
- URL/search context result shape is not unified across chat prefetch, agent tools, and research.
- Failed fetch representation remains inconsistent outside direct chat URL prefetch, especially in comprehensive search and research aggregation.
- Tool/context wording is spread across schema, prompt, and retrieval surfaces.
- Source-specific wrapping and unavailable-state behavior still needs broader focused coverage for literal URL intent, research, RAG/memory/skills, and YouTube; external tool results and approval continuation now have dedicated gate/taint regressions.
- Compare pre-search context is computed but may not be submitted through the current compare stream form.
+195
View File
@@ -0,0 +1,195 @@
# Cookbook And Hardware Fit
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model setup/serving and hardware fit in:
- app route registration in `app.py`;
- `routes/cookbook_routes.py`;
- `src/cookbook_serve_lifecycle.py`;
- `src/host_docker_access.py`;
- Cookbook package/rebuild/shell integration in `routes/shell_routes.py`;
- `routes/cookbook_helpers.py`;
- `routes/hwfit_routes.py`;
- `services/hwfit/*` and `services/hwfit/data/hf_models.json`;
- durable Cookbook state through `routes.cookbook_helpers.COOKBOOK_STATE_FILE`;
- helper/CLI scripts `scripts/odysseus-cookbook`, `scripts/add_hwfit_models.py`, `scripts/hf_download.py`, and `scripts/diffusion_server.py`;
- Docker overlays `docker-compose.gpu-*.yml`, `docker/gpu.*.yml`, `docker/host-docker.yml`, `scripts/check-docker-gpu.sh`, and `scripts/check-docker-amd-gpu.sh`;
- frontend modules `static/js/cookbook*.js`, including Cookbook running, serve, download, diagnosis, progress, and HW Fit modules;
- tests covering Cookbook helpers, routes, CLI state, package detection, frontend progress, HW Fit services, serve profiles, Docker GPU overlays, and GPU diagnostic scripts.
## Current Call Sites Include
- Cookbook modal and state modules in `static/js/cookbook*.js`;
- package readiness/install and rebuild flows through `routes/shell_routes.py`;
- direct shell exec/stream integration used by Cookbook task controls;
- model endpoint setup and serve flows;
- hardware-fit recommendations for model choices;
- image-model recommendations for diffusion serving;
- APFEL/local platform dependency paths where supported;
- Docker GPU helper scripts and compose overlays;
- the `odysseus-cookbook` CLI using the same Cookbook state file.
## Cookbook Runtime
`routes.cookbook_routes` owns model download, setup, SSH key, cached model scan, serve, GPU state, kill-pid, state sync, Hugging Face latest lookup, vLLM recipe lookup, serve diagnosis, and task-status endpoints. `src.cookbook_serve_lifecycle` bridges scheduled `cookbook_serve` tasks into serve/stop behavior; task/calendar scheduling ownership stays in `calendar-tasks-notes.md`.
Access policy is split by surface:
- download/setup/SSH key/cache scan/serve/GPU/kill/state/task-status are admin/internal-tool surfaces;
- `/api/cookbook/hf-latest` is authenticated-user gated;
- HW Fit routes are authenticated read/probe routes through normal middleware, not admin-only operations;
- bearer API tokens do not satisfy Cookbook admin gates.
Runtime behavior:
- POSIX and most remote flows run detached through tmux;
- local Windows uses detached process/log/pid behavior under `%TEMP%\\odysseus-tmux`; Python first publishes a valid Win32 fallback PID, then Git Bash may replace it with `/proc/$$/winpid` after a ready-file handoff, so PowerShell `Stop-Tree` can terminate the actual serving shell and children instead of receiving an MSYS PID. Frontend PowerShell venv activation is quoted safely and the local Git Bash runner converts a valid `Scripts\\Activate.ps1` prefix into `source <git-bash-path>/Scripts/activate` so the selected environment actually supplies the serve binary;
- remote Windows uses PowerShell runner scripts;
- missing `tmux`, `docker`, or serve-engine binaries return shaped errors where possible;
- local Docker inside the Odysseus container is available only when the Docker CLI exists, `ODYSSEUS_ENABLE_HOST_DOCKER=true`, and `/var/run/docker.sock` is actually mounted as a socket; otherwise Cookbook should show the host-Docker access hint and prefer remote SSH Docker workflows;
- model serve auto-registers LLM or image `ModelEndpoint` rows immediately, then frontend readiness probing can repair/create fallback endpoints;
- diffusion-server serves are registered as image endpoints;
- MLX image serves use `scripts/mlx_image_server.py`, which pins generation/edit dispatch to the model chosen at process start and ignores OpenAI-compatible per-request model selectors;
- vLLM recipe routes fetch and cache model recipe manifests/YAML from `vllm-project/recipes`, normalize base args/env/dependencies/tool-calling/reasoning variants, and expose compatible strategy metadata for serve setup;
- Hugging Face download/setup paths can detect and persist encrypted HF tokens for later Cookbook/agent use;
- local and remote model paths can contain spaces or non-ASCII characters when helper validation/quoting accepts them;
- task status handles tmux, remote Windows logs, local Windows PID/log files, HF cache completion checks, stale browser-state download guards, pip dependency-install success sentinels, exit-code wrappers, serve diagnosis snapshots, and scheduled serve lifecycle hooks;
- scheduled serve lifecycle stop attempts only persist `status=stopped`, clear `_scheduledStopAtMs`, and delete auto-registered endpoints for sessions whose tmux/remote stop command succeeded or were already gone; failed stop attempts are logged without marking unrelated expired serves as stopped.
`routes.cookbook_helpers` owns validation and command construction:
- repository and model IDs;
- local directories, SSH hosts/ports, GPU selectors, and tokens;
- shell quoting for Bash and PowerShell;
- pip/install fallback chains;
- safe environment prefixes;
- serve command validation;
- user-shell PATH bootstrap, Git-Bash drive-path conversion, preflight, and exit-code helpers.
Cookbook routes request shell/SSH behavior; they do not relax shell security.
## Shell Dependencies
`routes.shell_routes.py` owns Cookbook-adjacent package readiness/install, shell execution/streaming, and llama.cpp rebuild endpoints. The Cookbook UI calls these routes for dependency diagnosis, install/update actions, engine rebuilds, and tmux/reconnect/stop/kill flows. Windows uses detached log/PID wrappers where POSIX tmux is unavailable.
These are admin-only code-execution surfaces and should be reviewed with Cookbook changes even though they are implemented outside `routes.cookbook_routes.py`.
## State, Secrets, And Provenance
Cookbook state lives under the shared data dir through the `COOKBOOK_STATE_FILE` constant, normally `data/cookbook_state.json`. Routes and the `odysseus-cookbook` CLI use the same state path.
State behavior:
- browser-facing state masks secrets;
- server-side `env.hfToken` is encrypted before storage;
- task payloads strip raw HF tokens;
- browser local storage strips HF token values;
- state POST has anti-wipe guards for server lists;
- state POST rejects stale `done` download state when the latest shard/cache markers still show an incomplete download;
- recent server-side tasks are preserved against stale browser overwrites;
- task-status validates saved shell-bound fields before SSH/tmux commands.
Cookbook auto-registered endpoints are currently shared/null-owner rows with no API key when created by backend serve registration. Browser fallback registration goes through the normal model-endpoint route. The desired ownership policy for Cookbook-created endpoints should remain explicit.
HW Fit is an MIT-licensed llmfit adaptation; attribution lives in project acknowledgments/licenses.
## Hardware Fit
`services/hwfit/hardware.py` owns hardware detection across NVIDIA, AMD, Apple Silicon, Windows, CPU, RAM, available RAM, remote SSH, container/native probe context, and cached host detections.
`services/hwfit/models.py`, `fit.py`, `profiles.py`, `image_models.py`, and
`hf_discovery.py` own model catalog loading, normalization, API-backed dynamic
catalog refresh, memory estimates, quantization labels, fit scoring, serve
profile computation, image model ranking, and backend/format servability
filtering.
`routes/hwfit_routes.py` owns the HTTP surface and manual hardware override application.
Runtime behavior:
- hardware detection uses a cache with `fresh=true` bypass;
- probe results include scope/container visibility metadata, and containerized no-GPU/low-RAM states can return user-facing visibility warnings with rescan/manual/copy-diagnostics actions;
- manual hardware replacement is a what-if simulator, not additive hardware;
- manual hardware accepts `cuda`, `rocm`, `metal`, `cpu_x86`, and `cpu_arm`
backends and must stay in lock-step with backend support in `fit.py`. Metal
simulation marks unified memory and filters toward locally servable GGUF/MLX
choices instead of CUDA/vLLM-only formats.
- ignore switches can drop detected GPU/RAM before ranking;
- homogeneous GPU grouping targets realistic multi-GPU pools;
- image model ranking normalizes to a single-GPU fit view;
- Metal/RDNA/backend restrictions can filter otherwise fit models.
- Apple Silicon bandwidth estimates use chip/core-specific tables for M-series Max/Pro/Ultra variants and avoid matching non-Apple GPU names.
- Windows and Apple/consumer-AMD paths filter toward GGUF/llama.cpp-compatible
choices. On multi-GPU systems, fixed GGUF target quantization that cannot be
served by the selected backend returns `no_fit` rather than `None`.
## Platform And Degraded Behavior
- Linux, Windows/PowerShell, macOS, Docker, NVIDIA, AMD, Apple Silicon, and CPU-only systems have different command paths.
- Remote hosts are accessed through SSH helpers; Cookbook host/port/path inputs must be validated before command construction.
- HW Fit remote host/port query values currently do not share all Cookbook route-level validation before SSH probing.
- Missing local tools or failed installs should surface command/output/error detail where possible.
- GPU overlays remain optional and do not break CPU-only deployments.
- Docker GPU overlays pass host devices/env; they do not install CUDA/ROCm engines by themselves.
- Default Docker Compose intentionally does not mount the host Docker socket. `docker/host-docker.yml` is an explicit high-trust overlay for operators who accept broad host-Docker control from inside the container.
- NVIDIA Docker diagnostics are read-only by default, and `.env` edits/install actions require explicit flags.
- AMD Docker diagnostics are read-only and do not mutate `.env`.
- vLLM is rejected on unsupported Windows/macOS paths.
- llama.cpp CPU-only and GPU fallback scripts should preserve usable CPU paths.
- SSH probe failures, GPU driver errors, and no-GPU states should be distinguishable.
- Remote SSH host/port validation is shared through route validators for Cookbook/HWFit paths.
- Windows launcher/runtime Git Bash discovery includes per-user installs under `%LocalAppData%\\Programs\\Git`, and WSL/Git Bash detection shapes PATH handling for NVIDIA/remote flows.
- macOS startup helpers start ChromaDB alongside the app path.
- Ollama serve can auto-pick an available port, and scheduled task stop paths
verify stop success before persisting a stopped state.
## Model Catalog And Latest Lookup
HW Fit model scoring depends on bundled `services/hwfit/data/hf_models.json`,
bundled `services/hwfit/data/mlx_community_models.json`, runtime dynamic caches
under `DATA_DIR/hwfit/`, catalog normalization, and assumptions about model
formats and quantization. `scripts/add_hwfit_models.py` updates the static HF
catalog.
Hugging Face latest lookup and HW Fit dynamic refresh use external Hub metadata
and can degrade to empty, unknown-size, partial, or malformed-result behavior.
`refresh_catalog=1` refreshes API-backed collection caches for MLX community
and selected HF organization collections, with a 24-hour freshness guard and
bundled JSON fallbacks when the network/cache is unavailable. HW Fit tolerates
non-numeric `gpu_count` values from callers. Model normalization also treats
non-string `parameter_count` and quantization fields as unknown rather than
calling string methods and aborting the ranking pass. Catalog drift and dynamic
latest-model metadata are separate sources of recommendation drift.
## Security Policy
Admin gates must stay in place for install, serve, kill, setup, state mutation, and shell-like actions. `/api/shell/exec` is an admin primitive used by Cookbook task control and must stay in this review boundary. Scheduled `cookbook_serve` tasks are admin-only action tasks; task create/update/manual run/webhook/scheduler execution must all reject or pause them for non-admin owners.
Kill-pid guardrails:
- admin-only;
- PID floor;
- signal allowlist;
- validated remote host/port;
- frontend confirmation for TERM/KILL cleanup.
Shell-bound Cookbook inputs must pass helper validation before command construction. HF tokens, Cookbook state secrets, and endpoint API keys must remain encrypted or masked and must not be written back to clients in raw form. Host Docker socket access must stay opt-in and clearly distinguished from merely having a Docker CLI in the container.
## Testing Coverage
Existing coverage is strongest for helper validation/quoting, SSH host validation, pip fallback and dependency-completion regressions, cached scan scripts, serve profile computation, scheduled serve lifecycle state persistence, hardware detection/ranking across AMD/NVIDIA/macOS/manual/container modes, MLX/Metal ranking and request-model pinning, manual backend simulation, Docker GPU compose overlays, Cookbook CLI state, package detection, Windows venv/path/task helpers, non-numeric GPU counts, non-string model catalog fields, and selected frontend progress regressions.
Route-level auth/security and degraded-return coverage is thinner for Cookbook admin routes, shell dependency routes, `/api/cookbook/hf-latest`, state/status edge cases, HW Fit routes, frontend JS behavior, and helper scripts such as `hf_download.py`, `add_hwfit_models.py`, and `diffusion_server.py`.
## Current Gaps
- Cookbook-created model endpoint ownership/shared/null-owner policy needs a deliberate decision.
- `/api/shell/exec` and Cookbook package/rebuild routes need to remain cross-referenced with shell/admin specs because they are Cookbook-critical code-execution surfaces.
- Cookbook route auth/security and degraded-return behavior need route-level tests.
- `/api/cookbook/hf-latest` needs tests locking its user-authenticated access policy and failure behavior.
- HW Fit routes need route-level tests around missing catalogs, manual overrides, `fit_only`, profiles, and image-model cases.
- Dependency install/serve diagnosis remains split across Cookbook routes, shell routes, frontend diagnosis, optional binaries, and platform-specific scripts, even though longer serve-output tails are centralized through `routes/cookbook_output.py`.
- Model catalog, quantization, backend, and Hugging Face metadata drift need ongoing maintenance.
+205
View File
@@ -0,0 +1,205 @@
# Documents, RAG, And Uploads
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers file/document context, document storage, and vector retrieval in:
- `app.py` and `src/app_initializer.py` route/manager wiring;
- `routes/upload_routes.py`, `routes/personal_routes.py`, `routes/embedding_routes.py`, canonical `routes/document/document_routes.py` and `routes/document/document_helpers.py`, plus their top-level compatibility shims;
- chat attachment paths in `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, and `src/chat_processor.py`;
- `core/session_manager.py`, `src/attachment_refs.py`, `src/upload_handler.py`,
`src/upload_limits.py`, and the public reference contract in
`docs/attachments.md`;
- `src/document_processor.py`, `src/document_actions.py`, `src/personal_docs.py`, and `src/markitdown_runtime.py`;
- `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, `src/chroma_client.py`, `src/embeddings.py`, and `src/embedding_lanes.py`;
- PDF/form helpers in `src/pdf_runtime.py`, `src/pdf_forms.py`, and `src/pdf_form_doc.py`;
- `services/docs/service.py`;
- document, upload, RAG, chat, email, and admin frontend callers in `static/app.js`, `static/js/chat.js`, `static/js/chatRenderer.js`, `static/js/fileHandler.js`, `static/js/document.js`, `static/js/documentLibrary.js`, `static/js/rag.js`, `static/js/admin.js`, `static/js/emailInbox.js`, and `static/js/slashCommands.js`;
- tests covering upload, document, attachment, PDF, RAG, Chroma, MarkItDown, and embedding behavior.
## Runtime Integration
`app.py` registers upload, personal-doc/RAG, embedding, document, diagnostics, and Codex document routes. `src.app_initializer.initialize_managers()` creates `UploadHandler` and `PersonalDocsManager`, installs the upload handler on `SessionManager` and the shared tool helper, and startup attempts to initialize the RAG singleton. App route wiring passes that same handler to session/history, document, note, and calendar writers that can persist upload references.
`src.rag_singleton.get_rag_manager()` returns the live `VectorRAG` instance when Chroma/embedding dependencies are reachable. Personal routes can retry the singleton and return explicit 503s when unavailable. Chat RAG uses the `PersonalDocsManager.rag_manager` captured during app initialization and can silently skip RAG if that manager is absent.
## Uploads And Attachments
`src.upload_handler.UploadHandler` owns upload IDs, safe filenames, upload metadata, owner rename rewrites, atomic `uploads.json` writes, content-type detection, and file storage under `data/uploads`. Upload IDs accept extensionless values or one sanitized alphanumeric extension.
Upload-index reads track the live and `.bak` files by device, inode, size, nanosecond mtime, and ctime, then verify the combined signature after parsing. This catches same-timestamp corruption/replacement and prevents stale parsed data from being cached under a newer file identity. Non-destructive reads can recover from the backup; destructive cleanup requires a valid live index and never treats an older backup as deletion authority. Lifecycle writes can synchronize the backup so intentionally removed metadata is not resurrected.
`src.upload_limits` owns central upload-size caps and environment overrides for chat attachments, gallery, transforms, memory import, personal uploads, email compose, STT audio, and ICS imports. Invalid configured limits fail fast at import so routes do not silently accept unsafe sizes. Docker installs `libmagic1` plus `python-magic` so `UploadHandler.detect_content_type()` can sniff bytes in the official image; native installs can fall back to extension/MIME guesses when `python-magic` is unavailable.
`routes/upload_routes.py` owns:
- `POST /api/upload`, returning uploaded file metadata;
- reference-aware admin upload cleanup and stats;
- `GET /api/upload/{file_id}`;
- `GET/PUT /api/upload/{file_id}/vision` for editable OCR/vision cache;
- thumbnail and masked owner/admin access behavior.
It does not currently expose a general upload list/delete route. Download/preview responses that serve uploaded content should include `X-Content-Type-Options: nosniff` where route code owns the response so browser MIME sniffing does not widen accepted upload types.
Readable/code-like upload handling includes common text/code extensions plus `.nix`; document processing renders recognized code-like text into fenced blocks with language metadata.
Chat does not own attachment extraction. Runtime flow:
- the frontend uploads files and submits attachment IDs;
- `ChatHandler.preprocess_message()` resolves IDs with the session owner through `UploadHandler.resolve_upload()`, which enforces owner/admin access and no longer treats missing owner context as permission to read owned uploads;
- vision/OCR cache and attachment metadata are prepared before model calls;
- text-only models receive stripped multimodal blocks;
- `src.document_processor.build_user_content()` produces model-ready text, PDF text, Office/EPUB text when MarkItDown or the DOCX fallback is available, image/multimodal blocks, truncation, and PDF/Office auto-document updates;
- chat streams attachment, PDF-created `doc_update`, and `rag_sources` events where applicable.
Extensionless image and audio attachments derive their data-URI subtype from
the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become
invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename
has no extension.
## Durable References And Cleanup
`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw
upload storage: attachment id, name, MIME type, size, and optional checksum,
creation time, dimensions, vision text/model, and gallery id. Live provider
calls may still receive multimodal data URLs for the current turn, but durable
chat content is normalized to readable text plus compact reference lines.
Structured references remain in message attachment metadata, and chat FTS
triggers omit inline media while startup migration scrubs legacy indexed data
URLs.
Agent/tool manifests expose `odysseus://attachment/<id>` with
`read_policy: "owner_checked_upload"`. A compatibility filesystem path is
included only after owner-aware upload resolution, upload-root confinement, and
tool-readable-root checks; the stable contract for external tools is the URI
and attachment id, not host layout.
Writers reserve referenced uploads before committing durable state. This
includes session message append/replace and history rewrites, document
create/update and native document edits, note route/tool create/update,
calendar/event route/tool create/update, and attachment-bearing session
updates. A missing or wrong-owner reference aborts before destructive
replacement and surfaces a route conflict or tool error. Reservations serialize
with cleanup through the upload-index lock and refresh access time.
Admin cleanup first scans chat content and attachment metadata, current and
versioned documents including PDF markers, gallery filenames/hashes, note
image/color/content/checklist fields, and calendar color/description/location
fields. Reference discovery or index-integrity failure aborts cleanup; the
lower-level API removes nothing without both completed id and hash snapshots.
Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp
metadata are candidates. Matching index rows are persisted away before byte
deletion and restored if deletion fails. This lock is process-local, so the
documented race protection assumes the current single-worker deployment.
## Living Documents And PDF
`routes/document/document_routes.py` owns the HTTP document API: create/read/update/archive/delete, library listing, import/export, version history, tidy/AI tidy, PDF rendering/export, PDF form helpers, and email-attachment reply preparation. The top-level document route/helper modules remain compatibility aliases.
`static/js/documentLibrary.js` owns local library state after archive/delete actions, including total counts and language chips. Server route truth still owns durable document state.
`static/js/document.js` owns the browser document editor and markdown preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document.
Document mutations also happen through agent tools, Codex document routes, email attachment import, and scripts. HTTP and native-agent document writers owner-reserve any internal upload/PDF references before persisting new current content or versions. Native document tool outputs include metadata that the browser can use to open/update the editor if a later stream update is missed. Those callers must preserve document owner, attachment, and version semantics.
After external/workspace-untrusted context, a proposed document mutation is sealed into an exact approval with document id, current version, content digest, tool content, owner/session, and workspace. Approval continuation re-reads and verifies those fields before consuming the one-use authorization, so an intervening edit cannot apply a stale approved patch to new content.
Email draft documents are a first-class document language. Create/update paths
detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve
protected reply/forward headers such as `In-Reply-To`, `References`,
`X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original
history when model or UI edits replace the draft body. Creating a draft for the
same source UID/folder in the same session updates the active draft instead of
creating a duplicate.
`Document` rows own current content and owner. `DocumentVersion` rows own immutable snapshots. Document access should be owner-filtered, not session-id-only; the session document listing path still needs regression coverage for per-document owner filtering after the session owner check.
PDF runtime behavior:
- direct PDF import stores the upload through `UploadHandler`;
- PDF library entries preserve metadata/preview behavior for source PDFs;
- pypdf text extraction remains core;
- PyMuPDF enables form detection, page rendering, page PNGs, annotation fill, render/export PDF, and form filling;
- PDF render routes should return a shaped 503 when PyMuPDF is absent and use same-origin framing/download behavior for rendered pages;
- imported PDFs become either plain `pdf_source` markdown or `pdf_form_source` markdown with sidecar field data;
- PDF markers must resolve back through an upload owned by the caller;
- signed-reply preparation uses document `source_email_*` provenance and verifies the document owner and signature owner. Source email account resolution still needs explicit owner-scoped coverage.
Office/EPUB attachment extraction is optional and MarkItDown-backed for `.docx`, `.pptx`, `.xlsx`, `.xls`, and `.epub`; a pure-Python DOCX fallback can extract `word/document.xml`. When a session id is present, full extraction can be saved as a markdown `Document` while the chat-inline copy remains capped.
## Personal Docs And RAG
`src.personal_docs.PersonalDocsManager` owns personal-directory indexing and keyword retrieval.
`src.rag_vector.VectorRAG` owns Chroma/embedding-backed indexing and owner-filtered retrieval. Chunk ids are owner-scoped so byte-identical chunks from different owners do not suppress each other. `src.rag_singleton` owns lazy initialization, retry throttling, and reset behavior.
`routes/personal_routes.py` owns personal-doc and direct RAG-upload routes. Directory list/index/delete routes are admin-gated, and directory indexing runs in a worker thread so traversal/extraction does not block the async event loop. Direct RAG upload is user-authenticated, requires document privilege, forwards owner into the manager wrapper, writes unique files under per-owner subdirectories of `data/personal_uploads`, and has looser file-type validation than normal uploads.
Current call sites include:
- admin RAG pages and slash commands;
- chat RAG preface building;
- AI interaction and MCP RAG management tools;
- CLI scripts for document/personal indexing.
Some non-route tool/script paths can index ownerless or arbitrary directories and should be treated as compatibility-sensitive management surfaces.
## Embedding Models
`routes/embedding_routes.py` owns admin-gated embedding model and custom endpoint management. It validates custom endpoints with outbound URL checks, can persist and process-expose `EMBEDDING_API_KEY`, resets embedding/RAG/tool-index/Chroma state, and does not own document extraction.
`src.embeddings` owns HTTP embedding fallback to FastEmbed and process-level endpoint state. `src.embedding_lanes` keeps custom HTTP embedding vectors separate from FastEmbed fallback vectors with lane-specific Chroma collections, migrates legacy unsuffixed collections into empty lanes, and dedupes query results across lanes. `src.chroma_client` owns native Chroma defaults and fast reachability checks.
## Compatibility State
`src.rag_manager.RAGManager` is a backward-compat wrapper. The live owner-aware vector path is `VectorRAG`.
`services/docs/service.py` is a separate facade. It accepts live `VectorRAG` query rows (`document`, `similarity`, nested metadata source), retains legacy `text`/`content` and `score` fallbacks, skips non-object rows, and maps live `indexed_count`/`failed_count` plus legacy `indexed`/`failed` index summaries into its dataclasses.
`src.database` re-exports `core.database`; document models and migrations live in `core.database`.
## Optional And Degraded Behavior
- ChromaDB/FastEmbed are default installed dependencies, but Chroma can be offline or unreachable.
- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service and persistent Chroma storage.
- HTTP embeddings can fall back to FastEmbed; when both lanes exist, lane separation avoids Chroma dimension conflicts.
- MarkItDown is optional for Office/EPUB extraction; chat attachments and personal directory indexing have clear degraded behavior, while direct RAG upload does not share the same extraction path.
- PyMuPDF is optional, unlocks PDF form/render/fill paths, and carries AGPL implications when installed.
- PyMuPDF-dependent document routes should use the shared runtime helper/error text so missing-dependency and license policy stay visible.
- pypdf text extraction is core and should remain available without PyMuPDF.
## Security And Provenance
Uploaded files, documents, RAG chunks, extracted attachment text, OCR/vision text, PDF marker content, and source-email metadata are untrusted external or user-provided context when sent to an LLM.
Concrete enforcement points include:
- `UploadHandler.resolve_upload()` for upload ID validation, owner/admin access, and upload-dir confinement;
- owner-checked write reservations before durable attachment references are
stored, sharing the upload-index lock with reference-aware cleanup;
- PDF marker ownership checks before resolving source uploads;
- personal-directory and personal-upload confinement helpers, including symlink/realpath checks before deleting uploaded files or removing indexed directories;
- owner-filtered `VectorRAG.search(owner=...)`;
- shared untrusted-context wrappers for RAG preface insertion.
Extracted attachment text is currently appended into the user message rather than wrapped as a separate untrusted-context message. That is current behavior and a prompt-injection hardening gap.
Bearer-token callers are not a scoped document/upload API surface today. Routes that treat token-authenticated users as owners need explicit scope/effective-user policy before they are considered safe token APIs.
## Testing Coverage
Existing useful coverage includes upload owner scope, upload IDs, upload atomicity, durable attachment reference normalization, message/document/note/calendar write reservations, fail-closed reference-aware cleanup, attachment budgets, `.nix` text upload handling, upload/PDF security regressions, Docker `libmagic`/`python-magic` upload detection, RAG owner fallback, Chroma fast-fail, MarkItDown runtime, PDF runtime, document-library counter updates, and selected document helper behavior.
Route-level coverage is thinner for document CRUD, PDF import/render/export/fill, direct RAG upload, embedding admin/security behavior, and RAG unavailable states.
## Current Gaps
- Direct RAG upload still needs clearer file-type validation and MarkItDown/PDF extraction parity decisions.
- Document `session_id` relinking and session document listing need owner-scope regressions.
- Chat RAG can remain degraded after startup even if personal routes later initialize the RAG singleton.
- PyMuPDF-dependent routes do not all share the same optional-runtime helper/error behavior.
- Signed-reply preparation needs owner-scoped source email account/signature regression coverage.
- Document/upload routes need explicit bearer-token scope/effective-user policy.
- User-facing document/PDF/RAG route matrices need more regression coverage for owner denial, admin gates, unavailable services, and degraded optional dependencies.
+209
View File
@@ -0,0 +1,209 @@
# Email And Contacts
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
This spec covers mail and contacts in:
- app wiring in `app.py`;
- `core.database.EmailAccount`;
- `routes/email_routes.py`, `routes/email_helpers.py`, and `routes/email_pollers.py`;
- email threading in `src/email_thread_parser.py`;
- email MCP tools in `mcp_servers/email_server.py`;
- canonical contact/CardDAV routes in `routes/contacts/contacts_routes.py`,
with `routes/contacts_routes.py` as a compatibility shim;
- Codex email bridge in `routes/codex_routes.py`;
- document signed-reply flows in canonical `routes/document/document_routes.py` and document `source_email_*` fields;
- reminder/task email senders in `routes/note_routes.py` and `src/task_scheduler.py`;
- email/contact agent surfaces in `src/tool_implementations.py`, `src/tool_schemas.py`, `src/tool_index.py`, and `src/agent_loop.py`;
- CLI wrappers `scripts/odysseus-mail` and `scripts/odysseus-contacts`;
- frontend modules `static/js/emailInbox.js`, `static/js/emailLibrary.js`, `static/js/emailLibrary/*`, `static/js/emailShared.js`, `static/js/chatStream.js`, `static/js/document.js`, and `static/js/settings.js`;
- tests under `tests/test_email_*`, `tests/test_contacts_*`, `tests/test_mail_cli_*`, `tests/test_mcp_email_*`, `tests/test_schedule_email_*`, email/contact JS tests, and email security regressions.
## Current Call Sites Include
- browser email inbox/library, compose, schedule, account, and attachment actions;
- document-editor compose, recipient autocomplete, compose uploads, and signed-reply handoff;
- Codex email read/draft/send routes using API-token scopes;
- note reminder and task-output email delivery;
- built-in email summary/reply/calendar/urgency actions;
- scheduled email pollers and CLI one-shot pollers;
- MCP email tools;
- contact manager settings, compose contact autocomplete, agent contact tools, and contacts CLI.
## Email Accounts And Transport
`EmailAccount` rows own IMAP/SMTP configuration. Password fields are string columns containing encrypted ciphertext written with `src.secret_storage`; startup migrations handle legacy plaintext rows. Google OAuth account rows also carry `oauth_provider`, encrypted access/refresh tokens, token expiry, and an optional outbound `display_name`. Do not return decrypted credentials or OAuth tokens, or write them to logs.
Exactly one default account per owner is enforced as a serialized database transition. Startup normalizes legacy duplicate defaults and installs a unique per-owner default constraint/index; first create, delete/promotion, set-default, demo teardown, and owner rename lock the relevant owner rows and commit atomically. Multi-owner rename acquires locks in canonical order so stale concurrent writers fail closed.
`routes.email_helpers` owns:
- account owner assertions and config fallback order;
- IMAP/SMTP connection helpers and related transport utilities;
- Google OAuth2 state signing/verification, token refresh, and XOAUTH2 framing;
- SMTP security modes (`ssl`, `starttls`, `none`);
- envelope recipients and Odysseus headers;
- attachment extraction helpers;
- email pre-retrieval context for AI reply drafting;
- scheduled email, summary, reply, tag, calendar extraction, urgency, and signature-boundary side databases.
Email config can fall back to legacy `data/settings.json` or environment variables when no scoped account is configured. Account discovery now owner-scopes the default/first-enabled fallback and can still match legacy account rows by IMAP username or from-address. That fallback remains compatibility-sensitive in multi-user contexts.
Email owner semantics are route-local and compatibility-sensitive:
- `routes.email_helpers._require_auth()` returns `""` in `AUTH_ENABLED=false` mode, rejects configured auth with no user, and only tolerates first-run anonymous loopback fallback.
- Empty owner is treated as single-user compatibility: account-ownership assertions no-op, default/first-enabled account fallback can be global, and email cache clauses include `owner = '' OR owner IS NULL`.
- Non-empty owners scope account/config/cache queries. Legacy ownerless account
rows are visible to an authenticated owner only when the row's IMAP username
or from-address matches that owner, so old unowned rows do not become global
cross-user accounts in configured multi-user deployments.
`routes.email_routes` owns the HTTP mail surface:
- account CRUD, test, default, and masked config reads;
- Google OAuth authorize/callback for Workspace and .edu Gmail-style accounts;
- list, search, read, folders, and contacts;
- folder role resolution and UID fetch/search helpers used by the route surface;
- owner-scoped route caches and IMAP pool behavior;
- attachments, bulk attachment ZIP downloads, and attachment-to-document flows;
- compose upload, draft/send, `wait_for_delivery`, Sent append, and source `\Answered` marking;
- schedule/list/delete scheduled emails;
- pending agent-draft approval/cancel flows;
- mark read/unread/answered, spam flags, move, archive, and delete. IMAP move/delete/archive operations use UID commands for message identity and fail safe when the requested UID no longer exists; they never reinterpret a missing UID as a sequence number, which could mutate or expunge an unrelated message.
Google OAuth behavior is account-owned:
- `/api/email/oauth/google/authorize` requires an authenticated owner, checks account ownership, HMAC-signs state with account id, owner, and nonce, and redirects to Google with mail/userinfo scopes;
- `/api/email/oauth/google/callback` verifies signed state before token exchange, re-checks the target account owner before writing tokens, stores access/refresh tokens encrypted, stores token expiry as a timestamp, and redirects with generic success/error codes rather than raw provider errors;
- token refresh uses `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`, stores refreshed access tokens encrypted, and logs only generic/account-id context on failures;
- SMTP and IMAP use XOAUTH2 when `oauth_provider == "google"`; OAuth accounts are send-capable without an SMTP password when host and user are configured;
- outbound mail formats the `From` header with `display_name` when present.
- authorize/callback redirect URIs derive their scheme and host from the mounted request unless `GOOGLE_OAUTH_REDIRECT_URI` explicitly pins a value; the browser preserves the selected SMTP security mode during connect and reopens Settings after the callback.
MCP full-message read/reply/attachment fetches use IMAP `BODY.PEEK[]` rather than bare `RFC822`, so iCloud-style servers return the full body without marking messages seen. Poller UID handling must tolerate both bytes and string UIDs. Built-in signature-learning and daily-brief actions also use UID SEARCH/FETCH rather than sequence-number commands.
IMAP helpers quote mailbox names, raise the Python IMAP line cap for large messages, close sockets after connect/login failures, and preserve Gmail FETCH attributes that follow header literals so unread flag state is not lost. Browser list routes offload blocking IMAP work from async handlers; browser search runs in FastAPI's threadpool, rejects CRLF query input, tokenizes quoted phrases/terms, searches FROM/TO/CC/SUBJECT/TEXT, can search Gmail All Mail when an INBOX query should include archived or labelled messages, and supports `scope=folder` when callers intentionally want the selected folder only. The local index fallback can return indexed results when IMAP returns empty or fails.
## Runtime And Pollers
Scheduled email rows live in `data/scheduled_emails.db` and are owner-scoped. Scheduled send times are normalized before storage.
`routes.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`.
Manual and scheduled summaries use the shared LLM adapter and owner-scoped cache instead of constructing provider calls locally. Scheduled summaries use background fallback policy and yield to foreground work; provider exception text is shaped before it can reach the browser.
Urgency delivery publishes through a serialized atomic checkpoint transaction. Generation and membership fences prevent stale scans from overwriting newer state; authoritative scans retire deleted/disabled accounts, partial failures preserve the prior checkpoint, concurrent account-scoped actions merge disjoint facts, and cancellation rolls back without publishing.
Transport degraded behavior:
- IMAP timeouts are clamped by configuration;
- providers can use implicit SSL, STARTTLS, or plain connections;
- poisoned IMAP sockets are reconnected around known provider failures;
- SMTP-capable account fallback is used where supported;
- route helpers, MCP, and CLI do not all share identical SMTP/IMAP parsing and security behavior today.
## Caching And Staleness
Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has its own session SWR cache, cache-buster refreshes, scheduled/search cache exclusions, and stale-row behavior when refresh fails.
Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens, carry immutable mailbox context, and ignore late responses after account, folder, or message changes.
Library prewarm runs only while genuinely idle, as one bounded single-flight request for the default or last-used enabled account and initial page. Visible foreground work, panel lifecycle, account changes, or explicit reads cancel or join it so delayed duplicate IMAP work cannot escape the idle gate.
List/read route caches are owner/account-aware. Helper-side summary, AI-reply, tag, calendar-extraction, urgency-alert, and learned sender-signature tables carry owner columns and owner clauses. Thread-boundary rows are still keyed by message shape rather than a full owner/account/mailbox key, so they remain cross-owner audit points when identical messages appear in multiple mailboxes.
## Attachments And Signed Replies
Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX depends on `python-docx`; PDF form/open-in-doc flows can depend on optional PyMuPDF.
Email attachment-as-document flows stamp `Document.source_email_*` provenance. `GET /api/email/attachments-download/{uid}` builds an owner-scoped ZIP of visible non-signature attachments using safe names. `compose-from-odysseus` and `compose-from-odysseus-zip` can stage owner-visible documents and gallery images as compose uploads, preserving legacy session fallback only where the source object remains visible to the owner. `prepare-signed-reply` verifies document ownership, reconstructs reply headers, flattens/stages signed PDFs as compose uploads, and leaves final send/draft review to the compose flow.
Email bodies and attachments are untrusted model context.
## Threading And Rendering
`src.email_thread_parser` owns splitting plaintext/HTML email threads into quoted conversation parts. Frontend email library modules own reply-recipient logic, signature folding, local state, and rendering behavior. Bulk selections are cleared when folder/account loads, search text, search pills, or result scope changes so actions cannot carry stale UIDs into a different visible context. `static/js/emailShared.js` owns shared email UI helpers used across inbox/library surfaces.
Remote inbound email HTML is sanitized by frontend email-library utilities before `innerHTML` insertion. Server-side email routes sanitize composed/generated outbound HTML with an allowlist before draft/send, dropping scripts/styles and unsafe attributes. Both sides are part of the rendering invariant.
When the email reader is active, browser chat sends selected-message metadata. `src.tool_implementations` stores that request-local active email reference, `src.agent_loop` injects it as protected untrusted context, and `static/js/chatStream.js` handles `ui_control open_email_reply` so default reply/draft behavior opens the selected message's compose flow instead of a generic new document.
## MCP Email
`mcp_servers/email_server.py` exposes email tools for MCP/agent use. It has its own account discovery, IMAP/SMTP, attachment, cache, and send paths, but account visibility now mirrors the HTTP owner policy. The active owner comes from a hidden `_odysseus_owner` argument when the caller provides one, or from `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`. If any enabled account is owner-scoped and no current/configured owner exists, email MCP returns an owner-scope error instead of listing global accounts.
MCP email account filtering includes owner-owned rows and legacy ownerless rows
whose mailbox/from-address matches the owner. Confirmation-first `send_email`
resolves the selected account before stashing an `agent_draft`, so drafts cannot
be staged against another owner's account. MCP-created draft documents use the
resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER`
and single-admin fallback only as document-visibility compatibility.
MCP email send behavior is confirmation-first by default: `send_email` and reply send paths stash a `scheduled_emails` row with `status='agent_draft'` when `agent_email_confirm` is true, and browser routes expose pending drafts for approval or cancellation. Separate MCP draft tools create Odysseus compose documents for user review without sending.
MCP email remains a separate local/admin trust boundary. Public and non-admin users must not see or execute email MCP tools. It still needs route-helper parity audits for attachment path containment, sanitization, transport behavior, and pending-draft result text, but global all-account behavior is no longer the current owner model.
## Contacts
`routes.contacts.contacts_routes` owns global/admin contacts and CardDAV behavior. The top-level `routes.contacts_routes` module is a compatibility shim. The canonical package supports local contacts, CardDAV config, list/search/add/update/delete, VCF/CSV import/export, and clear.
Contact runtime behavior:
- contacts routes are admin-gated;
- local `data/contacts.json` is used when CardDAV is unconfigured;
- import paths tolerate malformed or non-string contact bodies by skipping invalid rows instead of crashing the import;
- configured CardDAV uses REPORT with GET fallback and a short in-memory cache;
- configured-but-offline CardDAV can return cached reads but writes fail instead of falling back to local JSON;
- CardDAV config reads mask the password, settings-stored passwords are encrypted with `src.secret_storage`, omitted password updates preserve the existing secret, and an explicit empty password clears it;
- the native contacts CLI is CardDAV-oriented and does not fully match web JSON fallback behavior;
- agent contact tools reuse helper functions in-process because the HTTP routes require browser/admin auth.
Contacts are global admin-only data today. There is no per-user contact sharing model unless a future spec defines one.
## Security Policy
Email HTTP access is owner-scoped, including account selection, scheduled email rows, and attachment routes. Null-owner/single-user compatibility paths are security-sensitive and must not allow cross-user mailbox access.
Codex email routes are the scoped bearer-token email API. They enforce `email:read`, `email:draft`, and `email:send` scopes and use token-owner attribution before borrowing email route handlers.
Known security policy details:
- decrypted email credentials stay process-local;
- account/config reads mask passwords and expose only OAuth status fields, not access or refresh token values;
- SMTP/IMAP security mode behavior is part of the credential contract;
- Google OAuth state and callback owner checks are part of the account-boundary contract;
- scheduled emails must remain owner-scoped;
- email pre-retrieval contacts context is allowed only for admin/single-user situations;
- MCP attachment downloads need route-level path-containment parity; current MCP paths are separate from the HTTP compose/attachment helper path.
CardDAV credentials and URLs are security-sensitive. CardDAV URL setup and derived href writes/deletes pass through outbound URL validation; absolute hrefs from a CardDAV server are constrained back to the configured origin before credentials are reused. CardDAV passwords in settings are encrypted and masked on read; environment-sourced legacy password values are used as supplied.
## Degraded Behavior
- IMAP/SMTP providers can be slow or inconsistent; folder resolution, pooled connections, and reconnect behavior should fail with clear errors.
- Google OAuth requires external Google endpoints plus configured `GOOGLE_OAUTH_CLIENT_ID`/`GOOGLE_OAUTH_CLIENT_SECRET`; missing client credentials or refresh failures degrade to reconnect-required or generic OAuth error paths.
- Scheduled email delivery depends on `scheduled_emails.db`, poller runtime, and configured SMTP.
- Attachment handling must tolerate missing staged files, unsupported formats, and inaccessible remote messages.
- CardDAV local fallback applies only when CardDAV is unconfigured; configured CardDAV outages are not treated as local-write mode.
- Multi-account list/search behavior can be sequential and cache-sensitive.
## Testing Coverage
Existing coverage includes header/envelope/IMAP/SMTP behavior, serialized default accounts, Google OAuth state/callback/token-refresh/XOAUTH2/redirect/settings behavior, shared-adapter summaries, authoritative read/mark-seen and frontend dedup, idle prewarm, UID-only mutations, scheduled-email claims and urgency checkpoint transactions, MCP full-message/owner behavior, owner scope/caches/signatures, thread/sanitizer behavior, CardDAV password encryption, mail CLI behavior, contacts basics, and selected frontend/security regressions.
Route-level and duplicate-path coverage is still thin for email list/read/search/mutations, account CRUD/security outside the OAuth path, send/draft security, attachments, scheduled-poller failures, contacts admin/CardDAV routes, MCP account/scope behavior, CardDAV degraded mode, and executable frontend behavior.
## Current Gaps
- Owner-keyed cache policy still needs an explicit decision for thread boundaries, plus continued migration/query audits for every email side table.
- CardDAV still needs redirect/proxy policy and broader route-level tests for URL validation, private-address blocking configuration, and same-origin href enforcement.
- MCP email needs continued route-helper parity for attachment path containment,
sanitization, transport behavior, and pending-draft result text.
- Empty-owner route compatibility and ownerless email cache rows need
end-to-end owner-boundary tests.
- CLI send/contact paths need parity decisions for SMTP security, recipient parsing, local fallback, and normalized contact shapes.
- Email HTTP route coverage is concentrated in scheduling/account-test helpers rather than full list/read/search/mutation/send/draft/account/attachment flows.
- Contacts coverage lacks admin-gate, config masking, import/export, CardDAV fallback, and CardDAV write-failure tests.
- Multi-account performance and cache staleness remain known audit areas.
+158
View File
@@ -0,0 +1,158 @@
# Frontend
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers the current browser app in:
- static serving and SPA routes in `app.py`;
- CSP/security headers in `core/middleware.py`;
- `static/index.html`;
- `static/login.html`;
- `static/app.js`;
- `static/style.css`;
- `static/js/*.js` and `static/js/*/*.js`;
- vendor libraries under `static/lib/*`;
- custom fonts and static assets under `static/fonts/*`;
- `static/sw.js` and `static/manifest.json`;
- frontend-oriented tests in `tests/*_js.py`, `tests/*.mjs`, `tests/bombadil-spec.ts`, static DOM/CSS/source-shape tests, and app/static tests such as `tests/test_app_static_mime.py`.
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
`static/manifest.json` and `static/index.html` reference PWA icon files under `static/icons/`; the current 192px, 512px, and maskable icon files exist and should stay aligned with those references.
## Current Call Sites Include
- `static/index.html` script tags and modulepreloads;
- `static/sw.js` `PRECACHE`;
- app-owned SPA deep links for notes, calendar, cookbook, email, memory, gallery, tasks, and library;
- `/login` and app-owned static/HTML routes;
- `/api/activity/heartbeat` browser visibility pings used by the foreground activity gate;
- `static/app.js` route opener/sidebar/tool-window wiring;
- frontend JS helper tests and static HTML/CSS/source-shape regressions;
- CDN dependencies, local vendor libraries, service worker, and PWA manifest.
## Runtime Shape
The frontend is a raw static SPA served by FastAPI. There is no Vite, React, TypeScript, bundler, or generated build output.
`app.py` owns:
- stable `.js`/`.mjs` MIME registration;
- the `/static` mount;
- no-cache headers for `.js`, `.css`, and `.html` static source files;
- nonce-injected SPA/login HTML serving;
- SPA deep-link routes.
`static/index.html` owns the DOM shell and script loading order. It loads browser ES modules directly. Current boot order includes nonce-bearing inline boot scripts, self-hosted highlight.js, modulepreloads, ordered module script tags, `static/app.js`, `static/js/init.js`, `static/js/a11y.js`, workspace/chat helpers, provider device-flow helpers, and service-worker registration. KaTeX and Mermaid are vendored under `static/lib` and injected only on first real math/diagram use rather than loading in the initial HTML.
The two first-paint Fira Code faces are preloaded so the shell does not wait for later CSS discovery. `static/js/startupShell.js` lets the visible shell initialize before session loading completes; session/transcript hydration is deferred and coordinated by `static/js/sessions.js` plus history/session routes rather than blocking first paint.
Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter currently contains the image-editor module graph so an editor never opened online can still open offline. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification.
## Security Policy
`core/middleware.py` owns CSP and security headers. `app.py` injects the per-request nonce into served HTML. New inline scripts or external scripts/styles/images/media must fit the CSP contract or explicitly update it.
`/static/*` is public/auth-exempt. Frontend privilege gates are display-only; backend routes enforce authorization.
XSS/DOM policy:
- prefer DOM construction, `textContent`, and shared escaping helpers;
- Markdown raw HTML preservation must remain constrained through sanitizer helpers;
- remote email `body_html` must pass through the email-library sanitizer before insertion;
- Mermaid, code-runner iframe `srcdoc`, visual reports, remote media, and scattered `innerHTML` templates require explicit review.
- Visual report Markdown HTML is server-rendered and should be treated as security-sensitive alongside frontend entry points and remote media.
Storage/secrets policy:
- localStorage/sessionStorage are for preferences, UI state, offline caches, and user-switch sentinels;
- `static/js/init.js` owns user-switch storage cleanup;
- raw API tokens, provider keys, HF tokens, and other credentials must not be persisted in browser storage unless a feature documents masking/stripping and backend storage ownership.
## Service Worker And PWA
`static/sw.js` owns PWA cache behavior:
- API and non-GET requests are bypassed;
- root navigation uses stale-while-revalidate;
- JS/CSS use network-first behavior;
- other static assets use cache-first with background refresh;
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
`static/manifest.json` owns default PWA metadata. Route-specific manifests can be generated as Blob URLs when supported. Current default icon references must match real files under `static/icons/`.
KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained.
## Module Ownership
Current major frontend areas include:
- chat, stream handling, rendering, sessions, markdown, uploads, voice recorder, TTS, and keyboard shortcuts;
- models, provider setup, pure model-key matching helpers, model picker, presets, search, RAG, settings, and admin;
- settings shell modules under `static/js/settings/`: registry metadata, navigation, finder search, lifecycle/docking, DOM helpers, and persisted sidebar collapse/resize behavior;
- compare modules under `static/js/compare/`, including sanitized popup/search/image handling;
- document editor/library in `static/js/document.js` and `static/js/documentLibrary.js`;
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
Coordinator ownership:
- `static/app.js` owns late orchestration, global fetch 401 redirects, sidebar/tool route wiring, and many `window.*` compatibility bridges;
- `static/js/init.js` owns post-load cleanup, user-switch storage wipe, and cosmetic privilege gates;
- `static/js/storage.js` owns shared key constants and safe JSON helpers;
- feature modules own feature state where possible.
`static/js/appConfig.js` owns one invalidatable promise cache for `GET /api/auth/settings` and `GET /api/tools`, including one-shot login-page settings prefetch, retry after rejected fetches, and explicit invalidation after settings/tool writes. Consumers treat resolved objects as read-only. `static/js/panels.js` owns memoized first-use panel imports; its current registry contains the image editor, shares in-flight imports, and evicts failed imports so a later online retry can succeed.
`static/js/MODULE_SUMMARY.md` is a refreshed ownership/navigation map for the no-build frontend. The current `static/js/` tree, `static/app.js`, `static/index.html`, and executable behavior remain the authority when the summary drifts.
Current small frontend helper contracts include `static/js/model/matchKey.js` for longest-substring model info/pricing matches, `static/js/models.js` for in-flight `/api/models` request sharing, `static/js/providerDeviceFlow.js` for Copilot/ChatGPT Subscription device-flow polling UI, `static/js/composerArrowUpRecall.js` for prompt recall from an empty composer, `static/js/fileHandler.js` for capped pending-file state and collapsed attachment-chip display, `static/js/streamingSegmenter.js` for incremental markdown/code-fence segmentation, `static/js/emojiShortcodes.js` for shortcode replacement, `static/js/documentLibrary.js` for keeping document counters/language chips in sync after archive/delete, `static/js/keyboard-shortcuts.js` for rejecting empty or non-string persisted keybinds before combo parsing, `static/js/modalSnap.js` for reusable desktop modal edge docking, `static/js/toolWindowZOrder.js` for shared portal/window z-index allocation, and `static/js/emailShared.js` for common email UI helpers.
Recent browser behavior contracts include mobile chat Enter inserting newlines while desktop Enter submits; ArrowUp recall only consuming a truly empty composer with the caret at the top, not an unsent multiline prompt; queued prompts preserving mobile behavior; regenerate-from-here versus resend; AI-message delete confirmation; native document tool results opening/updating the editor; and exact tool-approval cards that expose the sealed action/effects/workspace/document identity and submit only opaque task-scope/chat-session-scope/deny decisions without writing synthetic composer text. Chat rendering hides leaked tool JSON/document fences, no longer strips the ordinary word “assistant,” and batches live-thinking DOM updates with bounded timers. Markdown editing/restoration preserves extracted code/math blocks verbatim, including replacement-string `$&` and `$$` text and triple-backtick fences. Session URL hashes are restored, minimized sidebar icon state follows per-tab visibility, detached terminal dots remain centered, and spinner animation starts only when attached.
The Settings finder and navigation are registry-backed, hide admin-only destinations from non-admin users, lazy-load admin panels, and keep the registry synchronized with DOM panels. Email OAuth connect preserves SMTP security and reopens the settings surface; unread message opens use one authoritative backend read/mark-seen request with stale-response guards; email-library prewarm is idle-only, single-flight, bounded to the initial page, and cancelled around visible foreground work.
## UI Policy
- New code must run as browser ES modules without a build step.
- Reuse existing CSS variables, modal/window patterns, icon style, storage helpers, and route conventions.
- Custom font handling includes bundled OpenDyslexic assets plus user-supplied fonts exposed through `/api/fonts/custom`; font and text-size settings must stay coordinated between settings UI, theme helpers, and CSS variables.
- Avoid relying on stale module summaries.
- API shape changes must update the owning JS module and tests.
- Add behavior to large coordinators such as `static/app.js`, `static/js/chat.js`, `static/js/document.js`, or `static/js/settings.js` only when it matches their existing wiring ownership.
## Degraded And Platform Behavior
- Server no-cache applies to `.js`, `.css`, and `.html` source files, not every static asset.
- Service-worker cache changes can affect frontend behavior even when source files revalidate.
- Mobile behavior uses separate CSS/media/hover/safe-area/`100dvh` handling and JS layout code; check it directly.
- Browser APIs such as service workers, Blob route manifests, Web Speech, `getUserMedia`, visual viewport, and storage can be absent or restricted.
- Local libraries and CDN globals degrade differently; document, markdown, math, diagrams, and code runner flows should handle missing globals where possible.
- localStorage migrations and cross-user cleanup are part of compatibility.
## Testing Coverage
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
Missing coverage includes:
- SPA route/static auth and no-cache headers;
- CSP header contents and nonce injection for `/` and `/login`;
- service-worker API/non-GET bypass and cache strategy;
- service-worker precache versus `index.html` script/module tags, including query strings;
- ongoing manifest/icon reference drift;
- module graph/load-order validation;
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
## Current Gaps
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
- There is no build-time type checking, module graph validation, script-order validation, or service-worker precache validation.
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
- `window.*` compatibility bridges remain widespread.
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
- A static asset/route manifest regression should verify files referenced by `index.html`, `manifest.json`, `sw.js`, and app-owned HTML routes actually exist.
+165
View File
@@ -0,0 +1,165 @@
# Gallery, Editor, And Media
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers media surfaces in:
- app route registration and generated-file serving in `app.py`;
- canonical models in `core/database.py`, with `src.database` as a compatibility import path;
- canonical route package `routes/gallery/gallery_routes.py` and `routes/gallery/gallery_helpers.py`, with top-level `routes/gallery_routes.py` and `routes/gallery_helpers.py` compatibility shims;
- generated-image writers in `src/ai_interaction.py` and `mcp_servers/image_gen_server.py`;
- local MLX image compatibility server `scripts/mlx_image_server.py`;
- image tool schemas/dispatch/implementations in `src/tool_schemas.py`, `src/tool_execution.py`, and `src/tool_implementations.py`;
- `routes/editor_draft_routes.py`;
- `routes/signature_routes.py` and document signature consumers in canonical `routes/document/document_routes.py`;
- `routes/emoji_routes.py`;
- `routes/font_routes.py`;
- `src/generated_images.py`;
- `src/visual_report.py` plus research image hide/unhide routes;
- database models `GalleryImage`, `GalleryAlbum`, `EditorDraft`, and `Signature`;
- generated files under `data/generated_images`;
- frontend modules `static/js/gallery.js`, `static/js/galleryEditor.js`, `static/js/editor/*`, `static/js/signature.js`, `static/js/emojiPicker.js`, `static/js/chatRenderer.js`, `static/js/document.js`, `static/js/markdown.js`, and `static/js/theme.js`;
- CLI surfaces `scripts/odysseus-gallery` and `scripts/odysseus-signature`;
- tests covering gallery helpers/routes, generated-image serving, editor drafts, signatures, visual reports, fonts, upload limits, and image endpoint security.
## Current Call Sites Include
- gallery upload, library, album, tag, favorite, ZIP, delete, and saved-project views;
- chat-generated image rendering/edit/delete bubbles;
- agent `generate_image` and stale `edit_image` tool paths;
- MCP image-generation rows/files;
- image editor AI tools and model endpoint pickers;
- document PDF signing with stored signatures;
- visual-report hero/section image insertion and research hide/unhide controls;
- emoji picker/markdown emoji SVG proxy calls;
- theme custom-font loading;
- local gallery/signature CLI inspection.
## Gallery
`routes.gallery.gallery_routes` owns gallery upload/import/library/editor transform behavior: upload dedupe, image/video extension handling, EXIF extraction for images, albums, favorites, tags, generated media metadata, search/filter/sort, owner filtering, ZIP downloads, soft delete, disk cleanup, and chat-history cleanup after image delete. Top-level `routes.gallery_routes` is a `sys.modules` compatibility shim to the canonical module.
Frontend gallery behavior includes upload progress, folder-drop album import, stale-while-revalidate cards, saved editor projects, detail actions, bulk delete/download, and cache-busted image refreshes.
Album assignment and gallery image detail/update endpoints enforce owner scope and fail closed when no authenticated owner is available instead of falling back to broad access.
Generated media provenance:
- generated filenames are opaque hex-like media names, not trusted content hashes;
- upload `file_hash` is a separate metadata field;
- generated files live under `data/generated_images`;
- chat image generation writes files and inserts `GalleryImage` rows through `src.ai_interaction`;
- MCP image generation can create ownerless rows/files;
- generated-but-not-yet-imported images can have no gallery row;
- once a gallery row exists, owner checks decide visibility where the route enforces them.
`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates hex-like image/video filenames, rejects path escape and missing files, serves rowless generated files, checks row owner when a row exists, allows null-owner compatibility rows, and uses immutable/nosniff cache headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters.
## Image Tools And Providers
Gallery/editor image transforms are split across:
- `/api/gallery/ai-upscale` and `/api/gallery/style-transfer`;
- `/api/image/inpaint`;
- `/api/image/harmonize`;
- `/api/image/sharpen`;
- `/api/image/denoise`;
- `/api/image/upscale-local`;
- `/api/image/remove-bg`;
- `/api/image/enhance-face`.
AI image endpoints mostly require image-generation privilege in the gallery route layer. The sharpen route is explicitly auth-gated; utility routes that live outside gallery still need their own route-level gate checks rather than assuming a shared decorator. The chat image-generation session path calls `do_generate_image()` separately and has its own privilege/tool-listing behavior.
Provider behavior:
- OpenAI image edits use multipart `/images/edits`, mask conversion, size coercion, model restrictions, and source compositing where needed;
- diffusion/self-hosted paths use JSON APIs such as inpaint, img2img, variations, harmonize, or A1111-compatible fallbacks;
- client-supplied endpoint URLs on selected routes must pass outbound endpoint validation; DB-selected image endpoints should be resolved through owner-visible endpoint queries before decrypted headers/keys are used;
- provider-returned image result URLs are validated with `src.url_safety.check_outbound_url()` before server-side download, with private-IP blocking controlled by image-route settings;
- AI endpoint path suffixes are allowlisted before proxy/download use so arbitrary endpoint paths cannot be selected through gallery/editor requests;
- editor model pickers load `/api/model-endpoints` and classify image-capable endpoints.
Optional dependency behavior:
- Pillow-backed paths are effectively core for EXIF, rotate, sharpen, and image preparation;
- Real-ESRGAN powers denoise/upscale when installed and otherwise returns install guidance; import-time torchvision compatibility patches run before Real-ESRGAN imports;
- remove-bg tries `rembg`, then transformers-style fallback, then an error;
- face enhancement falls back from GFPGAN/OpenCV toward PIL behavior;
- video uploads intentionally skip EXIF/ffprobe metadata today.
- grounding and mask model inputs cast only `float64` tensors to `float32` before transfer to Apple's MPS backend, because MPS rejects float64; integer/other tensors and non-tensor processor values preserve their normal device-transfer behavior.
## Editor Drafts
`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store title, payload JSON, thumbnail, source image, timestamps, and owner.
Frontend editor behavior is split across `static/js/editor/*` and `static/js/galleryEditor.js`: canvas state, layer panel, masks, history, snapping, stroke pipeline, inpaint/rembg/harmonize tools, AI tool runner, model pickers, an AI edit command box that routes natural-language edit requests into existing inpaint/remove/upscale/background/style actions where possible, import wiring, topbar controls, auto-save, resume by draft ID or source image, draft-only open, and cleanup after close. `static/js/panels.js` loads this module graph on first editor use, shares concurrent imports, retries failed loads, and `static/sw.js` keeps the lazy graph in a separate offline panel precache.
Draft compatibility behavior:
- v2 server drafts store payloads and thumbnails server-side;
- legacy/local raw payloads can still be restored by the frontend;
- PUT 404 can recreate a missing draft row;
- broken image drafts can fall back to the source image;
- final close persist is best-effort.
## Signatures, Emoji, Fonts
`routes.signature_routes` owns reusable signature/stamp rows. Signature image payloads are normalized to bounded PNG base64, encrypted at rest, and owner-filtered; SVG signature input is not preserved. Document PDF render/export paths owner-filter signature IDs before stamping.
`routes.emoji_routes` owns same-origin OpenMoji black SVG proxy/caching. It validates codepoint filenames, caches SVGs under `data/emoji_cache`, and returns transparent no-store SVGs for invalid, unknown, or unreachable codepoints. `static/js/emojiPicker.js` is a curated inline monochrome picker.
`routes.font_routes` owns deriving available custom font family names from static font files under `static/fonts/custom`.
## Visual Reports
`src.visual_report` owns generated research/report HTML image behavior: HTTPS Open Graph image filtering, hero images, section images, icon/logo filtering, hide/reroll client controls, and inline JSON escaping for scripts.
Research routes and handler code own hidden-image persistence. Visual reports render model/source-influenced Markdown to HTML, so raw HTML/link/image sanitization remains security-sensitive.
## Security Policy
Media routes are cookie/current-user surfaces unless they explicitly implement token owner/scope handling. Bearer-token callers that arrive as synthetic `api` users should not be treated as owner-scoped media API clients without explicit policy.
Known boundaries:
- image-generation routes require `can_generate_images`;
- image proxy/editor endpoints currently resolve client-selected, DB-selected, or fallback image model endpoints without full owner-scoped endpoint-key policy or uniform outbound revalidation;
- generated-file serving allows rowless files and null-owner compatibility rows;
- uploads are byte-limited and extension-gated, with content sniffing available through `UploadHandler.detect_content_type()` when `python-magic`/`libmagic` is installed;
- several base64 JSON editor routes accept large decoded image payloads and need route-level size discipline;
- gallery DB filenames should be joined through shared generated-media path helpers before filesystem operations;
- editor draft source image IDs, payloads, and thumbnails are owner-scoped by draft owner but do not fully validate source-gallery ownership or payload size;
- emoji proxy constrains codepoint filenames and degrades invalid, unknown, or unreachable SVGs to transparent no-store placeholders, but remote SVG content still deserves security review;
- visual report Markdown HTML/link/image output needs continued sanitization coverage.
- `scripts/mlx_image_server.py` pins generation/edit routing to the process-start model and ignores request-selected model names, preventing unauthenticated callers from selecting a local model directory/repository whose model-specific script or bridge would execute.
## Degraded And Compatibility Behavior
- Uploaded images record display dimensions with EXIF orientation when possible; EXIF failures warn/degrade.
- Video uploads skip EXIF and have no metadata extraction yet.
- Missing generated files are skipped in ZIP downloads; if all are missing, the route returns no files found.
- Soft delete commits the gallery row state before removing the disk file, so a failed DB write does not orphan a missing image row.
- AI tagging can fail when disk files are missing.
- Static JS/CSS/HTML assets revalidate because there is no frontend build/versioning.
- Gallery/editor frontend state includes stale-while-revalidate and listener cleanup to avoid stale handlers.
- `edit_image` tool schema/implementation currently appears stale against implemented `/api/image/*` and `/api/gallery/*` routes.
## Testing Coverage
Existing tests cover EXIF dimensions, owner-filter helper behavior, direct upload limits, image-generation privilege source shape, sharpen auth, gallery null-user denial, endpoint SSRF/source checks, editor draft payload validation, lazy editor loading/offline precache, MLX request-model pinning, font family derivation, visual-report helper behavior, gallery CLI previews, and selected security regressions.
Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and frontend editor behavior.
## Current Gaps
- Owner-scoped endpoint-key resolution is needed for image proxy/editor routes.
- Media routes need a clear API-token policy: reject token callers, or implement owner/scope handling.
- Generated-image serving needs live route tests for invalid filenames, rowless files, owned rows, null-owner rows, MIME/cache headers, and cross-owner behavior.
- Mutable generated filenames plus immutable cache headers need cache-busting tests for replace/save-over-original flows.
- Base64 JSON editor payload size limits need hardening; upload content sniffing should keep native/Docker parity coverage as dependencies change.
- MCP image generation needs an owner attribution decision or explicit admin-only documentation.
- `edit_image` tool route mapping appears stale.
- Emoji SVG proxy/cache and visual-report raw HTML/link sanitization need stronger tests.
- Optional image dependency fallbacks are mostly untested.
+197
View File
@@ -0,0 +1,197 @@
# Integrations
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers external integration surfaces in:
- `routes/codex_routes.py`;
- `integrations/codex/*` and `integrations/claude/*`;
- `routes/api_token_routes.py` and bearer-token handling in `app.py`;
- `routes/auth_routes.py` integration CRUD/test routes;
- `src/integrations.py` and `data/integrations.json`;
- canonical `routes/webhook/webhook_routes.py` plus its top-level compatibility shim, and `src/webhook_manager.py`;
- task webhook generation/triggering in canonical `routes/task/task_routes.py`, its top-level compatibility shim, `app.py`, `static/js/tasks.js`, and `scripts/odysseus-webhook`;
- companion/mobile pairing in `companion/routes.py` and `companion/pairing.py`;
- provider OAuth/device-flow endpoint links in `routes/copilot_routes.py`, `routes/chatgpt_subscription_routes.py`, `routes/device_flow.py`, and `ProviderAuthSession` rows;
- integration UI surfaces in `static/js/settings.js` and `static/js/admin.js`;
- database models `ApiToken` and `Webhook`.
The SQLAlchemy `Integration` model exists in `core/database.py`, but current Settings generic integration CRUD uses `src/integrations.py` and `data/integrations.json`.
## Scoped Agent Runtime
`/api/codex/*` is the canonical scoped HTTP surface for external coding agents. Claude Code uses the same runtime endpoints; `/api/claude/plugin.zip` only delivers the Claude skill bundle.
`routes.codex_routes` owns:
- `/api/codex/capabilities`;
- todos list/manage through `do_manage_notes()`;
- email list/read/draft/send;
- memory list/add/delete;
- calendar list/create/delete;
- document list/read/create/delete;
- Cookbook task/server/output/cached-model/preset/serve/adopt/stop controls.
`_scope_owner()` owns scope checks and token-owner resolution. `_as_owner()` temporarily runs borrowed route handlers as the scoped owner and restores request state afterward. Borrowed email, memory, calendar, and document route handlers own their domain behavior; Codex routes only adapt them behind scoped access.
Runtime behavior:
- missing scopes return 403;
- invalid payloads return 400;
- unavailable borrowed route surfaces return 503;
- capabilities expose scope-derived booleans and partial availability flags;
- email send and destructive actions remain described as confirmation-required behavior in bundled agent instructions.
- Cookbook adopt/stop paths validate stored remote SSH host and port before interpolating them into SSH commands.
The local integration skill/helper files require `ODYSSEUS_URL` and `ODYSSEUS_API_TOKEN`. They must use `/api/codex/*` and must not bypass Settings/token scopes through SSH, Docker, direct DB access, local files, MCP internals, or app imports. Helper scripts refuse non-`/api/codex/*` paths.
## Bundle Distribution
`/api/codex/plugin.zip` ships the Codex plugin tree from `integrations/codex/`. `/api/claude/plugin.zip` ships only the Claude `skills/` subtree from `integrations/claude/skills/`. These routes require an authenticated browser/user request and do not embed an API token.
Setup instructions are duplicated in integration READMEs and `static/js/settings.js`; they need to stay aligned with live route surfaces and `/api/codex/capabilities`.
## API Tokens
`routes.api_token_routes` owns token profiles, allowed scopes, scope normalization, token creation/update/revocation, and profile metadata shown in Settings. Partial updates preserve existing scopes unless new scopes are supplied, owner checks apply to update/delete, and write scopes auto-include their read scope where applicable.
`app.py` owns bearer-token validation. It accepts `Bearer ody_...`, checks a bcrypt hash through a prefix cache, updates `last_used_at` asynchronously, and stamps:
- `request.state.current_user = "api"`;
- `request.state.api_token = True`;
- `request.state.api_token_owner`;
- `request.state.api_token_scopes`.
The raw token is returned only on creation. Stored state is hash, prefix, owner, scopes, active flag, and timestamps. Token create/update/delete invalidates the auth middleware cache. Companion pairing also mints chat-scoped `ApiToken` rows and invalidates that cache.
Current API-token consumers include:
- `/api/codex/*` scoped agent routes;
- `/api/v1/chat` synchronous external chat;
- `/api/models` catalog reads for `chat`-scoped token owners;
- companion read endpoints;
- selected session and owner-attribution helpers described in `auth-security.md`.
The Cookbook scoped-agent surface currently exposes `cookbook:read` and `cookbook:launch` in Settings and checks them in Codex routes; those scope names must stay reconciled with `routes.api_token_routes.ALLOWED_SCOPES`.
## Generic API Integrations
`src.integrations` owns generic API integration presets, `data/integrations.json`, API-key encryption/decryption, secret masking, plaintext-key migration, enabled integration prompt text, and `execute_api_call()`.
`routes.auth_routes` owns admin-only HTTP CRUD/test routes for these integrations. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health.
`api_call` is the agent/tool execution path for configured integrations. It is blocked for non-admin/public users by tool security, accepts only relative paths, uses the admin-configured base URL/auth settings, and returns truncated external responses to the model, including a sentinel when long JSON lists are shortened. Admin-authored integration descriptions are prompt context; external responses remain untrusted data.
`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and
path-only values, rejects request paths that are not relative absolute paths
(`/...`) or that carry schemes/fragments, treats `/` as the base URL without
appending an extra slash, and checks the final URL through `src.url_safety`.
Link-local/metadata targets are always rejected; setting
`INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private
addresses for operators who do not need LAN integrations.
After validation, `execute_api_call()` pins the outbound connection to the validated IP snapshot while preserving the configured URL, Host header, TLS server name, and redirect policy. DNS cannot select a different destination between SSRF validation and transport.
Current call sites include:
- `src.agent_loop` injecting enabled integration descriptions;
- `src.tool_implementations.do_api_call()`;
- task scheduler discovery/check-ins;
- note reminder delivery through ntfy integrations and the generic webhook reminder channel.
## Webhooks And External Chat
Outgoing webhooks are admin-managed `Webhook` rows. `routes.webhook_routes` owns CRUD/test/toggle/delete and `/api/v1/chat`. `src.webhook_manager` owns allowed event validation, public URL validation, delivery-time URL revalidation, DNS-rebinding-safe pinned-IP delivery, HMAC signing, fire-and-forget delivery, in-flight task references, and delivery status/error persistence. Sanitized delivery errors redact IPv6-style address details.
Allowed outgoing events are:
- `session.created`;
- `chat.message`;
- `chat.completed`;
- `webhook.test`.
Current webhook event emitters include session creation, chat message/completion paths, and `/api/v1/chat` completion.
`/api/v1/chat` is an inbound external chat endpoint. It requires a `chat` API token, checks session ownership before resume, can create a session from a direct API key, and otherwise falls back to the first owner-visible enabled model endpoint. Token-supplied direct `base_url` values use public-URL validation; configured endpoints remain admin-trusted. Logs and delivery/error text that include endpoint URLs should pass through URL redaction helpers before persistence or diagnostics.
## Task Webhooks And Event Triggers
Task webhook triggers are separate inbound webhooks. `app.py` exempts only `/api/tasks/{task_id}/webhook/{token}` from normal auth so external callers can trigger tasks without cookies. `routes.task.task_routes` owns token generation/regeneration and validates task id, token, and active status before queueing a run; the top-level route module is a compatibility alias.
`static/js/tasks.js` displays the live task webhook URL. `scripts/odysseus-webhook url` now emits the same route with percent-encoded task/token path segments; the CLI still reads and mutates task rows directly for list/show/rotate/revoke rather than delegating to HTTP route policy.
Event-triggered tasks use `src.event_bus`; task execution and scheduling ownership lives in `calendar-tasks-notes.md`.
## Companion Pairing
`companion.routes` owns companion/mobile HTTP routes:
- `/api/companion/ping`;
- `/api/companion/info`;
- `/api/companion/models`;
- `/api/companion/pair`.
Read endpoints accept session or bearer-token callers and resolve the effective owner for visible rows. Model responses omit API keys. Pairing `GET` renders the admin form; pairing `POST` is admin-cookie only, mints a normal chat-scoped API token, invalidates the auth token cache, and returns a host/port/token payload as HTML or JSON.
`companion.pairing` owns LAN host detection, pairing payload shape, token minting, and optional QR generation. QR rendering depends on optional `qrcode`; if unavailable or failing, pairing still returns the text payload.
When `COMPANION_BASE_URL` is set, pairing advertises that validated operator-selected v1 address instead of container/request auto-detection. The accepted form is a canonical ASCII `http://` LAN/Tailscale IPv4, single-label hostname, or `*.local` origin with optional valid port and no credentials/path/query/fragment; HTTPS, public/misleading numeric host spellings, percent/backslash/control characters, and unsupported hosts fail closed. Auth-disabled model inventory retains the normal single-user all-endpoints view instead of filtering every ownerless request to legacy-null rows.
## Unified Settings Surface
The Settings Integrations view aggregates several subsystem surfaces:
- generic API integrations;
- Codex/Claude agent token setup;
- CalDAV, CardDAV, email accounts including Google Workspace/.edu OAuth connect flows, MCP/OAuth links, provider device-flow links, and agent tokens.
- provider-auth backed model endpoints such as ChatGPT Subscription and Copilot, where device-flow credentials live in provider auth rows rather than endpoint API-key fields.
Vault and companion/mobile setup are separate settings/route surfaces today, not entries in the unified add-integration list.
This spec owns the cross-integration framing and agent/token/webhook surfaces. Domain internals stay with their subsystem specs: calendar, email/contacts, shell-MCP, vault/auth, and settings-admin.
## Degraded And Compatibility Behavior
- 403 from scoped APIs means a settings/scope restriction.
- 503 from Codex borrowed routes means the domain route surface is unavailable.
- Missing or corrupt `data/integrations.json` loads as an empty list; non-object rows are ignored.
- Plaintext generic integration API keys migrate to encrypted storage on load.
- Webhook delivery has no retry/backoff queue; the persisted state is last status or sanitized last error.
- Webhook URLs are validated at create and delivery time, redirects are disabled,
and delivery connects to the IP set validated immediately before the request.
- Companion LAN detection is best-effort and falls back to local host/port defaults unless a valid `COMPANION_BASE_URL` is configured.
- `ODYSSEUS_URL` must be reachable from the external coding agent; no Docker/native URL rewrite is performed.
## Security And Provenance
- API-token routes must either enforce a relevant scope or document an explicit exception.
- Codex/Claude plugin zips must not expose secrets beyond source instructions and helper files.
- Webhook list responses expose `has_secret`, not the secret value.
- Webhook secrets are encrypted when an API key manager is available; plaintext fallback is legacy/degraded behavior.
- Outgoing webhook signatures use `X-Odysseus-Signature`.
- Generic integration API keys are encrypted at rest and masked in API responses.
- Generic integration base URLs are admin-configured and not the same public-only policy as webhook URLs.
- `api_call` output and remote integration responses are untrusted model context.
- Pairing payloads expose the raw chat token once through HTML/JSON/QR; persisted token storage is hash/prefix only.
## Testing Notes
Current targeted coverage includes API-token CRUD basics, chat-scoped `/api/models` token access, companion pairing/read-only owner scoping, webhook SSRF validation, webhook auth-exempt source checks, webhook CLI token masking, integration-store shape/encryption migration, Google email OAuth route/helper behavior, Cookbook API-token scopes, Cookbook adopt SSH host validation, and `/api/v1/chat` base-url/fallback owner scoping.
The integration audit also ran the targeted venv subset covering those areas with 52 passing tests and one warning.
## Current Gaps
- Codex/Claude scoped routes, owner restoration, degraded 503 behavior, plugin zip contents, and helper-script path refusal need focused regression tests.
- Token profile/update behavior and Settings agent-token scope toggles need direct coverage.
- Codex Cookbook scopes need continued Settings, route-check, and `ALLOWED_SCOPES` regression coverage.
- Generic integration HTTP CRUD/test routes, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin flows need direct coverage.
- `do_manage_tokens()` does not match `/api/tokens` semantics for `ody_` prefix, owner, scopes, and cache invalidation.
- `do_manage_webhooks()` bypasses route behavior and does not cover signing-secret parity.
- Companion read endpoints should either require `chat` scope or be documented as an explicit scope-policy exception.
- Decide whether webhook secret plaintext fallback should remain accepted when the API key manager is unavailable.
- Decide whether generic integration base URLs should stay LAN-capable by default or make `INTEGRATION_API_BLOCK_PRIVATE_IPS=true` the default.
- Admin-authored integration descriptions and `api_call` results enter the untrusted-result/gated-action pipeline, but their product-level trust presentation still needs continued review.
- The dormant SQLAlchemy `Integration` model should be removed, migrated into use, or documented as legacy.
+153
View File
@@ -0,0 +1,153 @@
# LLM Models And Endpoints
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model/provider behavior in:
- `src/llm_core.py`;
- `src/endpoint_resolver.py`;
- `src/foreground_model_routing.py`;
- `src/model_discovery.py`;
- `src/model_context.py`;
- `src/model_capabilities.py`;
- `src/model_capability_readers/`;
- `src/task_endpoint.py`;
- `src/tls_overrides.py`;
- `src/copilot.py`;
- `routes/copilot_routes.py`;
- `routes/chatgpt_subscription_routes.py` and `routes/device_flow.py`;
- `routes/model_routes.py`;
- `routes/session_routes.py`;
- `routes/cookbook_routes.py`, `routes/hwfit_routes.py`, and `services/hwfit/`;
- `src/settings.py`;
- `core/database.py` model `ModelEndpoint`;
- frontend modules `static/js/models.js`, `static/js/modelPicker.js`, `static/js/model/matchKey.js`, `static/js/providers.js`, `static/js/settings.js`, `static/js/admin.js`, `static/js/compare/`, and Cookbook model-serving modules;
- chat, compare, research, STT/TTS, and utility-model call sites.
## Provider Calls
`src.llm_core` owns provider-call mechanics. It handles OpenAI-compatible calls, Ollama normalization, Anthropic payload conversion, GitHub Copilot and ChatGPT Subscription provider detection/header injection, NVIDIA provider routing, streaming, fallback calls, upstream error formatting, async/streaming host liveness caching, configured model-list cache reads, tool-call sanitization, reasoning/thinking stream routing, and provider-specific parameter rules. GitHub Copilot OAuth/device-flow orchestration lives in `routes/copilot_routes.py` and `src/copilot.py`; ChatGPT Subscription device flow uses `routes/chatgpt_subscription_routes.py`, shared device-flow helpers, and `ProviderAuthSession` rows.
`llm_core` owns payload shape. Route files and chat/agent code should request a call; they should not duplicate provider-specific payload quirks.
Kimi Code User-Agent discovery has both sync and async implementations. Async
post and stream paths probe `/models` through their existing async client and
await each candidate, so header negotiation does not block the event loop; both
paths share the accepted-value cache and 403 fallback policy.
Provider-specific behavior is part of this layer: `LLM_CONNECT_TIMEOUT` controls the connect budget for sync and streaming calls, Kimi Code endpoints retry a small whitelisted User-Agent set on 403 and cache the accepted value, official Moonshot/Kimi Code and Anthropic Opus 4.7+ payloads omit sampling controls where required, and major-only Opus IDs such as `claude-opus-5` also omit temperature instead of falling through numeric minor-version parsing. Reasoning models omit or clamp unsupported temperature values, while self-hosted compatible endpoints keep normal parameters unless detected otherwise. Mistral structured content is normalized in async utility calls as well as stream/chat paths, and Mistral/Moonshot/Kimi reasoning content, `gpt-oss` harmony output, DeepSeek V4 thinking identifiers, and native/OpenAI-compatible Ollama thinking formats keep hidden reasoning separate from visible text. Tool names that collide with GPT-OSS built-ins are aliased on the provider boundary and mapped back before execution. Copilot request metadata remains defensive against malformed `request_flags`.
## Canonical Provider And Model Shape
`src.model_capabilities` owns canonical model family, task, modality,
capability, limit, evidence, assertion, deterministic-control, probe-result,
reasoning-control token, and display-query values.
`src.model_capability_readers` owns endpoint-scoped stable identity, lightweight
provider detection, record serialization, and normalization of already-fetched
provider payloads. Readers do no network I/O. Model-specific observations are
kept in `model-quirks.md`, not a runtime registry without a consumer.
Provider support and model support are different facts. A provider may expose
tools, reasoning, vision, or multiple APIs while individual models differ.
Provider-native readers describe where model evidence can appear. Current
concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter,
Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain
unknown.
Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]`
or `models[]` items with `id`, `name`, or `model`; it does not accept a bare
list and never promotes capability-looking fields. Unknown fields remain in
the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md),
[model-quirks.md](model-quirks.md), and the
[provider map](model-providers/_readme.md).
This canonical layer is currently exercised by focused unit tests but is not
wired into runtime discovery, endpoint resolution, model context, request
shaping, or frontend pickers. `routes/model_routes.py` model probes continue to
return model IDs through their existing runtime path.
Route-level probe helpers in `routes/model_routes.py` are the current exception: they build minimal provider-specific probe payloads using `llm_core` detection helpers. Keep probe behavior aligned with `llm_core` provider adapters. LLM provider HTTP clients and endpoint probes share `src.tls_overrides.llm_verify()`, which can add an operator-provided `LLM_CA_BUNDLE` on top of normal certificate verification without turning verification off or widening that trust to arbitrary URL fetches.
## Endpoint Resolution
`src.endpoint_resolver` owns endpoint normalization and URL construction:
- base URL normalization;
- chat and model-list URL construction;
- endpoint ID resolution;
- chat, utility, and vision fallback candidate selection;
- Tailscale hostname resolution where available.
OpenAI-compatible model-list URL construction preserves `/v1` bases and inserts `/v1/models` for bare local bases such as LM Studio `http://localhost:1234`.
`routes/model_routes.py` owns model endpoint CRUD, admin provider discovery/probing, visible/hidden/pinned model lists, endpoint kind and refresh policy, curated/extra model partitioning, `/api/models` catalog caching, Docker loopback rewriting, tool-support probing, provider-auth linkage, endpoint-dependent settings cleanup, and owner filtering. Endpoint dedupe allows the same base URL under different API keys and surfaces API-key fingerprints/key presence without returning secrets.
`routes/session_routes.py` owns binding sessions to endpoint IDs, owner-scoped header construction, raw-endpoint rejection for non-admin users, model validation, and persisted session headers. Compare panes and normal chat session creation use this path.
`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while admins can see all. Secret fields must remain encrypted and scrubbed in responses.
Decrypted endpoint headers can be copied into session metadata for chat use. Endpoint deletion must clear dependent settings and copied session headers.
## Model Discovery And Lists
`src.model_discovery` owns host/env/Tailscale/local-port scanning for model servers. Admin `/api/providers` and `/api/discover` use that scanner; endpoint CRUD, test, refresh, and hidden-model controls are frontend-owned by `static/js/admin.js`.
`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` owns default, utility, vision, image, TTS, STT, and fallback selectors.
`src.task_endpoint` owns background-task endpoint/model resolution for task routes and scheduler callers. It resolves `task_endpoint_id`/`task_model` through the normal endpoint resolver with owner context.
Cookbook and HWFit own local model download, serve, ranking, and auto-registration flows. They can create LLM or image `ModelEndpoint` rows, but provider dispatch remains owned by `llm_core`/endpoint resolution.
## Context Length
`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows.
## Runtime Fallback And Routing
`src.foreground_model_routing` owns foreground Chat/Agent fallback policy. Selected models are strict by default. Fallback requires owner-scoped `foreground_fallback_enabled=true` and an ordered `foreground_model_fallbacks` list; the old `default_model_fallbacks` setting is retired, ignored, and not migrated into consent. Named users never inherit a legacy flat/single-user fallback choice, candidate lists are capped at ten exact owner-visible models, and caller-provided allowed-model restrictions remain authoritative.
Only eligible availability failures before substantive output can fall through. Default eligible statuses are 408, 425, 429, 500, 502, 503, 504, 507, 508, and 529. Missing endpoint/configuration, provider/schema/request errors, empty completions, and post-content failures do not silently change routes. A candidate commits after non-empty visible/reasoning text or a tool call; the answering route is then pinned. Foreground routing carries model and endpoint descriptors together, shapes context/compaction route-neutrally across candidates, persists only answering-route compaction, and records requested/actual/per-round route provenance plus cost attribution. Utility/background and vision fallbacks remain separate policies.
Model selection has three layers: endpoint resolver hidden-model and first-chat-model selection, `/api/default-chat` per-user default/fallback resolution, and frontend picker auto-selection for empty sessions.
Image routing uses model-name prefixes and `ModelEndpoint.model_type == "image"` to bypass text chat and generate media. Vision analysis uses configured vision models and `vision_model_fallbacks`; image and vision endpoint lifecycle changes should update chat, document processing, Cookbook, and settings UI together.
Provider tool calls are untrusted requests, not authorization. `supports_tools` controls schema emission only; `llm_core` normalizes provider tool-call payloads, while execution authority remains in `src.tool_execution`, `src.tool_security`, and agent-tool policy.
## Degraded And Platform Behavior
- Provider offline or probe failures should surface actionable errors without crashing the app. Async calls retry transient 429/502/503/504 responses before failing.
- Docker deployments may need loopback URL rewriting from `127.0.0.1` to host-accessible addresses.
- Foreground fallback selection must preserve endpoint identity, explicit owner consent, allowed-model policy, and owner scope. User/API-token LLM dispatch that can carry configured endpoint keys must pass the effective owner into resolver calls.
- Async and streaming calls use dead-host cooldown; sync utility/vision calls do not have identical cooldown coverage.
- llama.cpp slot-affinity routing is local-endpoint behavior only and must not be applied to cloud/provider endpoints.
- Hidden, pinned, cached, endpoint-kind, refresh-policy, and offline model state are UI/runtime compatibility data. Pinned models may not participate in every resolver auto-pick path unless code explicitly includes them.
- SSE/stream parsers tolerate null choice/usage/tool-call entries and null streaming tool-call arguments; provider events should degrade to empty text or shaped stream errors instead of crashing the chat loop.
- Provider adapters carry small model-specific quirks: Opus 4.7+ and official Kimi/Moonshot code payloads omit `temperature`, Kimi/Moonshot/Mistral reasoning content is preserved separately, ChatGPT Subscription refreshes bearer credentials, native Ollama can handle multimodal content, and Ollama `/v1` responses for Qwen3/Gemma4-style thinking can suppress thinking text when requested.
## Security Policy
- Endpoint API keys are encrypted in `ModelEndpoint.api_key` and never returned by endpoint APIs; admin surfaces return key presence only.
- Endpoint CRUD, probes, provider discovery, and most endpoint configuration are admin-cookie or internal-tool gated.
- `/api/models` is auth/owner scoped for configured deployments; API-token access requires `chat` scope and token-owner attribution.
- Admin-created model endpoints may target local/LAN servers. Non-admin chat session creation must use registered endpoint IDs. API-token `/api/v1/chat` requires `chat` scope and validates direct `base_url` with public-only URL checks.
## Current Call Sites Include
- chat streaming and non-streaming calls;
- agent loop calls with optional tool schemas;
- compare pane calls;
- research synthesis/probe calls;
- utility model fallbacks for summarization/extraction;
- frontend Settings and model picker endpoint management.
## Current Gaps
- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; the canonical reader package has no production consumer yet.
- Provider-specific behavior is concentrated in `llm_core.py`, which is large and easy to regress.
- Several runtime request builders still use model-name heuristics. They should migrate only after endpoint/provider code supplies structured identity and a real consumer contract; the canonical catalog does not add a parallel quirk matcher.
- Endpoint identity and fallback behavior need careful review when new OAuth/subscription providers are added.
- Owner must continue to be threaded through new utility/research/default endpoint-resolution call sites so provider keys stay isolated.
- `/api/models` owner-scoped listing/cache behavior, shared/private endpoint dedupe, endpoint-kind refresh policy, fallback-chain owner scope, and image endpoint create/list/update lifecycle need stronger route-level regression coverage.
+118
View File
@@ -0,0 +1,118 @@
# Memory And Skills
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
This spec covers persistent memory and user skills in:
- app wiring in `app.py` and `src/app_initializer.py`;
- active legacy memory managers `src/memory.py` and `src/memory_vector.py`;
- canonical memory routes in `routes/memory/memory_routes.py`, with `routes/memory_routes.py` as a compatibility shim;
- chat memory/skill gating in `routes/chat_helpers.py`;
- memory compatibility modules in `services/memory/memory.py`, `services/memory/memory_vector.py`, and `services/memory/service.py`;
- provider abstractions in `src/memory_provider.py`;
- LLM extraction/audit in `services/memory/memory_extractor.py`;
- skill storage, format, import, and extraction in `services/memory/skills.py`, `services/memory/skill_format.py`, `services/memory/skill_importer.py`, and `services/memory/skill_extractor.py`;
- skill routes in `routes/skills_routes.py`;
- prompt/tool call sites in `src/chat_processor.py`, `src/agent_loop.py`, `src/ai_interaction.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_security.py`;
- MCP and Codex surfaces in `mcp_servers/memory_server.py` and `routes/codex_routes.py`;
- backup/admin/CLI surfaces in `routes/backup_routes.py`, canonical `routes/admin_wipe/admin_wipe_routes.py` plus its shim, `scripts/odysseus-memory`, `scripts/odysseus-skills`, and `scripts/odysseus-backup`;
- frontend modules `static/js/memory.js` and `static/js/skills.js`;
- tests under `tests/test_memory_*`, `tests/test_builtin_memory_consolidation.py`, `tests/test_skill_*`, and `tests/test_skills_*`.
## Memory Runtime
`src.app_initializer.initialize_managers()` creates the active `src.memory.MemoryManager` and `src.memory_vector.MemoryVectorStore` used by app startup. `routes.memory.memory_routes` imports through `services.memory` but is passed the startup manager instances; top-level `routes.memory_routes` is a `sys.modules` compatibility shim.
`MemoryManager` owns JSON-backed memory storage in `data/memory.json`, validation, owner fields, pinned state, use counts, and text/keyword similarity. Read-only `load_all()` remains lenient and can degrade an unreadable store to no memories. Mutating read-modify-write paths use `load_all_for_update()`, which raises `MemoryStoreUnreadable` rather than letting a corrupt or unreadable file be overwritten with an empty list. Agent/MCP/native-provider adds, extraction, backup import, and owner migration preserve that distinction; legacy `memory.txt` migration remains allowed. `MemoryVectorStore` owns semantic lookup when Chroma and embeddings are reachable.
Chat memory behavior:
- chat preferences and incognito state gate memory preface use;
- pinned memories are loaded for the owner;
- retrieved memories use keyword matching plus optional vector scoring;
- inserted memory is wrapped as untrusted context;
- memory use counts are incremented after insertion.
`services/memory/memory_extractor.py` owns LLM-assisted extraction, audit, and validation flows. It requests model behavior and writes through the memory manager; it does not own chat session persistence.
Extraction handles reasoning-model response shapes and records explicit dislike/drop preferences as `dislikes` rather than losing them to generic fact handling.
## Skills Runtime
`services/memory/skills.py` owns disk-backed skill storage under `data/skills/<category>/<name>/SKILL.md`, plus `_usage.json` usage/audit sidecars. Legacy `data/skills.json` is a read-only fallback/import source, not the current write shape.
`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns CRUD/search/index/import, owner filtering, skill test/audit jobs, and admin-gated built-in tool instruction overrides.
Skill extraction is owned by `services/memory/skill_extractor.py`. It can suggest or save skills from conversations, tries valid brace-delimited JSON candidates with `JSONDecoder.raw_decode()`, rejects ambiguous multiple top-level JSON objects instead of guessing, and saved skills remain user-editable data.
Agent skill behavior:
- matched skills are owner-scoped, confidence-gated, usage-counted, and wrapped as untrusted context;
- `index_for()` exposes published skills plus teacher-escalation drafts gated by platform and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate;
- user prefs such as skills enabled, auto-approve, and max injected skills shape runtime insertion;
- the level-0 base skill index currently calls `index_for(owner=None)`, so it is not fully owner-scoped.
- skill tests use the configured utility model rather than the chat default and wrap user-editable skill text as untrusted context; approval continuation for a test or teacher-generated skill uses the same exact-action gate as the normal agent loop.
## Tools, MCP, And Backup
Native `manage_memory` and `manage_skills` tool paths pass owner context and use in-process policy gates. `manage_skills` requires an explicit action instead of silently defaulting a malformed call. Manual memory add can choose a category, and route-side manual add validates the source session owner before attaching session-derived memories. `mcp_servers/memory_server.py` lazy-initializes `src` managers and exposes list/add/edit/delete/search. It can scope to `ODYSSEUS_MCP_MEMORY_OWNER` or `ODYSSEUS_MEMORY_OWNER`; if the JSON store contains owner-bearing entries and no owner env is configured, it returns an owner-scope error instead of listing or mutating across owners. Ownerless stores remain ownerless compatibility mode.
The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows
when checking whether its newly added entry is already present; it ignores
those rows instead of calling mapping methods on them and crashing the add.
`/api/export` owner-filters memories and skills. `/api/import` imports skills through current disk-backed `SkillsManager` APIs, stamping missing owners to the importer and preserving supported skill metadata. Full data snapshots through `scripts/odysseus-backup` preserve on-disk skill trees, memory JSON, and caches differently from JSON import/export.
## Compatibility State
Memory and skills are partially migrated:
- app startup, MCP, and some tools still use `src.memory*`;
- services memory modules remain relevant for imports/tests, with memory and vector modules re-exporting canonical `src` implementations;
- `services/memory/service.py` is a compatibility facade around the canonical managers, but it remains ownerless and should not be assumed equivalent to route/tool owner policy;
- skills are service-owned and disk-backed, while backup import and some compatibility paths still expect older JSON/list shapes.
## Degraded Vector Memory
Chroma is an external HTTP service. Native defaults use `localhost:8100`; Docker uses `chromadb:8000`. Embeddings prefer configured HTTP endpoints and can fall back to local FastEmbed.
Startup can degrade to keyword-only memory when vector initialization fails. Extraction/audit paths catch vector failures and continue with text/JSON behavior. Vector dedup is checked against the current owner before suppressing a candidate, and audit rebuilds preserve other owners' vector rows. Chat retrieval assumes a healthy startup vector store remains usable, so post-start vector failures can still break memory retrieval unless handled by the caller.
Admin wipe currently has a vector cleanup compatibility gap because it imports a nonexistent helper before attempting vector clearing.
## Policy
Saved memories and skills are untrusted source data when shown to the model. A stored skill may contain useful instructions, but it is still user-editable content and must be framed consistently with prompt-injection policy.
Owner isolation is surface-specific:
- HTTP memory and skills routes are expected to owner-filter normal user data;
- native memory/skill tools are expected to pass owner context;
- Codex exposes scoped token memory behavior separately;
- normal memory/skills routes are cookie/current-user surfaces, not scoped token APIs;
- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index currently has ownerless/global behavior;
- vector dedup during memory extraction suppresses only same-owner or legacy-ownerless vector matches.
Skill test/audit flows intentionally run user-editable `SKILL.md` content as instructions inside controlled jobs. Those jobs rely on route owner checks, admin gates where applicable, and tool execution policy.
Skill import is admin-gated defense-in-depth, but imported URLs are still untrusted network input. Initial and redirected targets must remain public, automatic redirects stay disabled, and the connection must use only the IP set validated for that hop so DNS rebinding cannot change the destination between validation and transport.
User rename flows update skill frontmatter owner fields and `_usage.json` owner keys alongside memory/upload/research ownership migrations.
## Testing Coverage
Existing tests cover memory extraction/degraded vectors, owner isolation, unreadable-store mutation refusal, MCP memory shape/scope, skill owner update/delete, prompt-injection wrapping and approval continuation, utility-model selection, toolset gating, frontmatter escape round trips, skill-import redirect and DNS-rebinding/SSRF defenses, CLI non-object rows, and selected route owner checks.
Route-level memory CRUD/security, skills route security, MCP memory behavior, vector degraded writes, compatibility facade owner behavior, backup skill import, admin vector cleanup, and frontend endpoint wiring need broader coverage.
## Current Gaps
- `services/memory/service.py` needs an explicit owner-scope/support decision before it is treated as a public memory API.
- The agent level-0 skill index should thread owner or be documented as an intentional local/global index.
- MCP memory still needs a deliberate multi-user UX/config decision, but current behavior avoids cross-owner access when owner-bearing rows exist without an explicit MCP owner env.
- Memory JSON import does not rebuild vector indexes.
- Admin wipe vector clearing is currently ineffective.
- Chat memory retrieval needs a graceful path for vector failures after startup.
- Route-level memory and skills security coverage is incomplete.
+178
View File
@@ -0,0 +1,178 @@
# Canonical Provider And Model Capability Layer
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers the implementation introduced on current `dev` in:
- canonical model values and query helpers in `src/model_capabilities.py`;
- record, identity, and provider-detection helpers in
`src/model_capability_readers/base.py`;
- reader dispatch in `src/model_capability_readers/__init__.py`;
- concrete readers for generic OpenAI-compatible, OpenAI, OpenRouter, Google,
Ollama, LM Studio, and llama.cpp payloads;
- regression coverage in `tests/test_model_capabilities.py` and
`tests/test_model_capability_readers.py`.
The layer normalizes already-fetched JSON-compatible values. It performs no
network I/O, does not shape provider requests, does not persist its output, and
does not authorize model or tool use. No production caller currently consumes
the canonical records outside this package; runtime integration remains later
work.
There is no `src/provider_capability_schemas.py`, capability-specific
diagnostics module, or runtime model-quirk registry on current `dev`.
## Layer Boundaries
- `src.model_capabilities` defines normalized families, tasks, modalities,
capabilities, evidence sources/confidence, assertion states, deterministic
controls, probe results, reasoning-control tokens, and display-surface
queries.
- `ModelCapability` owns family, primary task, input/output modalities,
capability tokens, limits, source, and confidence.
- `CapabilityAssertion` records claimed, verified, unsupported, or unknown
status for one capability. Missing evidence is not an unsupported claim.
- `DeterministicControl` records support evidence for controls such as
temperature, top-p, seed, tool choice, or prompt caching. A supported
request control is not itself a model capability.
- `CapabilityProbeResult` is an in-memory evidence shape that converts pass,
fail, or partial probe state into an assertion. No current runtime probe
stores or merges these objects.
- `CapabilityQuery` and `display_surfaces_for()` map a normalized capability
into candidate surfaces such as chat, vision chat, image generation,
embeddings, or reranking. They are not wired into current pickers.
- Reader `ModelCapabilityRecord` binds a vendor/model identity to the nested
capability object, assertions, deterministic controls, and optional raw
provider evidence.
Provider transport support and per-model support are separate facts. Request
and response adapters remain in `src.llm_core` and related provider modules.
Model-specific observations remain in [model-quirks.md](model-quirks.md).
## Current Serialized Shapes
`ModelCapability.to_dict()` emits the nested capability shape:
```json
{
"family": "chat",
"primary_task": "chat.completions",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"capabilities": ["tool_call", "vision"],
"limits": {"context_tokens": 131072},
"source": "provider_reader",
"confidence": "provider_reported"
}
```
`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`,
`stable_model_id`, `display_name`, `capability_assertions`, and
`deterministic_controls`. It does not currently emit a schema version or the
flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are
included only when the caller passes `include_raw=True`.
Endpoint configuration can explicitly map `model_type=llm` to chat and
`model_type=image` to image generation. Missing or unrecognized endpoint types
stay unknown rather than silently becoming chat-capable in this schema layer.
## Identity And Reader Dispatch
`records_from_payload()` selects a reader from an explicit `vendor`, or from
`detect_vendor(base_url, endpoint_kind)` when no vendor is supplied.
Current detection order and behavior are:
1. a recognized explicit endpoint kind;
2. label-bounded hostname checks for OpenRouter, OpenAI, Anthropic, Google APIs, and Ollama Cloud;
3. common local ports: `11434` for Ollama, `1234` for LM Studio, `8000` for vLLM, and `30000` for SGLang;
4. generic OpenAI-compatible for any other parsed host, otherwise unknown.
These are normalization hints, not authorization. Hostname checks accept an exact domain or its dot-delimited subdomains after lowercasing and removing a trailing dot, so names such as `notopenai.com` do not match `openai.com`; local-port mappings remain intentionally covered by tests. Callers must not treat any result as proof of endpoint trust.
Implemented reader modules are `generic_openai`, `openai`, `openrouter`,
`google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face,
SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the
generic identity-only reader. Other explicitly supplied vendor strings are
also preserved while using that generic reader.
Stable model identity is scoped in this order:
- explicit endpoint ID;
- a short hash of normalized base URL when an endpoint ID is absent;
- `global` when neither endpoint identity is supplied.
## Generic Identity-Only Contract
The generic reader accepts mapping payloads containing `data[]` or `models[]`.
Each item must itself be a mapping and provide `id`, `name`, or `model`.
Bare-list payloads and `key`/`slug`-only items are not accepted by the current
implementation.
The reader deliberately returns unknown family, modalities, capabilities, and
controls. It preserves the raw item on the in-memory record but does not parse
type/task fields, descriptions, ownership, supported-parameter lists,
capability-looking booleans, or token limits.
## Provider-Native Readers
- OpenAI keeps the official Models API identity-only.
- OpenRouter maps explicit architecture modalities, supported parameters,
limits, voices, and default parameters into family/capability/control state.
- Google maps the native Models resource. Embedding-only methods map to the
embedding family; content-generation methods do not prove modality or chat
family. Explicit thinking, limits, sampling fields, caching, and batch
methods are retained without parsing product names.
- Ollama treats `/api/tags` as identity-only and maps selected-model
`/api/show` capability tokens. Context can come from structured fields or a
parsed `num_ctx` line in the serialized `parameters` value.
- LM Studio maps native v1 `models[]` and v0-style `data[]` fields. A plain
OpenAI-compatible list without native type/capability fields stays unknown.
- llama.cpp can merge `/v1/models`, `/props`, and `/slots` evidence for one
served model. It records tool/streaming claims, explicit unsupported
vision/audio assertions, controls, and runtime/training/size limits.
Readers tolerate non-object entries and unknown fields where their helpers
permit it. They do not infer authoritative capability from model IDs or display
names.
## Evidence Semantics
The canonical vocabulary includes admin override, endpoint configuration,
provider reader, Cookbook/Hugging Face, maintained registries, heuristic,
probe, and unknown sources. It also defines explicit, provider-reported,
registry, heuristic, and unknown confidence values.
Those tokens make evidence representable; current `dev` does not implement a
global precedence, merge, expiry, or conflict-resolution engine. Assertions
generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass
to verified, fail to unsupported, and partial to claimed at the scope carried
by that object.
## Tests
Focused tests pin:
- endpoint-kind, host, and common-port vendor detection;
- endpoint/base-URL-scoped stable IDs;
- unknown behavior for generic and official OpenAI lists;
- canonical normalization and display-surface matching;
- assertion, deterministic-control, and probe-result shapes;
- OpenRouter, Google, Ollama, LM Studio, and llama.cpp mappings;
- negative cases that avoid name-based media/capability inference.
## Current Gaps
- Canonical records are not yet used by runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers.
- Reader output is not persisted, refreshed, merged, or expired.
- Provider detection still uses common-port hints; consumers must not promote normalization hints into trust decisions.
- Only seven concrete readers exist; placeholder and other providers use the
identity-only generic reader.
- Generic fallback does not accept bare-list or `key`/`slug`-only payloads.
- There is no capability-specific diagnostic/logging path.
- Runtime request builders still contain model-name heuristics outside this
canonical layer.
+100
View File
@@ -0,0 +1,100 @@
# Provider Capability Specs
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This directory maps serving-provider observations and current model-catalog
normalization into the canonical layer defined by
[model-capability-canonical.md](../model-capability-canonical.md). It records
current Odysseus implementation evidence, merged fixes, reproducible user
observations, and provider documentation without treating any single source as
global model truth.
## General To Specific Resolution
Read specs in this order:
1. [openai-compatible.md](openai-compatible.md) for the conservative general
identity-only reader;
2. the serving-provider file for native endpoints, headers, request/response
observations, and catalog fields;
3. [model-quirks.md](../model-quirks.md) for model-specific observations.
Provider files document transport; runtime adapters still own it. Model quirks
record only deviations and are not a second runtime matcher. Shared model facts
must not be copied into every provider file. An OpenAI-compatible provider is
not OpenAI: an explicitly supplied vendor string is preserved even when it uses
the generic reader.
Current reader dispatch does not infer a provider from payload shape. It uses an explicit vendor, then endpoint kind, label-bounded hostname matches, and common local-port hints. The port hints map 11434 to Ollama, 1234 to LM Studio, 8000 to vLLM, and 30000 to SGLang. Those hints are normalization behavior, not endpoint trust.
## Provider Map
### Implemented canonical readers
- [openai.md](openai.md): identity-only Models API plus Chat/Responses dialects.
- [openai-compatible.md](openai-compatible.md): generic compatible catalog and runtime dialect boundaries.
- [openrouter.md](openrouter.md): rich architecture, modalities, parameters, and limits.
- [google.md](google.md): native paginated Gemini Models API and GenerateContent.
- [ollama.md](ollama.md): `/api/tags`, `/api/show`, native chat, and OpenAI compatibility.
- [lm-studio.md](lm-studio.md): native v1 catalog/chat, explicit v0 compatibility, and OpenAI compatibility.
- [llama-cpp.md](llama-cpp.md): `/props`, `/slots`, OpenAI/Responses/Anthropic surfaces.
### Placeholder identities using the generic reader
- [anthropic.md](anthropic.md): identity-only Models API and native Messages runtime adapter.
- [vllm.md](vllm.md): common-port identity hint; deployment capability remains unknown.
- [sglang.md](sglang.md): common-port identity hint; parser/config-dependent capability remains unknown.
- [hugging-face.md](hugging-face.md): Hub observations and download/fit metadata without a canonical reader.
### Provider observations without a dedicated canonical reader
- [mistral.md](mistral.md): rich model cards, reasoning controls, and structured runtime content.
- [github-copilot.md](github-copilot.md): account model-list observations and required runtime headers.
- [chatgpt-subscription.md](chatgpt-subscription.md): Codex model identity and Responses event shape.
- [cohere.md](cohere.md): native endpoint/catalog observations; not currently normalized.
### Other provider identity and general/identity-only observations
- [moonshot-kimi.md](moonshot-kimi.md)
- [deepseek.md](deepseek.md)
- [groq.md](groq.md)
- [nvidia-nim.md](nvidia-nim.md)
- [cerebras.md](cerebras.md)
- [together.md](together.md)
- [fireworks.md](fireworks.md)
- [xai.md](xai.md)
- [zai.md](zai.md)
- [opencode.md](opencode.md)
- [perplexity.md](perplexity.md)
- [github-models.md](github-models.md)
- [venice.md](venice.md)
- [azure-openai.md](azure-openai.md)
- [bedrock.md](bedrock.md)
- [cloudflare-workers-ai.md](cloudflare-workers-ai.md)
- [atlas-cloud.md](atlas-cloud.md)
- [siliconflow.md](siliconflow.md)
- [minimax.md](minimax.md)
### Other local/proxy serving identities
- [local-compatible-engines.md](local-compatible-engines.md): MLX LM, TGI,
LMDeploy, LiteLLM, and unknown compatible deployments.
## Provider Spec Template
Each provider file records:
- provider identity and API dialects;
- latest observed native catalog endpoint/envelope and capability-bearing fields;
- whether current source has a dedicated reader or only generic fallback;
- observed request, tool, text, reasoning, and control paths owned by runtime
adapters rather than the catalog reader;
- what remains per-model/unknown;
- Odysseus evidence and regressions;
- fallback/safety behavior and current gaps.
Marketing capability lists and curated picker lists may guide research but do
not automatically become model claims. Provider-returned false values can be
negative evidence only at the same provider/endpoint/model scope.
+39
View File
@@ -0,0 +1,39 @@
# Anthropic Provider Shape
Last updated: dev@e71f8ce | 2026-08-25
## Scope
Canonical placeholder vendor ID `anthropic`; Anthropic Messages runtime
adapter in `src/llm_core.py`. There is no dedicated Anthropic capability-reader
module; explicit/auto-detected Anthropic payloads use the generic identity-only
reader.
## Catalog Shape
`GET /v1/models` returns `data[]` model resources with `id`, `type: model`,
`display_name`, and `created_at`, plus pagination metadata. These fields prove
identity/availability only. Do not assume all listed Claude models share
vision, tools, reasoning, sampling, or context limits.
## Request And Response Shape
Native Messages uses a top-level `system`, alternating `messages`, content
blocks, `tools[].input_schema`, `tool_use` assistant blocks, and `tool_result`
user blocks. Text, thinking, signatures, server-tool blocks, and tool calls are
typed content rather than OpenAI roles/fields. Preserve block IDs/signatures
needed for continuation.
Sampling and thinking support can be version/model specific. The Opus 4.7+ sampling omission is a model-scoped runtime observation, not an Anthropic-wide rule. Runtime version parsing accepts explicit major/minor IDs and later major-only IDs such as `claude-opus-5`, treats a missing minor as `.0`, caps both components so date stamps cannot be misread as versions, and keeps legacy Claude 3 Opus sampling intact. Anthropic-compatible proxies are Anthropic dialect only when configured or their exact payload/endpoint shape proves it (#3110).
## Fallback And Safety
Runtime and canonical reader detection use label-bounded Anthropic host matching or an explicit endpoint kind. A provider using Anthropic Messages through another host must be explicit. Identity-only model cards remain unknown.
## Current Gaps
- The public model list does not provide per-model canonical capability data.
- There is no dedicated Anthropic canonical reader; only `id`, `name`, or
`model` identity survives generic normalization.
- Runtime model-version parsing needs structured identity before a later
consumer can centralize sampling exceptions without another name matcher.
+21
View File
@@ -0,0 +1,21 @@
# Atlas Cloud Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `atlas_cloud`; OpenAI-compatible provider proposed in
#5566 with live `/v1/models` observations for current Qwen/DeepSeek offerings.
## Shape
Treat the observed list as identity-only. Even capability-looking item fields
remain raw until an Atlas-specific discriminating shape intentionally maps
them. The model IDs observed by a PR demonstrate availability at that time,
not permanent capability or a reason to hardcode family-name behavior.
## Fallback And Current Gaps
Exact Atlas Cloud host or explicit kind preserves identity; otherwise use the
inventory fallback. The provider work is open/unmerged and has no independently
versioned rich catalog schema, so evidence remains provisional.
+26
View File
@@ -0,0 +1,26 @@
# Azure OpenAI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `azure_openai`; Azure deployment-scoped OpenAI dialects;
custom endpoints use explicit configuration.
## Shape
Azure commonly identifies deployments rather than globally stable model IDs.
Preserve endpoint, deployment ID, API version, and underlying model/version as
separate structured identity when returned. A standard OpenAI-compatible model
list is identity-only until an Azure-specific reader intentionally maps its
deployment fields.
Request paths and authentication can be deployment/API-version specific; do
not blindly append public OpenAI paths or copy provider quirks. Capability and
limits are deployment scoped.
## Fallback And Current Gaps
Known `*.openai.azure.com` hosts select Azure OpenAI; other Azure gateways need
explicit kind. Odysseus lacks a native Azure deployment catalog reader and
structured API-version persistence in the canonical record.
+23
View File
@@ -0,0 +1,23 @@
# AWS Bedrock Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `bedrock`; UI/provider mapping currently recognizes AWS
Bedrock, but the canonical layer has no native Bedrock runtime reader.
## Shape
Bedrock is not generally an OpenAI-compatible host: model IDs, inference
profiles, request/response unions, signing, and per-family payloads differ.
Only an explicitly configured OpenAI/Anthropic-compatible gateway may use those
dialects. Native Bedrock capability must come from a versioned Bedrock model
catalog plus exact foundation-model/inference-profile identity.
## Fallback And Current Gaps
Do not classify all `amazonaws.com` hosts as Bedrock; use explicit kind or a
future region-aware exact host/path shape. General fallback is safe only behind
an explicitly compatible gateway. Native signing, catalogs, and family payload
mappings remain unimplemented.
+23
View File
@@ -0,0 +1,23 @@
# Cerebras Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `cerebras`; OpenAI-compatible cloud transport; runtime
provider detection and cache-affinity safeguards in `src/llm_core.py`.
## Shape And Observations
Model lists use the general identity-only inventory reader. Cerebras rejects
llama.cpp-only `session_id` and `cache_prompt` fields (#4640), so cloud identity
must suppress local slot-affinity extensions. Current regressions pin this
provider boundary.
Tool, reasoning, structured output, and limits remain per model. Do not promote
them from the fact that the API accepts OpenAI Chat.
## Fallback And Current Gaps
Exact `*.cerebras.ai` selects provider identity. Compatible proxies require
explicit configuration. No rich per-model Cerebras catalog reader is present.
@@ -0,0 +1,47 @@
# ChatGPT Subscription Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `chatgpt_subscription`; Codex Responses transport;
auth and runtime code in `src/chatgpt_subscription.py`,
`routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`.
There is no dedicated ChatGPT Subscription canonical reader on current `dev`.
## Catalog Shape
The account-scoped Codex models endpoint returns root `models[]`; `slug` is the
request identity and `visibility`/`priority` control availability/order. These
fields do not prove tools, reasoning, vision, or context. Null/malformed model
lists fail soft rather than crashing discovery (#5280/#5281).
The canonical generic reader does not accept `slug`-only items, so this runtime
catalog is not currently normalized into `ModelCapabilityRecord` values.
## Request And Response Shape
Transport uses a ChatGPT backend Responses endpoint, `input` items, flattened
function tools, streamed function-call argument events, exact `call_id`, and
`function_call_output` continuation. Parallel calls and encrypted reasoning
continuity require preserving typed output/history rather than coercing all
roles to text. This shape is supported by the existing adapter and the focused
tool-calling follow-up evidence in #5490; unmerged observations remain claimed
until integrated/reproduced.
OAuth/device credentials and refresh are provider-session behavior. Expired
credentials should return an actionable reconnect error, not generic model
failure.
## Fallback And Safety
Only the explicit internal base/ChatGPT host selects this provider. Never send
subscription credentials to a custom OpenAI-compatible URL. Catalog slugs stay
identity-only unless account-scoped fields or probes supply capability.
## Current Gaps
- Comprehensive Responses tool/reasoning parity is still evolving.
- Account model slugs are not consumed by the canonical reader package.
- The account catalog does not currently provide a complete canonical
capability card for every slug.
@@ -0,0 +1,21 @@
# Cloudflare Workers AI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `cloudflare_workers_ai`; OpenAI-compatible Workers AI
endpoint observations in #5175; explicit provider configuration required.
## Shape
Cloudflare account/path identity is part of the endpoint. Use the general
OpenAI-compatible inventory reader for returned model cards, preserving full
model IDs but no capability fields.
Do not identify the provider from broad `api.cloudflare.com` alone or infer
capability from Workers AI catalog prose.
## Fallback And Current Gaps
Provider identity must be explicit until a narrow account/AI path matcher is
implemented. There is no rich normalized capability catalog reader.
+56
View File
@@ -0,0 +1,56 @@
# Cohere Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Documented provider identity `cohere`; native Chat v2 plus the OpenAI
Compatibility API. Current `dev` has no dedicated Cohere capability reader or
direct Cohere request adapter; compatible endpoints use the general runtime
path when explicitly configured.
## Catalog Shape
`GET /v1/models` returns a paginated `models[]` envelope. Each model can carry
`name`, `endpoints`, `default_endpoints`, `context_length`, `features`, and
`sampling_defaults`; the root can carry `next_page_token`.
These are candidate fields for a future dedicated reader:
- a single canonical family from `endpoints`: `chat`/`generate`, `embed`,
`rerank`, or `classify`;
- `context_length` to the endpoint/model context limit;
- known sampling-default keys to deterministic controls.
Current canonical normalization does not map them. When the generic reader is
explicitly selected with vendor `cohere`, it preserves only item identity plus
the raw item; family, context, features, and sampling controls stay unknown.
## Request And Response Shape
Native `POST /v2/chat` uses `messages`, structured content blocks, tools,
`response_format`, sampling fields, and an optional structured `thinking`
object. Text lives in `message.content[type=text].text`; reasoning-capable
models use `message.content[type=thinking].thinking`. Streaming uses typed
events rather than one generic text delta.
The OpenAI compatibility base is `/compatibility/v1`. Its current chat subset
includes tools, structured output, sampling, and `reasoning_effort`, but model
support remains per-model. In the compatibility dialect only `none` and `high`
currently map to native thinking off/on; do not assume low/medium support.
## Fallback And Safety
No Cohere host or payload-shape detection exists in the canonical reader
registry. The caller must supply provider/endpoint configuration. Marketing
pages and provider-wide endpoint features do not grant every listed model
tools, vision, or reasoning.
## Evidence And Gaps
- Official List/Get Models resources define the catalog fields.
- Official Chat v2, Reasoning, and Compatibility API resources define the
transport and thinking controls.
- Odysseus has no direct Cohere request adapter, canonical reader, or sanitized
canonical fixtures yet; both normalization and runtime integration remain
follow-up work.
+30
View File
@@ -0,0 +1,30 @@
# DeepSeek Provider Shape
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
Canonical provider ID `deepseek`; official cloud OpenAI-compatible API;
curation/detection in `routes/model_routes.py` and runtime reasoning handling in
`src/llm_core.py`.
## Shape And Observations
Use the general model-list inventory shape; capability-looking fields remain
unknown until a DeepSeek-native reader maps them. Cloud response history can use
`reasoning_content`; preserve it structurally for reasoning turns and tool
continuation (#968, #3152). `deepseek-chat`, reasoning models, distilled local
variants, and future V4 models do not share one capability record.
Cloud endpoint evidence can support tools while a local DeepSeek-R1 deployment
may not have a working tool parser. Existing tool-support tests intentionally
separate official host from local engine/model-name heuristics.
Current runtime thinking-pattern detection includes DeepSeek V4 identifiers so their structured reasoning channel is handled like the other supported DeepSeek reasoning families. This name-level compatibility rule is not canonical capability evidence and does not make every V4-labelled local deployment tool-capable.
## Fallback And Current Gaps
Exact `*.deepseek.com` selects provider identity; self-hosted checkpoints use
Ollama/vLLM/SGLang/llama.cpp identity. Curated model IDs and pricing/context
tables are compatibility data, not authoritative capability. A rich official
model-card reader is still absent.
+22
View File
@@ -0,0 +1,22 @@
# Fireworks AI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `fireworks`; OpenAI-compatible cloud transport with path
prefixes such as `/inference/v1`; curation and URL handling in
`routes/model_routes.py` and `src/endpoint_resolver.py`.
## Shape
Use the general identity-only inventory reader. Fireworks IDs can contain
account/model paths; preserve the full request ID and endpoint scope. Item
modalities, supported parameters, task/type, and limits require a
Fireworks-native mapped shape before promotion.
## Fallback And Current Gaps
Exact `*.fireworks.ai` preserves provider identity and its configured path
prefix. Do not normalize account-qualified IDs by taking the last path segment.
No verified rich Fireworks capability catalog is currently mapped.
+46
View File
@@ -0,0 +1,46 @@
# GitHub Copilot Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `copilot`; OpenAI-compatible chat with Copilot headers
and OAuth; runtime adapter `src/copilot.py` and routes in
`routes/copilot_routes.py`. There is no dedicated Copilot canonical reader on
current `dev`.
## Catalog Shape
The observed Copilot `/models` response uses `data[]` entries with:
- `id`;
- `model_picker_enabled`;
- `capabilities.supports.tool_calls` and `.vision`;
- optional limit/family metadata.
Runtime model discovery uses picker state for availability. The canonical
reader package does not map the nested support fields; an explicitly supplied
`copilot` vendor currently uses generic identity-only normalization, and
`model_picker_enabled` does not become canonical capability.
## Request And Response Shape
Chat is OpenAI-compatible but requires Copilot/GitHub API version, editor/plugin
identity, intent, integration, and initiator headers; image requests add the
vision request flag. Header derivation must tolerate malformed message entries.
OAuth token exchange and access policies are provider authentication, not model
capability.
## Fallback And Safety
Use exact GitHub Copilot host or explicit kind, including the constrained
enterprise `copilot-api.*.ghe.com` form. Do not treat arbitrary `ghe.com` hosts
as Copilot. Official model availability tables are useful registry context but
do not replace the account-scoped catalog response.
## Current Gaps
- The catalog shape is implementation-observed and needs ongoing fixture
comparison with current Copilot clients.
- Copilot catalog capability fields are not normalized by current `dev`.
- Account/plan/policy availability must remain endpoint-user scoped.
+21
View File
@@ -0,0 +1,21 @@
# GitHub Models Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `github_models`; OpenAI-compatible GitHub Models/Azure
inference endpoint observed in #2995; distinct from GitHub Copilot.
## Shape
Use general identity-only inventory. Deployment IDs and account access
can differ from upstream model IDs. Do not copy Copilot picker metadata,
headers, plan rules, or capabilities into GitHub Models; they are separate
providers despite shared GitHub branding.
## Fallback And Current Gaps
The known `models.inference.ai.azure.com` host selects GitHub Models. Other
Azure deployment hosts require explicit provider configuration. No rich
account-scoped capability catalog is currently mapped.
+54
View File
@@ -0,0 +1,54 @@
# Google Gemini Provider Shape
Last updated: dev@e71f8ce | 2026-08-25
## Scope
Canonical provider ID `google`; native GenerateContent plus optional Google
OpenAI-compatible chat; readers `google.py` and
`google_ai_studio_mapping.py`; catalog/probe ownership in
`routes/model_routes.py`.
## Catalog Shape
Use the native paginated `GET /v1beta/models` endpoint, including
`nextPageToken`, with `x-goog-api-key` when configured. `models[]` can contain:
- `name`, `baseModelId`, `version`, and `displayName`;
- `inputTokenLimit` and `outputTokenLimit`;
- `supportedGenerationMethods`;
- `thinking`, `temperature`, `maxTemperature`, `topP`, and `topK`.
Embedding-only methods map to embedding. Generation methods prove a native
method, not chat/image/video/audio modality, so those records remain unknown
unless stronger structured evidence exists. `thinking: true` and explicit
sampling fields map to a reasoning claim and controls. Model IDs such as
Imagen, Veo, or TTS names are not parsed.
## Request And Response Shape
Native generation uses `contents`, `systemInstruction`,
`generationConfig`, `tools[].functionDeclarations`, and
`models/{model}:generateContent|streamGenerateContent`. Responses use
`candidates[].content.parts[]` for `text`, `functionCall`, `functionResponse`,
`thought`, and `thoughtSignature`; token accounting is in `usageMetadata`.
Native Google tool/thought continuity must not be flattened through an
OpenAI-only history shape.
## Fallback And Safety
Prefer native model metadata even when chat is configured through Google's
OpenAI compatibility URL. Pagination parameters must remain stable between
pages. The route probe activates only for the exact
`generativelanguage.googleapis.com` hostname, filters the picker list to
content-generation methods, returns no curated fallback after probe failure,
and defaults those endpoints to manual catalog refresh unless explicitly
overridden. The canonical Google reader is not yet called by that probe.
Unknown methods and fields stay raw; unrecognized prediction models remain
unknown.
## Current Gaps
- The Models resource does not expose full modalities for every Google media
family.
- Native Gemini request/response support is not yet the only runtime path.
+24
View File
@@ -0,0 +1,24 @@
# Groq Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `groq`; OpenAI-compatible cloud transport; detection and
request behavior in `src/llm_core.py`.
## Shape
Model discovery falls back to the general `data[].id` identity shape. Richer
fields require a Groq-native mapped shape even when the payload happens to
supply modalities, supported parameters, or limits. Groq transport may accept OpenAI-style tools and streaming extensions,
but support remains per model and account.
Runtime currently exempts Groq/OpenRouter from some parameter stripping paths;
that is transport compatibility, not a provider-wide model capability claim.
## Fallback And Current Gaps
Exact `*.groq.com` preserves Groq identity. Do not infer Llama/Gemma model
capabilities from IDs. There is no canonical rich Groq model-card reader or
freshness policy yet.
+41
View File
@@ -0,0 +1,41 @@
# Hugging Face Provider And Registry Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical placeholder vendor ID `huggingface`; download/fit metadata in
`services/hwfit/`; OpenAI-compatible inference providers/TGI handled as their
serving dialect. There is no dedicated Hugging Face canonical reader on
current `dev`.
## Hub Model Shape
Hub model info can provide `modelId`/`id`, `pipeline_tag`, `tags`, `config`, and
card metadata. Current canonical normalization does not map `pipeline_tag`,
`config.model_type`, or Hub task/modality fields. An explicitly selected
Hugging Face vendor uses generic identity-only normalization.
This source is `cookbook_hf`/registry confidence, not live endpoint truth.
Free-form tags, README/card prose, repository names, and architecture names do
not automatically claim capability. A serving engine can load a model with
missing projection, different template, or disabled parser.
## Serving Shape
Hugging Face routed inference and TGI can expose OpenAI-compatible endpoints;
their model list may be identity-only. Keep Hub identity separate from the
serving endpoint and merge only when exact revision/model identity is known.
## Fallback And Safety
Hub metadata can fill a scoped registry record after provider payload fields
and probes, but must not overwrite fresh endpoint-negative evidence. Treat
remote code, model cards, and repository files as untrusted content.
## Current Gaps
- Revision/digest linkage between downloads, Hub records, and serving
endpoints is incomplete.
- Hub task/family metadata is not consumed by the canonical reader package.
- Pipeline tags can be missing or overly broad; unknown stays unknown.
+47
View File
@@ -0,0 +1,47 @@
# llama.cpp Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages
compatibility plus native server metadata; reader
`src/model_capability_readers/llamacpp.py`.
## Metadata Shapes
`/v1/models` provides served identity and can include server model entries;
native `/props` is authoritative for the running model/server combination:
- `model_alias`/`model_path`;
- `default_generation_settings.n_ctx` and sampling `params`;
- `total_slots` and optional `/slots[].n_ctx` fallback;
- `chat_template_caps` for tools/system role;
- `modalities.vision|audio`;
- current server/build state.
Capability depends on weights, projection/model assets, chat template, parser,
and launch flags. It is endpoint evidence, not a checkpoint-name claim.
`/props` and `/v1/models` can be merged only for the same served identity.
## Request And Response Shape
llama-server supports several OpenAI-compatible tasks and native extensions.
Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an
explicit server model capability field or endpoint configuration. Tool and
reasoning correctness can depend on selected chat template and parser.
## Fallback And Safety
The registry selects llama.cpp through an explicit vendor or endpoint kind; it
does not auto-detect `/props` from payload shape. Port 8000 currently maps to
the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible.
llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local
endpoint behavior and never leak to strict cloud providers (#4640 and current
affinity tests).
## Current Gaps
- Multi-model routing requires per-served-model `/props` association.
- Parser/template configuration is not yet fully represented in canonical
endpoint metadata.
+45
View File
@@ -0,0 +1,45 @@
# LM Studio Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `lmstudio`; native LM Studio v1 plus OpenAI Chat and
Responses compatibility; reader `src/model_capability_readers/lmstudio.py`.
## Catalog Shapes
Preferred shape is `GET /api/v1/models` with root `models[]`. Current fields
include `key`, `type` (`llm` or `embedding`), display/publisher data,
`architecture`, quantization/format/size, `max_context_length`,
`loaded_instances[].config.context_length`, and a capability object containing
`vision`, `trained_for_tool_use`, and reasoning options/defaults.
Compatibility shape `GET /api/v0/models` uses `data[]` with `id`, `type`
(`llm`, `vlm`, or embeddings), `arch`, `compatibility_type`, state, and
context metadata. It is an explicit older shape, not a loose fallback.
OpenAI `/v1/models` is identity-only when native endpoints are unavailable.
Loaded-instance context is the effective runtime context; maximum context is a
separate limit. Model type maps family, explicit capability booleans map
vision/tools/reasoning, and architecture is provider-reported model family.
## Request And Response Shape
Native v1 chat is `/api/v1/chat` and can expose stateful/MCP-oriented output;
LM Studio also supports OpenAI Chat and Responses compatibility. Keep dialect
selection explicit because tool/MCP features differ between native and
compatible paths.
## Fallback And Safety
Current reader detection identifies port 1234 as LM Studio. Prefer pathless
native `/api/v1/models` discovery where configured (#1122, #3615), then v0,
then general identity. The port mapping is a normalization hint, not endpoint
trust. An error object from an unsupported native route is not a model list.
## Current Gaps
- Runtime discovery does not yet persist native capability records.
- LM Studio API capabilities continue to evolve; each new native version needs
an explicit shape fixture before promotion.
@@ -0,0 +1,37 @@
# Other Local And Proxy Compatible Engines
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical explicit identities `mlx_lm`, `text_generation_inference`,
`lmdeploy`, and `litellm`, plus unknown OpenAI-compatible deployments not
covered by the native Ollama, LM Studio, llama.cpp, vLLM, or SGLang specs.
## Shape
Use explicit endpoint kind when known; otherwise use only the general model
list envelopes for inventory identity. Capability-looking structural fields
remain raw. Local host and port do not distinguish these engines.
MLX/Cookbook launch recipes, TGI task configuration, LMDeploy
adapters, and LiteLLM upstream routing can all change capability independently
of the model ID.
Proxy model aliases are endpoint scoped. A proxy may return richer fields, but
unknown keys remain raw until a versioned shape is added. Provider-specific
headers/extensions must not be applied based on a port or upstream model name.
## Fallback And Safety
Discovery can probe cheap native identity endpoints when available, but
capability probes execute only explicit bounded test contracts. Never read
broad server/environment dumps as ordinary model metadata. Unknown compatible
servers should still list identities and make conservative text calls where
explicitly configured, without appearing on capability-gated surfaces.
## Current Gaps
- These engines need individual safe metadata fixtures before they can graduate
from general fallback.
- Gateway upstream identity and effective downstream model capability are not
yet represented as a chain.
+48
View File
@@ -0,0 +1,48 @@
# MiniMax Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `minimax`; international host `api.minimax.io`, China
host `api.minimaxi.com`; current OpenAI-compatible and recommended
Anthropic-compatible text transports. Odysseus contains MiniMax-oriented tool
output handling and local-serving guidance but no dedicated catalog reader.
## Catalog Shape
Current `GET /v1/models` is an OpenAI-compatible identity list:
`object: list`, `data[]`, and model cards containing `id`, `object: model`,
`created`, and `owned_by: minimax`. The `owned_by` discriminator identifies the
provider shape, but the card exposes no per-model capability or modality
fields. Keep these records unknown and preserve raw identity metadata.
Do not backfill current model capabilities, token limits, or modalities from
the platform overview into this list response. Those tables are useful scoped
registry evidence only after model/version identity and freshness are carried
explicitly.
## Request And Response Shape
- OpenAI compatibility uses `/v1/chat/completions` and structured
`reasoning_content` alongside normal message content.
- Anthropic compatibility uses `/anthropic/v1/messages`; the current M2.7
family supports typed thinking blocks and interleaved thinking, making this
the preferred reasoning/tool-continuation transport in provider guidance.
- Native audio, image, video, music, and file endpoints are separate product
shapes. They must not be inferred from presence in the text model list.
## Local Deployments
The current provider guide documents vLLM, SGLang, and MLX deployment. Those
instances retain serving-engine identity and configuration-derived capability;
the checkpoint name alone does not turn a vLLM/SGLang card into the hosted
MiniMax provider shape.
## Fallback And Current Gaps
Exact MiniMax hosts or the discriminating `owned_by: minimax` model-list shape
select provider identity. Unknown compatible proxies retain the general shape.
The identity list does not safely distinguish M2 reasoning behavior from
speech/image/video/music products, so exact model quirks remain documentation
until structured model-version evidence reaches runtime request builders.
+46
View File
@@ -0,0 +1,46 @@
# Mistral Provider Shape
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
Canonical provider ID `mistral`; OpenAI-compatible chat with Mistral response
extensions and runtime handling in `src/llm_core.py`. There is no dedicated
Mistral canonical reader on current `dev`.
## Catalog Shape
`GET /v1/models` returns `data[]` cards with `id`, `root`, aliases,
`max_context_length`, and `capabilities` booleans including
`completion_chat`, `completion_fim`, `function_calling`, `vision`,
`classification`, and lifecycle/fine-tuning fields. These are candidate fields
for a future dedicated reader:
- chat/FIM or classification family;
- vision input;
- function calling;
- explicitly reported reasoning/structured output when present;
- context limit and root family.
Fine-tuning availability and archived status are not inference capabilities.
The current generic reader retains identity/raw data only and does not map any
of these fields. Different Mistral models retain independent identities.
## Request And Response Shape
Reasoning-capable models accept graded `reasoning_effort`. Mistral can return `content` as typed blocks: a `thinking` block containing text fragments plus a normal `text` block. Runtime normalizes those blocks for async utility calls as well as chat/stream paths, keeping reasoning and visible text separate instead of stringifying the list or scanning text tags (#4698, #5882).
## Fallback And Safety
Runtime `llm_core` detects label-bounded Mistral hosts for request/response
handling. The canonical registry has no Mistral host or rich-payload detector;
an explicitly supplied `mistral` vendor falls back to generic identity. A
Mistral model served through another engine uses that serving engine's dialect.
## Current Gaps
- Catalog reasoning fields vary across model-card generations; absent remains
unknown.
- Mistral catalog capability fields are not normalized by current `dev`.
- Runtime thinking-family selection still uses names and should migrate to
structured root/capability identity.
+30
View File
@@ -0,0 +1,30 @@
# Moonshot And Kimi Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Provider IDs `moonshot` for official Moonshot API and `kimi_code` for the Kimi
Code surface; OpenAI-compatible transport with provider-specific headers and
model-specific behavior in `src/llm_core.py`.
## Shape And Observations
Model lists use the general OpenAI-compatible identity shape unless a richer
account response is returned. Official Kimi K2.5/K2.6 fixes temperature by
thinking mode, so Odysseus omits `temperature` rather than sending an invalid
value (#3960). Thinking tool-call continuation requires preservation of
assistant `reasoning_content` (#3118). Kimi Code negotiates a small exact
User-Agent set on 403 and caches the accepted value; this is provider transport,
not model capability.
Reports distinguish K2.5/K2.6 multimodality from older K2 variants (#2522).
Promote those claims only through exact structured model IDs/families, not a
`kimi` name match.
## Fallback And Current Gaps
Keep Moonshot and Kimi Code identities distinct even when both use OpenAI Chat.
Self-hosted Kimi checkpoints inherit their serving engine shape, not official
Moonshot sampling rules. The provider catalog does not yet yield a complete
canonical capability card.
+28
View File
@@ -0,0 +1,28 @@
# NVIDIA NIM Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `nvidia`; OpenAI-compatible NVIDIA/NIM endpoints; current
provider detection, catalog routing, and reasoning stream handling in
`src/llm_core.py`, `routes/model_routes.py`, and tests.
## Shape And Observations
Model lists use the general identity-only shape; capability-looking fields
require a provider-native mapped shape.
NIM/vLLM-style responses have emitted structured `reasoning` while older paths
used `reasoning_content`; Odysseus routes either to the reasoning channel
(#602). This response compatibility does not claim that every NIM model
reasons.
NVIDIA endpoints can host many unrelated model families with different tools,
vision, context, and parser support. Keep endpoint/model stable identity and
prefer provider fields or probes.
## Fallback And Current Gaps
Exact NVIDIA host preserves provider identity; private NIM installations need
explicit endpoint kind because a local port/hostname is not distinctive. No
safe normalized native NIM capability endpoint is currently consumed.
+52
View File
@@ -0,0 +1,52 @@
# Ollama Provider Shape
Last updated: dev@e71f8ce | 2026-08-25
## Scope
Canonical provider ID `ollama`; native Ollama chat/generate plus OpenAI
compatibility; reader `src/model_capability_readers/ollama.py`; discovery and
runtime code in `routes/model_routes.py` and `src/llm_core.py`.
## Catalog And Detail Shapes
Use two native steps:
1. `GET /api/tags` returns `models[]` identity (`name`/`model`, digest,
`details.family|families`, format, parameter size, quantization). Tags do not
claim capabilities.
2. `POST /api/show` for a selected model returns explicit `capabilities[]`,
`details`, and `model_info`. Map completion/chat, embedding, vision, tools,
and thinking/reasoning tokens. Map context from exact `context_length` or
native `<architecture>.context_length` fields.
The reader does not parse model names or architecture names. It does parse a
two-column serialized `parameters` value and can take `num_ctx` from it before
falling back to exact or suffix `*.context_length` keys in structured mappings.
The parameters text is used only for that keyed limit lookup, not capability
inference.
## Request And Response Shape
Native chat uses `/api/chat`, `messages`, optional OpenAI-shaped tool
definitions, `format`, `options`, and model-dependent `think`. Responses use
`message.content`, `message.thinking`, and `message.tool_calls`. Generate uses
top-level `response` and `thinking`. OpenAI compatibility is a separate dialect
and can change control names independently.
Manual Ollama endpoints registered against the OpenAI-compatible `/v1` surface default to text/prompted tools unless the operator explicitly enables `supports_tools`; model naming alone does not opt that dialect into native function schemas.
Thinking control is model-specific: most documented reasoning families accept
a native bool, while GPT-OSS accepts low/medium/high and cannot be fully
disabled. A reported Ollama 0.20.6 Qwen3.5 OpenAI-compat path requires
`reasoning_effort: none` rather than `think: false` (#5503); keep it versioned
and low-confidence until corroborated.
## Fallback And Safety
Current reader detection identifies port 11434 as Ollama, in addition to an explicit endpoint kind or an exact/label-bounded `ollama.com` hostname. This is a normalization hint, not endpoint trust or capability evidence. Names that contain `vision`, `embed`, or `qwen` are not capability evidence (#3743, #4487).
## Current Gaps
- List discovery needs an orchestrated `/api/show` detail step per model.
- Runtime OpenAI-compat thinking suppression still contains name heuristics.
@@ -0,0 +1,57 @@
# General OpenAI-Compatible Inventory Fallback
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
Canonical compatibility identity `generic_openai`; identity-only reader
`src/model_capability_readers/generic_openai.py`; shared envelope and identity
helpers in `src/model_capability_readers/base.py`.
This is not a universal OpenAI-compatible capability schema. Transport request
and response behavior remains in `src.llm_core` and provider adapters.
## Accepted Inventory Shape
- `{"data": [...]}`;
- `{"models": [...]}`.
Within an item, the reader recovers identity from `id`, `name`, or `model`.
Bare-list payloads and `key`/`slug`-only items are not supported. It preserves
the raw item on the in-memory record, while `to_dict()` includes it only when
the caller explicitly requests `include_raw=True`. Capability remains unknown.
## Disabled Capability Paths
The generic reader does not inspect capability-looking fields, including:
- `type`, `model_type`, `task`, and `pipeline_tag`;
- top-level or nested modality fields;
- capability booleans/maps/lists;
- `supported_parameters`;
- context, input, output, and model-length fields.
Names, descriptions, ownership, pricing, and serialized text also never
promote capability through this reader.
## Forward Compatibility
An explicitly configured but unknown provider ID is preserved when the generic
reader is selected. That allows endpoint-scoped stable IDs to keep working
while every family, modality, capability, limit, and control remains unknown.
Non-object entries are skipped; null or malformed roots return no records.
Provider-specific headers, request extensions, and reasoning channels must be
selected by explicit provider/endpoint adapters. They never leak through this
fallback.
Compatible tool-call syntax is likewise a runtime concern rather than catalog capability. Current parsers recover selected Hermes/Qwen JSON bodies nested inside `tool_call` wrappers and require the full Qwen bare end delimiter; GPT-OSS compatibility can alias names that collide with its built-in tools and reverse that alias before local dispatch. None of those repairs grants execution authority or proves generic tool support.
## Current Gaps
- Compatible providers differ on path prefixes, null handling, tools,
streaming usage, and strict extra-field rejection.
- Bare-list and `key`/`slug`-only inventories need explicit normalization if a
runtime consumer later requires them.
- Safe request shaping still requires explicit endpoint/provider
configuration even when identity normalization succeeds.
+34
View File
@@ -0,0 +1,34 @@
# OpenAI Provider Shape
Last updated: dev@e71f8ce | 2026-08-25
## Scope
Canonical provider ID `openai`; API dialects OpenAI Chat Completions and
Responses; catalog reader `src/model_capability_readers/openai.py`.
## Catalog Shape
`GET /v1/models` returns `object: list` with `data[]` model cards containing
`id`, `object`, `created`, and `owned_by`. This is identity and availability
metadata only. It does not claim vision, tools, reasoning, modality, task, or
context length. The record remains unknown and keeps the raw fields.
## Request And Response Shape
Chat uses `messages`, `tools[].function`, `tool_choice`, and
`choices[].message|delta`; Responses uses `input`, flattened tools, output
items, and typed stream events. OpenAI may support a parameter at the platform
level while individual models differ. A later model registry or probe must
scope that fact before it becomes canonical model capability.
## Fallback And Safety
An explicit endpoint kind selects this provider. Automatic reader detection accepts exact `openai.com` or a dot-delimited subdomain after normalizing case/trailing dots; it is a normalization hint rather than a trust boundary. Do not parse model IDs or ownership labels. If a proxy returns richer fields while explicitly configured as OpenAI, the reader preserves them as raw evidence but keeps capability unknown.
## Current Gaps
- OpenAI's Models API does not publish the per-model capability shape needed
for automatic canonical classification.
- Runtime model-specific sampling/reasoning behavior still needs a maintained
structured registry or endpoint probes.
+21
View File
@@ -0,0 +1,21 @@
# OpenCode Provider Shape
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
Canonical provider identity `opencode` with Zen/Go endpoint variants; OpenAI-compatible transport and webhook presets in `src/llm_core.py` and canonical `routes/webhook/webhook_routes.py`, with the top-level route module retained as a compatibility shim.
## Shape
Keep Zen and Go path identity in endpoint metadata even though the canonical
provider family is OpenCode. Model discovery uses general identity-only
fallback. Path/version, account policy, and model selection can differ between
variants; do not flatten them into OpenAI.
## Fallback And Current Gaps
Exact `*.opencode.ai` plus configured `/zen` or `/zen/go` selects this family.
No provider-specific rich capability catalog is mapped, and runtime still has
separate variant labels that should eventually become structured endpoint
metadata.
+41
View File
@@ -0,0 +1,41 @@
# OpenRouter Provider Shape
Last updated: dev@e71f8ce | 2026-08-25
## Scope
Canonical provider ID `openrouter`; OpenAI-compatible chat dialect; rich reader
`src/model_capability_readers/openrouter.py`.
## Catalog Shape
`GET /api/v1/models` returns `data[]`. Canonical fields are:
- `id` (falling back to `name`) and display `name`;
- `architecture.input_modalities`, `architecture.output_modalities`, and
compatibility `architecture.modality`;
- `context_length` and `top_provider.max_completion_tokens`;
- `supported_parameters`, `default_parameters`, `supported_voices`, and
`per_request_limits`.
Modalities determine family and vision/file/audio/image/video behavior.
Recognized supported parameters claim tools, JSON/structured output,
reasoning, and web search. Sampling/default parameters become controls, not
capabilities. Descriptions, pricing, author slugs, and tokenizer names do not.
## Provider Versus Routed Endpoint
OpenRouter normalizes requests while routing a model to one of several
underlying providers. The catalog model record is OpenRouter-scoped. Do not
copy a direct-provider quirk to OpenRouter unless its normalized API and exact
model/endpoint evidence require it. `top_provider` limits describe the current
route class, not a permanent global model limit.
## Fallback And Safety
The reader receives OpenRouter through explicit selection or an exact/label-bounded `openrouter.ai` hostname hint. Future fields remain raw. If modalities are absent, it falls back to an identity-only OpenRouter record and does not parse the model slug; supported-parameter controls are not retained on that fallback path.
## Current Gaps
- Per-upstream endpoint differences can still invalidate an aggregate claim.
- Catalog values change frequently and need freshness/expiry when persisted.
+20
View File
@@ -0,0 +1,20 @@
# Perplexity Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `perplexity`; OpenAI-compatible cloud endpoint recognized
by current UI/provider host maps and agent cloud-host safeguards (#3015).
## Shape
Use general identity-only inventory mapping. Perplexity products may perform
search, but `web_search` becomes a canonical model capability only when an
exact model card, maintained registry, or probe reports it. Provider identity
alone and product descriptions are insufficient.
## Fallback And Current Gaps
Exact `*.perplexity.ai` preserves provider identity. No rich per-model catalog
or search-control mapping is currently consumed.
+47
View File
@@ -0,0 +1,47 @@
# SGLang Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `sglang`; OpenAI Chat/Responses plus native generation;
Cookbook launch behavior in `routes/cookbook_routes.py` and serving UI modules.
There is no dedicated SGLang canonical reader on current `dev`.
## Metadata Shapes
Preferred native `GET /model_info` (legacy `/get_model_info`) returns:
- `model_path` and `tokenizer_path`;
- `is_generation`;
- `has_image_understanding` and `has_audio_understanding`;
- `model_type`, `architectures`, `weight_version`;
- `preferred_sampling_params`.
These are provider observations for a future dedicated reader. Current generic
normalization does not map `is_generation`, modality booleans, sampling keys,
or `max_model_len`.
`GET /v1/models` returns served IDs with `owned_by: sglang`, `root`, and
`max_model_len`; it supplies identity/context but not parser capability.
## Runtime Capability
Tools and reasoning depend on explicit `--tool-call-parser` and
`--reasoning-parser`; multimodality and context can also be launch-configured.
Cookbook recipes for Qwen, DeepSeek, GLM, Kimi, MiniMax, StepFun, and other
families are deployment observations, not universal model-name rules. Persist
the selected parser/config as endpoint evidence before canonical promotion.
## Fallback And Safety
Current reader detection identifies port 30000 as SGLang, or accepts an
explicit endpoint kind, then dispatches to the generic identity-only reader.
It does not infer SGLang from `/model_info` payload shape. Avoid normal
discovery through the broad admin `/server_info` dump.
## Current Gaps
- Endpoint records do not yet store parser/task configuration canonically.
- Non-generation task classification needs explicit serving metadata.
- No dedicated reader maps SGLang metadata today.
+21
View File
@@ -0,0 +1,21 @@
# SiliconFlow Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `siliconflow`; global/CN OpenAI-compatible provider
proposed in #5562.
## Shape
Use the general `/v1/models` identity-only inventory reader for both regional
surfaces. Region/base URL and API key remain endpoint identity. A regional
provider-native schema is required before any item fields are promoted; model
tokens in returned IDs or PR examples are never capability evidence.
## Fallback And Current Gaps
Exact SiliconFlow hosts or explicit kind preserve provider identity. The open
provider work has no confirmed rich capability card; regional path/host details
and current payload fixtures need revalidation before runtime integration.
+27
View File
@@ -0,0 +1,27 @@
# Together AI Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `together`; OpenAI-compatible cloud transport; curated
models and discovery compatibility in `routes/model_routes.py`.
## Shape And Observations
Together has returned both standard `data[]` and bare model-card lists. The
current generic reader accepts the standard envelope when the caller supplies
the Together vendor, but it does not accept a bare root list. It keeps
identity/provider scope and promotes no capability fields. Task, modality,
parameter, and limit data needs a dedicated Together reader before it becomes
canonical; model names and the curated picker list are not capability evidence.
Together can serve many upstream families. Direct-provider quirks do not
automatically apply because Together may normalize requests and responses.
## Fallback And Current Gaps
Both `*.together.xyz` and `*.together.ai` identify the provider. Malformed/null
lists fail soft. A provider-specific rich capability schema has not been
confirmed, so general fallback remains intentional. Bare-list catalogs require
route-specific preprocessing or a future reader update.
+19
View File
@@ -0,0 +1,19 @@
# Venice Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `venice`; paid OpenAI-compatible cloud API represented in
webhook presets and cloud/self-hosted classification tests.
## Shape
Use general identity-only inventory mapping. Treat `api.venice.ai` as a remote API
for routing/security, while keeping model capability per returned model. Do not
infer privacy, tools, reasoning, or context from provider marketing or names.
## Fallback And Current Gaps
Exact `*.venice.ai` preserves provider identity. No verified rich model-card
schema is currently mapped.
+44
View File
@@ -0,0 +1,44 @@
# vLLM Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical placeholder provider ID `vllm`; OpenAI Chat and Responses serving;
generic identity-only inventory normalization. There is no dedicated vLLM
reader or model-card detector on current `dev`.
## Catalog Shape
Current `GET /v1/models` returns `object: list`, `data[]` model cards with
`id`, `object`, `owned_by: vllm`, `root`, `parent`, `max_model_len`, and
`permission[]`. The generic reader retains only identity/raw data and does not
inspect `owned_by`, `root`, `parent`, `max_model_len`, or `permission`. The card
does not prove chat template, tools,
reasoning parser, vision assets, embeddings, transcription, or rerank.
LoRA cards can use a different `id`, root path, and parent. Keep each served ID
endpoint scoped and do not merge it globally with the base checkpoint.
## Runtime Capability
vLLM's supported API surface is broad, but actual behavior depends on the
loaded model task, chat template, multimodal assets, tool-call parser,
reasoning parser, structured-output configuration, and launch flags. Current
Odysseus reasoning regressions cover structured `reasoning`, legacy
`reasoning_content`, and compatible fields (#602). These response channels are
transport evidence, not a claim that every vLLM model reasons.
## Fallback And Safety
Current reader detection identifies port 8000 as vLLM, or accepts an explicit
endpoint kind, then dispatches to the generic identity-only reader. It does not
infer vLLM from the model-card payload. Do not consume `/server_info`
environment/config dumps for normal discovery because they can be large and
operationally sensitive.
## Current Gaps
- A small safe native capability endpoint is not part of the canonical probe.
- Deployment parser/template flags are not persisted with endpoint capability.
- No dedicated reader maps vLLM model-card fields today.
+21
View File
@@ -0,0 +1,21 @@
# xAI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `xai`; OpenAI-compatible xAI cloud transport; provider
labels/curation in `src/llm_core.py` and `routes/model_routes.py`.
## Shape
Model discovery uses general identity-only inventory. Reasoning effort, tools,
image input, or other Grok behavior must be
scoped per returned model/registry/probe. The provider's broad API feature set
does not grant every listed model every capability.
## Fallback And Current Gaps
Exact `*.x.ai` selects xAI. Preserve provider identity through OpenAI-compatible
fallback and reject lookalikes. A current rich model catalog schema and
structured version registry are not yet mapped.
+23
View File
@@ -0,0 +1,23 @@
# Z.AI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `zai`; Z.AI/GLM OpenAI-compatible endpoints including
coding-plan variants; curated discovery in `routes/model_routes.py` and prior
vision/reasoning fixes such as #664.
## Shape And Observations
Use general identity-only inventory mapping. Some working coding-plan models may be
absent from `/models`, so pinned/curated IDs are availability compatibility,
not capability truth. GLM reasoning controls have appeared as structured
objects or serving-template kwargs depending on direct cloud versus local
engine (#3031). Keep those scopes separate.
## Fallback And Current Gaps
Exact `*.z.ai` or explicit endpoint kind preserves Z.AI identity. Never infer
vision/reasoning/tool support from `glm` in a name. A rich official model-card
reader and direct-versus-coding-plan schema split are still missing.

Some files were not shown because too many files have changed in this diff Show More