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
nopoz d0d8edf5d8 Merge commit from fork
scripts/mlx_image_server.py resolved the model per request
(`req.model or _args.model`) on both /v1/images/generations and
/v1/images/edits, so the caller chose which model was served.

`_is_hidream()` is a substring test and `_snapshot_path()` accepts either a
local directory or a Hugging Face repo id, so a caller-supplied string
selected the HiDream branch and then supplied the directory it runs
`scripts/hidream_o1/generate_hidream_o1_mlx.py` from, under sys.executable.
The server has no auth, and the Cookbook binds it to 0.0.0.0 whenever it is
serving to a remote host, so one POST executed attacker code on the serving
host.

Both paths now use `_args.model`. The request field is still accepted for
OpenAI wire compatibility and ignored, matching scripts/diffusion_server.py,
and Odysseus already sends the served model's own id, so this is a no-op for
legitimate callers. /v1/images/harmonize already pinned.

Regression tests cover both endpoints, the local-directory and
Hugging-Face-repo halves, and that a server actually launched with a HiDream
model still serves it. Three of the four fail on the unfixed code.
2026-08-24 17:38:40 +02:00
Joeseph Grey b4d12932a9 fix(agent): drop the empty assistant turn from an approved-action replay (#6124)
The approved-action replay appends the sealed tool result with no assistant
prose for that round, which produced an assistant message with content "".
Anthropic's Messages API rejects a non-final assistant message with empty
content, so a resumed turn after a tool approval failed before the model saw
the result. A turn carrying neither prose nor reasoning has nothing to say to
any provider, so it is no longer appended. A round with prose, and a
reasoning-only round that DeepSeek thinking mode needs, both still append.
2026-08-20 13:06:22 +02:00
Nikhil Chaudhary 85297cee44 fix(core): clean up orphaned temp files on atomic write failure (#6068)
* fix(core): clean up orphaned temp files on atomic write failure

* fixed reviewer suggestion

* removed whitespace
2026-08-19 17:38:24 +02:00
RaresKeYandLéo 981652358e fix(agent): allow remaining actions for an approved task (#6113)
* fix(agent): allow remaining actions for an approved task

* fix(agent): make approval continuation control-only

* fix(ci): preserve approval taint and cache-buster contract

* fix(ui): keep tool approvals in current chat

* fix(ui): route tool approvals through chat submit

* test(ui): pin approval submit routing

* fix(agent): complete approval denial flow

* fix(ui): avoid duplicate ask-user close icon

* fix(agent): retain approved tool in continuation set

* revert(ui): keep PR 6113 scoped to approval continuation

* fix(agent): add task and chat approval scopes

* fix(ui): prevent duplicate ask-user close icon

* feat(ui): add ask-user option shortcuts

* fix(compare): route ask-user choices per pane

* fix(agent): keep skill-test approvals to a single action

The chat card now reuses the wire value `approve` to mean chat-session
scope, and `consume()` returned `allow_remaining_actions=True` for it
unconditionally. The skill-test approval route was never updated: it still
sends `approve` meaning "once", and its button still reads "Allow once",
but the grant it got back set `approval_gate_bypassed` for the rest of the
resumed run. That surface wraps the skill body and every transcript byte
as untrusted context, so it is the last place where one click should
ungate everything that follows.

Give `consume()` an explicit `allow_continuation` flag. Callers that own a
resumable chat keep the scope the user picked; callers that do not — the
skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms
behind the sealed action, which is what their label promises.

* fix(ui): cache-bust every module the approval click depends on

chatStream.js, compare/index.js and compare/stream.js all changed
behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were
bumped. A returning browser therefore serves the new chat.js — which now
deliberately leaves the composer empty and clicks the send button — next to
the cached chatStream.js that has no interceptor. With an empty composer
that button sits at `data-mode="newchat"`, so the click opens a new chat
and the approval is dropped.

Bump the three, and version compare/stream.js's chatRenderer import to
match everyone else's so the ask_user keydown listener binds to one module
instance instead of two.

* fix(ui): keep the digit shortcuts off tool approval cards

With an approval card on screen and focus anywhere outside an input, a bare
`1` fired `approve_task` — the widest of the three grants — with no
modifier and no confirmation. That card is the one control whose entire
purpose is deliberate consent after untrusted context influenced the run,
and Deny sits at 3.

Label the card with its kind and skip the shortcut for approvals. Ordinary
ask_user questions keep 1-3.

* fix(compare): restore a pane's ask_user card instead of dropping the choice

renderAskUserCard removes the card as soon as onSubmit accepts, but the
resume loop gave up silently after 10s if the originating stream still owned
the pane. The user saw the click land, the card vanish, and nothing happen,
with no way to get it back.

Re-render the card on that deadline and say why. The reroll case still
returns without sending — that choice belongs to a stream that no longer
exists.

* refactor(chat): drop the unreachable deny branch

`if decision != "deny"` is always true — the deny path returns a
StreamingResponse a few lines above. It reads as if deny still falls
through to the toggle restore.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-19 08:01:34 -06:00
Utkarsh AdhranandRaresKeY 5c835014ac fix(time): prefer IANA timezone name over offset (#6122)
* fix(time): prefer IANA timezone name over offset

When both headers are present, resolve x-tz-name with ZoneInfo and ignore
a conflicting numeric offset. The prompt label uses the resolved zone so
name and UTC offset cannot disagree.

Related: #6111

* test(calendar): cover IANA timezone precedence

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-08-19 12:56:07 +02:00
Dividesbyzer0 43682d4e2e fix(cookbook): activate local Windows venv in bash runner (#5734) 2026-08-18 16:19:33 +02:00
RaresKeY 032967af4b fix(models): show API models by default (#6089) 2026-08-17 13:41:04 +02:00
RaresKeY 0e03aea134 fix(models): align API model checkbox state (#6087) 2026-08-17 11:09:02 +01:00
Joeseph Grey 2a6b09b968 Merge pull request #6081 from ydonghao/refactor/routes-task-to-subdir
refactor(routes): move task domain into routes/task/ subpackage
2026-08-16 22:29:43 -06:00
yuandonghao 1a2d889c33 refactor(routes): move task domain into routes/task/ subpackage
Slice 2p of the route-domain reorganization (#4082/#4071). Moves
task_routes.py (1181 lines) into routes/task/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.

The shim uses sys.modules replacement so the `import ... as task_routes` +
`monkeypatch.setattr(task_routes, "SessionLocal", ...)` /
`"get_current_user"` pattern and the `task_routes.__file__` reads in
test_auth_regressions.py all reach the canonical module.

Four source-introspection test sites repointed:
- test_aux_llm_owner_scope.py
- test_model_helper_owner_scope.py
- test_internal_api_base.py
- test_webhook_trigger_auth_exempt.py

Adds tests/test_task_routes_shim.py to pin the sys.modules shim contract.

Verified: compileall clean; full suite 5040 passed, 3 skipped.
2026-08-17 10:07:17 +08:00
Boody 517946d778 Merge pull request #5911 from Mubelotix/patch-1
docs(readme): Fix Star History section in README
2026-08-17 02:55:27 +03:00
RaresKeYandAlexandre Teixeira 8cb8b074a4 fix(docs): map live VectorRAG result shapes (#5960)
* fix(docs): map live VectorRAG result shapes

* fix(docs): normalize optional VectorRAG fields

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-17 00:07:12 +01:00
RaresKeYandAlexandre Teixeira ee252e7cd9 fix(chat): preserve URL prefetch failures in context (#5954)
* fix(chat): preserve URL fetch failures in context

* fix(chat): avoid duplicating signed URLs in fetch failures

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-17 00:01:10 +01:00
RaresKeY 0af6a99e81 refactor(search): extract outbound fetch transport (#5953) 2026-08-16 23:43:04 +01:00
RaresKeY f562bfee01 fix(speech): define the Kokoro optional install contract (#5962) 2026-08-16 23:39:12 +01:00
RaresKeY 0728b994d8 fix: discover sessions from persisted messages (#5938)
* fix(session): discover sessions from persisted messages

Use indexed chat-row existence instead of stale derived message_count metadata during startup discovery, then repair the bounded in-memory counts so lazy hydration remains correct. Keep truly empty sessions excluded and cover stale-low and stale-high counts with real SQLite.

* test(session): isolate discovery database

* test(session): use manager database metadata
2026-08-16 23:34:27 +01:00
RaresKeY db05175e3e fix(cli): generate live task webhook URLs (#5956) 2026-08-16 23:28:26 +01:00
RaresKeY e4046aa41f fix(models): bind provider detection to DNS labels (#5961) 2026-08-16 23:25:46 +01:00
RaresKeY 71f30fcc9d fix(issues): require exact bug-report revisions (#5984) 2026-08-16 23:04:23 +01:00
LéoandAlexandre Teixeira b19d327f03 fix(auth): derive the session cookie Secure flag from the request scheme (#6048)
* fix(auth): derive the session cookie Secure flag from the request scheme

SECURE_COOKIES only marked the login cookie Secure when it was explicitly
set to true, so an HTTPS login on an install that never set it handed out a
session cookie the browser is happy to send back in cleartext.

Unset now derives the flag from the request: the connection scheme, which
uvicorn's proxy-headers middleware rewrites for the proxies it trusts, or
X-Forwarded-Proto for a terminator that is not on a trusted address. That
is the same test core/middleware.py already applies before sending HSTS, so
the two stop disagreeing about whether a request arrived over TLS. An
explicit true still forces the flag on and an explicit false turns it off
for an install still answering on both HTTP and HTTPS. Strictly more Secure
flags than before and never fewer.

Empty counts as unset, because docker-compose pinned SECURE_COOKIES=false
for every container; the compose files now pass the variable through
unset, the way FASTEMBED_CACHE_PATH already does.

The helper and its decision order come from #3799, which was closed for
being too large to review and whose six replacement PRs dropped this fix.

Part of #3803.

* docs(setup): flag the leftover SECURE_COOKIES=false on upgrades

The old default was false, so an install set up before scheme derivation
can still carry an explicit SECURE_COOKIES=false in its own .env. That
value stays authoritative, so HTTPS logins keep getting a non-Secure
session cookie even after the tracked compose defaults are updated by a
pull. Say so where people look: the security notes and the variable's
own comment in .env.example.

* docs(setup): align TLS guidance with scheme-derived cookies

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-16 22:56:36 +01:00
Léo d0bf771f9d perf(static): vendor KaTeX and Mermaid, and load them on first use (#5994)
* fix(static): vendor KaTeX and Mermaid instead of loading them from a CDN

index.html pulled katex.min.{js,css} and mermaid.min.js from cdn.jsdelivr.net on
every page load. For self-hosted software that is three problems at once: an
air-gapped or offline install renders no math and no diagrams at all, every
session announces its IP, User-Agent and Referer to a third party, and the "runs
on your own hardware" promise quietly isn't true.

static/lib/ already vendors highlight.js, docx, xlsx, mammoth, html2pdf and
qrcode, so the CDN usage was an inconsistency rather than a policy. Vendoring
also pins Mermaid, which was floating on the `11` tag, to 11.16.1.

Behaviour is unchanged: both libraries still load eagerly from <head>, just from
this machine.

- KaTeX goes in its own directory because its stylesheet resolves fonts with a
  relative url(fonts/...), so the vendored CSS needs no rewrite. Only the .woff2
  variants ship, matching static/fonts/, since a browser that supports woff2
  never requests the .woff/.ttf alternatives the stylesheet also lists.
- The service worker precaches KaTeX and its fonts so offline math is typeset
  rather than falling back to system glyphs, and CACHE_NAME is bumped. Mermaid
  is left to the existing cache-first rule: at 3.5 MB, precaching it would mean
  re-downloading it on every cache bump for a library most sessions never touch.
- Licence texts travel with the bundles in licenses/, following the convention
  the repo already uses for OpenDyslexic and DeepResearch.
- .gitattributes turns the whitespace check off for static/lib/ so `git diff
  --check` passes without stripping bytes from the published npm artifacts,
  which would desync them from upstream.

* perf(markdown): load KaTeX and Mermaid on first use, not on every page load

Both libraries loaded eagerly from <head>, costing every session ~985 KB on the
wire (929 KB of that Mermaid) even though most chats contain neither a formula
nor a diagram. Measured on a cold profile via the Resource Timing API: JS bytes
per page load drop from 3,102,141 to 2,098,634, a saving of 1,003,507 bytes, and
third-party requests per load go from 3 to 0.

markdown.js now fetches each library the first time one is actually needed:

- renderMermaid() checks for an unprocessed mermaid fence before touching the
  network, and re-queries the DOM after the load so a diagram replaced mid-stream
  still renders.
- mdToHtml() is synchronous, so when KaTeX is not in yet it banks the math source
  in an inert placeholder and schedules a flush that loads the library and swaps
  the placeholders in. Once KaTeX is loaded it typesets inline exactly as before,
  so callers that never call a render helper still get their math.

Both loaders memoise the promise rather than the module, so concurrent callers
share one fetch and a double trigger cannot start two loads; a failed load clears
the memo so the next formula retries instead of being poisoned for the session.
The flush is scheduled with setTimeout rather than requestAnimationFrame, which
is throttled to a stop in a background tab and never fires at all in a headless
browser, so math would have sat as plain source text until the tab was focused.

If neither library ever loads, math degrades to readable source text and diagrams
to their fence contents, rather than to nothing.

* fix(markdown): unescape &amp; last so math entities survive intact

The math pass unescaped &amp; before &lt; and &gt;. mdToHtml escapes the source
first, so a literal "&lt;" typed inside a formula arrives here as "&amp;lt;",
turns back into "&lt;" on the ampersand pass, and is then eaten by the very next
one. Typing $a &lt; b$ rendered as "a < b" instead of the literal text.

The code-block pass in the same function already unescapes &amp; last; only the
math paths were the outlier, in all four of the copies this branch consolidated
into pushMath(). Reordering to match makes them consistent and clears the
js/double-escaping alert CodeQL raised on this PR.

Math containing a genuinely typed "<" is unaffected, which is why this went
unnoticed for so long. Covered by a regression test asserting both cases.

* fix(markdown): decode entity-spelled math in one pass

mdToHtml escapes the source before the math pass, so a typed "<" reaches
the delimiters as "&lt;" and a typed "&lt;" reaches them as "&amp;lt;".
KaTeX has no entity syntax and reads the leftover "&" as an alignment
marker, so "$a &lt; b$" rendered as a red .katex-error instead of a
formula, on both the inline and the deferred path.

Chained replaces cannot fix it in either order: unescaping "&amp;" first
lets the next pass eat the "&lt;" it just wrote, and unescaping it last
leaves the entity spelling for KaTeX to choke on. One alternation,
longest form first, decodes every spelling and never rescans its own
output.

The tests now drive the vendored KaTeX build rather than a renderer that
echoes its input, which is why the old assertion looked correct.

* fix(document): typeset deferred math before the PDF export

exportAsPdf() renders the document into a detached container and hands
it straight to html2pdf. On a page where KaTeX has not loaded yet,
mdToHtml() returns pending placeholders and schedules a flush scoped to
document, which never reaches a node that was never attached, so the
PDF printed raw formula source.

Render the container's own math first. renderMath() returns immediately
without fetching anything when there is nothing pending, so a document
with no formulas still exports without pulling KaTeX.
2026-08-16 22:43:12 +01:00
LéoandAlexandre Teixeira 04b8829fb2 perf(frontend): share one cached fetch for /api/auth/settings and /api/tools (#5997)
* perf(frontend): share one cached fetch for settings and tools

/api/auth/settings was fetched independently by eight modules and /api/tools by
three on a single load — 4 and 3 requests measured — and any two of those
callers could observe a different snapshot of the same object. chatRenderer.js
is imported under three different ?v= query strings, so it is three separate
module instances each issuing its own /api/tools request.

appConfig.js holds one promise per endpoint, so concurrent and later callers
share it. Every writer invalidates: the settings panel routes its 16 saves
through a single helper, and the admin tools save drops both snapshots because
that route persists disabled_tools into the same settings store. A rejected
fetch clears its slot rather than being memoised, so one blip at boot cannot
leave keybinds, TTS and the search provider on defaults for the session.

The settings panel keeps reading directly: it is the writer and edits what it
reads, so it must see authoritative state.

Cold load, Resource Timing: /api/auth/settings 4 -> 1, /api/tools 3 -> 1, and
0 settings requests on the first load after a login, because the cache now
consumes the sessionStorage prefetch that login.html writes.

Fixes #5996

* fix(admin): refetch tool state when the Agent Tools panel opens

The shared cache made Admin > Tools render the boot snapshot on every
reopen. Its save posts the whole disabled list rebuilt from the checkboxes,
so a tool disabled out of band (the manage_settings tool, another tab) came
back enabled on the next unrelated toggle. Reproduced against the running
app: with api_call disabled by a separate client, toggling app_api off
posted ['app_api'] and silently re-enabled api_call.

The panel now drops the shared entry before reading it, which restores what
dev does today and keeps the startup read that chatRenderer.js shares. Cold
load is still 1 request each for /api/auth/settings and /api/tools, and the
panel costs the same 2 requests per open as dev.

* fix(static): preserve concurrent tool setting changes

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-16 21:03:05 +01:00
Léo 895bf896e3 refactor(static): load the image editor on first use (#6074)
galleryEditor.js and its js/editor/ graph are 54 modules / 576 KB, and
gallery.js imported them statically. Every page load paid for the whole
image editor even though most sessions never open the Edit tab: 54 of the
173 JS files on a cold load, and 576 KB of the decoded JS, were for a
panel that was never displayed.

Add a small panel-loader registry (static/js/panels.js) that imports a
panel's module on first use and memoises the promise, so a double-click
cannot start two loads and a failed load can still be retried. Convert
the image editor to it, and route the two existing dynamic imports in
chat.js and chatRenderer.js through the same entry so all three call
sites share one module instance instead of two.

closeEditor() and isEditorOpen() stay synchronous: if the module was
never loaded there is no edit session to close and none can be open.

The service worker keeps precaching the editor, in a separate
PANEL_PRECACHE list, so the panel stays available offline even though
index.html no longer loads it. The two lists now serve different
purposes and the header comment says so.
2026-08-16 17:54:24 +01:00
Léo cc42f38a89 fix(ci): match the screenshot checkbox by wording, not emphasis (#6073)
The PR-description check folded the template's asterisks into the pattern,
so a ticked box written without them read as unchecked while rendering
identically on the PR page. `ready for review` was silently withheld and
the bot reported missing visual evidence even with screenshots attached,
with no way to tell from the rendered PR what was wrong.

The two attestations directly above it already anchor on the wording
alone. This one now does the same, accepting `**bold**`, `*italic*`,
`__underscores__` and plain text.

Fixes #6071
2026-08-16 16:26:08 +01:00
Joeseph Grey d5514da3ab fix(tasks): scope action_tidy_research broken-file sweep to admins (#6069)
action_tidy_research took an `owner` argument and never used it. Any user's
scheduled tidy task swept data/deep_research globally, unlinking every empty
or unparseable file regardless of who owned it.

A broken file has no readable owner stamp, so it cannot be matched against
`owner` the way _find_owned_research_path does, which is why the HTTP path and
manage_research already treat parse failure as not-owned. Clearing one is a
privileged act rather than an ownership one, so gate it on the canonical
owner_is_admin_or_single_user helper: admins and the single-user operator keep
the janitor, a regular user does not, and neither does the pre-setup window
before an admin exists.

Returns before the directory glob rather than filtering inside the loop, so a
denied run reports why instead of reporting "none broken" over files it never
inspected. That reason string surfaces in Activity as a skipped row.
2026-08-16 13:19:56 +01:00
RaresKeYandAlexandre Teixeira 67e08cce1b ci(prs): separate validation readiness from description checks (#5939)
* ci(prs): separate validation readiness from description checks

* fix(ci): harden PR readiness state

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-16 13:03:06 +01:00
Léo 2e2bb5231e fix(mcp): stop assuming http://localhost:7000 for the OAuth callback (#6032)
* fix(mcp): stop assuming http://localhost:7000 for the OAuth callback

The MCP OAuth callback origin is wrong on any install not reached at
http://localhost:7000, and on Docker it cannot be corrected at all.
Three sites, one assumption:

- The redirect base fell back to a fixed port 7000. The app binds APP_PORT
  natively (app.py, launcher.py) and the macOS launcher defaults to 7860,
  where 7000 is AirPlay Receiver, so the callback lands on another service
  entirely. The fallback now follows APP_PORT. The hostname stays localhost
  rather than internal_api_base()'s 127.0.0.1: this URI is registered with
  the authorization server, so changing the host would invalidate the
  registrations that already exist.

- The paste-back form hardcoded an http:// action. Serving the page over
  HTTPS, Chrome raises its insecure-form interstitial, and overriding that
  posts plain HTTP at a TLS port, which fails too. Either way the
  authorization code never reaches Odysseus. The action now carries the
  scheme the request arrived on.

- OAUTH_REDIRECT_BASE_URL is the only fix available to a Docker install,
  because the container listens on 7000 and cannot see the host port map,
  but compose never forwarded it and nothing documented it. Both fixed.

* fix(mcp): make the paste-back form action relative and export APP_PORT

Answers the review on #6032. Three of the fixes did not survive contact with
the deployments they targeted.

- The form action derived its scheme from request.url.scheme. uvicorn only
  honours X-Forwarded-Proto from a peer inside --forwarded-allow-ips, which
  defaults to 127.0.0.1; the Dockerfile CMD sets no override, so a proxy
  arriving over the Docker bridge is untrusted and the scheme stays http.
  That is mixed content on exactly the HTTPS installs paste-back exists for.
  A relative action is resolved by the browser against the origin the page
  came from, which is right under every proxy setup, and it drops the Host
  header from the page entirely.

- The APP_PORT fallback never fired for the shipped launchers. start-macos.sh,
  the generated .app launcher and launch-windows.ps1 all pass --port to
  uvicorn without putting the value in the environment, so the motivating
  case, macOS on 7860, still registered localhost:7000. Each now exports it.
  internal_api_base() and companion pairing read APP_PORT too and were wrong
  in the same way.

- .env.example pointed Google MCP servers at OAUTH_REDIRECT_BASE_URL.
  add_server writes Desktop App credentials, and Google only accepts loopback
  redirects for that client type, so a public origin comes back as
  redirect_uri_mismatch. The variable is for the DCR flow; Google stays on the
  loopback default and finishes remotely through paste-back.

The Host header is no longer reflected into the page, so the escaping
regression test asserts its absence instead of its escaping.
2026-08-15 23:09:01 -06:00
RaresKeYandLéo f7cbc885c1 fix(docker): migrate retained SearXNG settings (#6055)
* fix(docker): migrate retained SearXNG settings

Retained nonempty SearXNG settings can miss defaults required by newer pinned images while bypassing the entrypoint's narrow regeneration checks.

Add an atomic PyYAML-aware migration to all Compose variants. Preserve existing inheritance choices, custom content, secrets, ownership, and mode while inserting only the missing top-level default-inheritance key.

Validated with 39 focused and adjacent tests, compile checks, and fresh and retained pinned-image HTTP 200 gates. Full repository CI remains for the PR.

* fix(docker): chmod the settings temp file before chowning it

The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/DAC_OVERRIDE
and carries no FOWNER, and searxng's own entrypoint chowns /etc/searxng to
searxng:searxng, so every retained settings file belongs to that user by the
second boot. Chowning the temporary file first left root unable to chmod it,
so the migration exited 1 and `set -eu` killed the container before
`exec /usr/local/searxng/entrypoint.sh` — SearXNG never started and odysseus
blocked on its healthcheck.

Swap the two calls so the chmod lands while the temporary file is still
root-owned, and cover the ordering with a test that refuses the chmod once
the chown has happened, the way the kernel does.

* fix(docker): let searxng boot when the settings migration fails

The migration runs under `set -eu`, so any settings file it cannot parse or
rewrite took the container down instead of merely going unmigrated. A symlinked
/etc/searxng/settings.yml is enough: the migration refuses a non-regular file
and searxng, which reads through the symlink perfectly well, never got to start.

Guard the call with `|| true` in all three Compose variants. The failure still
prints its reason on stderr, and searxng is left to report anything genuinely
wrong with the file.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-16 04:17:58 +02:00
Alexandre Teixeira cee319050c refactor(settings): add registry-backed navigation and finder (#6040)
* refactor(settings): add modular shell primitives

* refactor(settings): wire modular shell

* test(settings): exercise real coordinator ESM boundary

* refactor(settings): add registry-backed settings finder

* fix(settings): harden registry navigation behavior
2026-08-16 02:48:19 +01:00
RaresKeYandAlexandre Teixeira 0dd70a7556 feat(auth): define Default/Local owner contract (#5795)
* feat(auth): define default local owner contract

* test(auth): harden default local owner matrix

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-15 20:27:26 +01:00
Alexandre TeixeiraandRaresKeY 9c71948376 fix(companion): honor configured pairing address (#6060)
* fix(companion): honor configured pairing origin

* fix(companion): keep configured pairing on v1 LAN contract

* fix(companion): reject numeric pairing hosts

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-08-15 21:11:10 +02:00
RaresKeY 18991d6f67 fix(companion): preserve models with auth disabled (#5797)
* fix(companion): preserve models with auth disabled

* test(companion): guard auth-disabled model scoping
2026-08-15 19:47:51 +01:00
Joeseph Grey 79b891c7ee Merge pull request #5817 from RaresKeY/fix/agent-external-context-gate
fix(agent): gate tools after external context
2026-08-15 12:26:00 -06:00
Léo 60bed54703 fix(chat): centre the agent-thread terminating dot on the rail (#6059)
The timeline's terminating dot used a single left offset (-17px) at both
breakpoints, but the thread's padding-left differs (22px desktop, 18px
mobile) and the step dots already carry a per-breakpoint offset. The 6px
dot therefore landed 2px right of the 2px rail on desktop and 2px left of
it on mobile, which is the visible kink under an expanded last step.

Derive each offset from the rail's centre instead: the rail sits at
left:5px and is 2px wide, so the dot's left edge belongs at 3px, giving
3px - padding-left per breakpoint.
2026-08-15 19:24:19 +01:00
RaresKeY 443f7d2963 fix(auth): normalize mounted request paths (#5807)
* fix(auth): normalize mounted request paths

* fix: make login page mount-aware
2026-08-15 18:55:15 +01:00
Joeseph GreyandRaresKeY 2c394704c6 fix(personal): run directory indexing off the event loop (#5634)
* fix(personal): run directory indexing off the event loop (#5558)

POST /api/personal/add_directory called rag.index_personal_documents
inline from an async handler, so the whole indexing job (os.walk, file
reads, per-chunk embedding, Chroma inserts) ran on the event loop and
every other request queued behind it. Indexing a real directory froze
the UI and API for 25+ minutes with no sign of life.

Move the blocking section into the threadpool via run_in_threadpool.
personal_docs_manager.add_directory stays inside it because its
refresh_index() re-extracts text across tracked directories, which is
also blocking work. A module-level lock serializes index jobs so the
threadpool move does not introduce parallel jobs racing
PersonalDocsManager's unsynchronized list mutations and file writes;
they previously serialized on the blocked loop, so one-at-a-time is
behavior parity.

* fix(personal): serialize add/remove/reload on an async job lock

The #5558 fix took the job lock INSIDE the threadpool worker and only on the
add path, so (1) remove_directory and /reload mutated PersonalDocsManager's
unsynchronized list/index concurrently with an in-flight add — the inconsistent
state the PR claimed to prevent — and (2) a queued add blocked on the lock while
holding an AnyIO threadpool token, starving the shared pool.

Move the lock to an asyncio.Lock acquired in the async handler BEFORE offloading,
and route add, remove and reload through it. A waiting request now parks on the
event loop instead of pinning a worker, and all three mutators are serialized so
the 'add/remove are serialized and cannot leave inconsistent state' guarantee
holds. remove and reload also run their blocking work off the event loop. The
lock is per-router so each app binds it to its own loop; single-process scope.

Tests: add-vs-remove and add-vs-reload serialization regressions (async via
ASGITransport, since asyncio.Lock deadlocks starlette TestClient's portal); the
existing add-vs-add test converted to the same driver.

* fix(personal): route upload and delete through the index job lock

/api/personal/upload and DELETE /api/personal/file mutated the same
vector and tracking state add/remove/reload serialize on, outside
_index_job_lock and inline on the event loop.

Both now stage async work on the loop, then run the complete transition
(vector writes, disk change, personal_docs_manager update) in one
offloaded critical section under the shared lock, acquired before the
offload so queued requests park on the loop rather than pinning a
threadpool worker.

Adds add-vs-upload and add-vs-file ordering regressions.

* fix(personal): bound multi-file upload memory

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-08-15 10:12:47 +01:00
RaresKeY d401e806d4 fix(agent): retire superseded approvals 2026-08-15 07:49:52 +00:00
RaresKeY 105a7c0d96 fix(agent): close exact approval edge cases 2026-08-15 07:44:32 +00:00
RaresKeY 73a4b10642 fix(agent): approve teacher-generated skills 2026-08-15 07:26:51 +00:00
RaresKeY 94cf119b11 fix(agent): taint model-visible tool responses 2026-08-15 07:18:25 +00:00
RaresKeY 7a138e8a3f fix(agent): seal document approval content 2026-08-15 07:01:36 +00:00
RaresKeY 2b72531eaa fix(agent): harden approval lifecycle 2026-08-15 06:52:44 +00:00
RaresKeY 58b2a4bfa9 fix(agent): close approval continuation gaps 2026-08-15 06:14:37 +00:00
RaresKeY fd50561af6 fix(ui): complete exact approval continuation 2026-08-15 05:51:54 +00:00
RaresKeY 1b09c568d8 fix(agent): authorize exact actions after untrusted context 2026-08-15 05:37:47 +00:00
RaresKeY 2811c7e815 fix(agent): keep ambient context fail closed 2026-08-15 04:18:05 +00:00
RaresKeY 1f216cfd0e fix(agent): taint stored document tool results 2026-08-15 04:13:56 +00:00
RaresKeY b715b81ad0 fix(agent): preserve authorized document event order 2026-08-15 04:03:46 +00:00
RaresKeY 05442a9945 fix(agent): close external-context gate gaps 2026-08-15 03:52:23 +00:00
RaresKeY 2295504141 fix(agent): close untrusted-context gate bypasses 2026-08-15 01:58:32 +00:00
RaresKeY 329f9d298d fix: taint prefetched web context 2026-08-15 01:57:09 +00:00
RaresKeY fef0e6f3c0 fix(agent): gate tools after external context
Classify built-in tool effects in a server-owned registry and carry run-local external-context integrity state through the agent loop and dispatcher. Block high-impact and unknown actions after successful external results, including same-batch calls, without relying on model compliance.
2026-08-15 01:57:08 +00:00
LéoandAlexandre Teixeira f9235ebbf1 docs(setup): document the HTTP/2 reverse-proxy setup (#6046)
* docs(setup): document the HTTP/2 reverse-proxy setup

The "private or proxied deployments" section named Caddy, nginx and Traefik
but gave no runnable config, and never mentioned the main reason to bother:
the frontend is unbundled ES modules, so a page load is a few hundred small
same-origin requests. Over HTTP/1.1 the 6-connection cap serialises those
into dozens of round trips, which is invisible on localhost and dominates
load time over a LAN or VPN.

Adds a five-step setup you can paste: a Caddyfile for each of the three ways
people reach these boxes (public domain, Tailscale, own certificate), how to
run the proxy in the foreground and then as a service, the .env keys that
have to follow the origin, and a curl one-liner to confirm HTTP/2 actually
negotiated.

Also covers what bites when moving an existing install behind TLS:
SECURE_COOKIES applying regardless of the scheme the request arrived on,
OAUTH_REDIRECT_BASE_URL still defaulting to localhost because the MCP
redirect is registered up front rather than derived per request, and HSTS
being host-wide and port-agnostic. Notes that a custom HTTPS port does not
stop Caddy binding port 80 for the redirect, which is the failure I hit
first.

Docs only — no code change is needed to run behind HTTP/2 today.

* docs(setup): clarify HTTP/2 and origin migration

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-14 18:44:42 +01:00
49e4e55d2c fix(skills): harden skill import against DNS rebinding and SSRF TOCTOU (#5986)
* fix(skill-importer): validate URL scheme and improve skills.sh handling

* fix(skill-importer): enhance DNS resolution and SSRF protection in fetch URL handling

* fix(url-safety): add allowed_dist parameter to check_outbound_url for flexible private blocking

* test(skill-importer): add comprehensive tests for URL parsing and outbound checks

* ensure newline at end of file in test_check_outbound_url_allows_public_ip

* fix(skill-importer): improve TLS certificate handling in _get_checked function

* fix(skill-importer): enhance _check_fetch_url to handle both hostnames and full URLs

* fix(skill-importer): enhance parse_skill_source to support skills.sh URLs in path and netloc

* fix(skill-importer): simplify skills.sh hostname check in parse_skill_source

* fix(skill-importer): enhance parse_skill_source to identify skills.sh URLs in path and handle localhost/IP addresses

* fix(skill-importer): enhance _resolve_and_check_url to validate all resolved IP addresses and prevent TOCTOU vulnerabilities

* fix(skill-importer): enhance parse_skill_source to support schemeless GitHub and skills.sh URLs

* fix(memory): resolve CodeQL URL sanitization warning and restore _check_fetch_url test alias

* fix(memory): pin skill fetch sockets without rewriting URLs

* fix(memory): reject unsupported skill wrapper hosts

* refactor(url-safety): remove unused importer exception

* test(memory): keep redirect regression hermetic

* test(dns-rebinding): add test for _PinnedTransport to ensure connection to pinned IP

* fix(skill-importer): enhance skills.sh support to extract GitHub links from page content

* fix(skill-importer): improve URL scheme validation for GitHub and skills.sh links

* fix(skills): reject unusable skill URLs instead of guessing

Resolving a skills.sh link by scraping the first github.com URL out of
the page body cannot work. Skill pages only ever link the repository
root, never the skill's subdirectory, so every skill in a repo resolved
to the same bundle: importing skills.sh/anthropics/skills/pdf walked the
whole monorepo, saturated the 64-file cap, and installed algorithmic-art
behind an ok:true response. Restore the redirect-target unwrap and fail
with a message that says what to do instead.

Also report the real reason a URL is rejected. The scheme check keyed off
"://" appearing anywhere in the string, so a supplied-but-unusable URL
came back as "URL is required", and a schemeless URL carrying "://" in
its query was reported as an unsupported scheme. Key off the parsed
scheme and let opaque schemes (mailto:, javascript:) and a schemeless
host:port fall through to the host check.

* test(skills): tighten the real-socket pinning regression

The handler swallowed its own exceptions, so a failure inside it
surfaced as a confusing assertion on the captured client address.
Record the exception and assert on it, run the thread as a daemon, and
close the listening socket from the test so a hang cannot outlive the
run. Also drop the duplicate ipaddress import and the missing newline.

* fix(skills): require exact GitHub skill URLs

* test(skills): read complete pinned request headers

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-14 13:33:06 +01:00
Christian SidakandAlexandre Teixeira b2789d04fb fix: stop status polling from cancelling running scheduled tasks (#5789)
* fix: stop polling GET /api/tasks/runs/recent from cancelling running tasks

Two paths caused the scheduler to interrupt a running background task
when the frontend Activity view polled for status:

1. GET /api/tasks/runs/recent was not in _PASSIVE_EXACT_PATHS, so
   _InteractiveActivityMiddleware treated it as a foreground request
   and called stop_background_tasks_for_foreground, cancelling any
   in-flight scheduled task. Add it to _PASSIVE_EXACT_PATHS alongside
   the other read-only polling endpoints.

2. The /api/activity/heartbeat handler called
   stop_background_tasks_for_foreground unconditionally, ignoring
   BACKGROUND_TASK_FOREGROUND_GATE=false. Wrap the call in a
   _gate_enabled() guard so the env var fully disables heartbeat-
   triggered cancellations.

Fixes #5782

Signed-off-by: Christian Sidak <christian@sentineltech.eu>
Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com>

* fix(scheduler): respect foreground gate for heartbeat

---------

Signed-off-by: Christian Sidak <christian@sentineltech.eu>
Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-14 10:47:47 +01:00
Michaelandmichaelxer a6bc86e331 fix(scheduler): treat /api/email/unread-state as passive UI poll (#6009)
Background scheduled agent runs were aborted as "Stopped by user" when
the web UI was merely open, because the idle /api/email/unread-state
poll was counted as foreground activity while its sibling
/api/email/urgency-state was already excluded.

Fixes #5981

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-08-14 10:22:27 +01:00
c4369305f0 refactor(model-routing): centralize explicit foreground fallback policy (#6020)
* refactor(model-routing): centralize explicit foreground fallback policy

Make foreground fallback an explicit per-user, availability-only policy shared by streaming Chat, non-stream Chat, and Agent runs.

Preserve strict defaults, owner/model and credential boundaries, pinned Agent routes, and truthful per-round provenance/accounting. Carry provider-reported model identifiers through native streaming adapters, non-stream responses, and caches, and keep legacy default_model_fallbacks as tombstoned raw storage that generic settings APIs and agent tools cannot expose or mutate.

* fix(agent-loop): restore rebase-dropped qwen routing, workspace prompt, and temperature clamp

* fix(model-routing): thread selected endpoint identity, fix cost classification and fallback eligibility

* fix(chat): restore stream helpers and harden run stop lifecycle

* fix(model-routing): let numeric provider codes win over symbolic rate-limit statuses

* fix(agent-loop): apply qwen temperature and notes-tool clamps per fallback candidate

* fix(chat): honor queued stop across resend and reload canonical terminal on EOF

* fix(chat): track stop queue and cleanup ownership by per-send generation

* fix(agent-loop): preserve requested temperature for non-qwen fallback candidates

* fix(chat): reserve send ownership before any await and scope stop to the current send

* fix(chat): clear the previous run identity at send reservation

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
2026-08-14 08:10:30 +01:00
RaresKeYandAlexandre Teixeira b52296471b fix(model-routing): keep selected models strict (#5801)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 14:10:07 +01:00
53869d194d fix(cookbook): record real Windows pid for local serve so Stop kills the model (#5912)
* fix(cookbook): record real Windows pid for local serve so Stop kills the model

The Windows-local serve runner recorded Git Bash's `$$`, which is the
MSYS/Cygwin pid, not the Windows pid. Win32 tooling (taskkill,
Get-CimInstance ParentProcessId, Stop-Process) can't match an MSYS pid, so
the frontend Stop-Tree walk found nothing and the llama-server child survived
after Stop, leaving the model loaded and the GPU pinned.

Record the serving shell's true Win32 pid via `/proc/$$/winpid`, falling back
to the outer proc.pid already written from Python when the map is unavailable.

The existing pid-tracking test asserted the buggy `$$` literal at the source
level, so it passed while the feature was broken; update it to the winpid
behavior and add a focused regression test.

* fix(cookbook): make Windows serve pid handoff deterministic

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 10:32:24 +01:00
DocFuriousandAlexandre Teixeira 17ee856d1c fix(teacher): import _TEACHER_SYSTEM_PROMPT from its current module (#5756)
* fix(teacher): import teacher prompt from current module

* test(teacher): make prompt monkeypatch import-order independent

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 08:13:16 +01:00
RaresKeYandAlexandre Teixeira e7eddbae13 fix(email): serialize urgency checkpoint delivery (#5804)
* fix(email): serialize urgency checkpoints

* fix(email): preserve urgency transaction lifecycle

* fix(email): fence stale urgency scans

* fix(email): fence stale urgency delivery

* fix(email): retire stale urgency accounts

* fix(email): fence urgency account retirement

* fix(email): retain urgency registration generation

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 05:05:21 +01:00
RaresKeYandAlexandre Teixeira 93eb10d4f0 fix(email): serialize default-account mutations (#5805)
* fix(email): serialize default account mutations

* fix(email): enforce default account invariant

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 04:59:46 +01:00
RaresKeYandAlexandre Teixeira 3f9633c44f fix(calendar): keep default creation transactional (#5806)
* fix(calendar): keep default creation transactional

* fix(calendar): serialize default calendar creation

* fix(calendar): handle renamed default id collisions

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 04:51:52 +01:00
858c872832 docs(setup): document supports_tools opt-in for manual Ollama /v1 endpoints (#5835)
* docs(setup): document supports_tools opt-in for manual Ollama /v1 endpoints

Manually-added Ollama /v1 endpoints default to the conservative
fenced-block tool-calling path, and there's currently no UI control to
opt a specific endpoint into native tool calling (#5192). The
supports_tools PATCH flag already exists and works, it just wasn't
documented anywhere a user could find it without reading source.

Adds a short section next to the existing "Ollama with Docker" notes
explaining when to use it and the exact API call, framed as an
advanced/opt-in setting per the maintainer's stated preference against
a casual UI toggle (#3195/#3438).

* docs(setup): clarify supports_tools false semantics

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 04:46:11 +01:00
1939a6ad2d fix(thinking): add deepseek-v4 to thinking model patterns (#6000)
* fix(thinking): add deepseek-v4 to thinking model patterns

deepseek-v4-flash emits reasoning_content via the API but was not
recognized in _THINKING_MODEL_PATTERNS (only deepseek-r1 and
deepseek-reasoner were listed). Add the deepseek-v4 prefix so
the model is recognized as thinking-capable.

The between-round _thinkOpen leakage was separately fixed by
PR #5931 (perf(chat): batch live thinking rendering).

Related: #3998, #5931

* test(thinking): cover DeepSeek v4 detection

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 04:26:13 +01:00
RaresKeYandAlexandre Teixeira 937c883c41 ci: make Python validation authoritative (#5940)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 03:35:09 +01:00
RaresKeYandAlexandre Teixeira e0615cda47 fix(upload): recover backups after same-timestamp corruption (#5860)
* fix(upload): harden index cache recovery

* fix: retry upload index loads across replacement

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 03:22:31 +01:00
leepokaiandAlexandre Teixeira 1976fe1b60 fix(tools): parse Hermes/Qwen JSON bodies inside tool_call wrappers (#5887)
parse_tool_blocks fed <tool_call> wrapper bodies only to the XML
iterators (_iter_xml_invoke/_iter_xml_direct), so the canonical
Qwen/Hermes text-mode form — a bare JSON object like
{"name": "bash", "arguments": {"command": "..."}} inside the
wrapper — parsed to zero tool blocks and the agent never executed
anything. Pattern 4d only matches OpenAI-style blobs with a literal
"function" key, which the Hermes format lacks.

Wrapper bodies are now classified first: a JSON-looking body ({ or [)
is parsed by the new _parse_json_tool_call_body, which requires an
object with a string "name" and rejects a non-object "arguments"
instead of coercing it, then converts through the same
function_call_to_tool_block used by the XML paths so aliases and
per-tool argument formatting stay uniform. JSON-looking bodies fail
closed — they are never rescanned by the XML iterators (including the
unclosed-wrapper and bare-invoke fallbacks), so XML-like text inside
JSON argument values stays data instead of selecting a different tool.
Non-JSON bodies keep the existing XML path unchanged.

Fixes #5187

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 02:41:03 +01:00
adfe3ab379 fix(llm): alias tool names that collide with gpt-oss built-ins (#5878)
gpt-oss (harmony) ships BUILT-IN tools named python/browser, invoked
with the raw body as the argument (to=python + bare source), while
custom functions use to=functions.NAME + JSON. Exposing our own tools
under those names makes the model answer with the built-in convention:
it emits raw code, the server parses it as JSON, and the request dies
with 'error parsing tool call: raw=import sys, ...'. In streaming mode
Ollama does not report it at all — it truncates the stream, so the turn
arrives as an empty response and the agent loop reads it as a model
stall. bash collides the same way in practice.

Measured on gpt-oss:20b via Ollama /v1, fixed agentic prompt, 12 runs
per arm: python+bash as-is 2/12, python renamed 10/12, both renamed
12/12. 74 HTTP 500s were logged server-side during investigation with
zero surfaced to the client.

Rename the colliding tools on the outbound payload and map the names
back on responses. Transport-only and gated on gpt-oss: every other
model's schemas pass through untouched (asserted in tests).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 02:01:52 +01:00
d87a913729 fix(ui): stop stripping the word assistant from rendered text (#5974)
* fix: stop stripping the word 'assistant' from rendered text

The QWEN_BARE_MARKER_RE regex in both the Python backend (tool_parsing.py)
and JS frontend (chatRenderer.js) was matching any standalone occurrence of
the word 'assistant' separated by any whitespace, then replacing it with a
space. This caused normal English uses like 'Home assistant' to render as
'Home '.

Fixed by narrowing the word-boundary check from [\t\r\n ] (any whitespace)
to [\r\n] (line boundaries only), so only Qwen-format role-token leaks
(where 'assistant' appears alone on a line) are stripped.

* fix(tests): update bare-marker test expectations for #5971

Move 'x assistant y' from STRIPPED to KEPT (mid-sentence must survive).
Add 'Before\nassistant\nAfter' to STRIPPED (bare-marker on own line).

* fix(ui): strip whitespace-padded assistant role markers

---------

Co-authored-by: samy <samy@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-12 01:47:11 +01:00
bea48c749c fix(sidebar): keep minimized icon rail in sync with per-tab visibility (#5987)
* fix(sidebar): keep minimized icon rail in sync with per-tab visibility

Per-tab visibility (Customize UI / Appearance checkboxes, stored in
localStorage under `odysseus-ui-visibility`) was only applied to the full
sidebar elements — `UI_VIS_MAP` never targeted the collapsed `#icon-rail`
launchers. So a user who turned a tab off (e.g. Email) in the full view saw
every tab reappear when minimizing the sidebar to the icon rail.

Pair each tool/section selector with its `#rail-*` counterpart (mapping
mirrors `_railToolMap`), so `applyUIVis()` hides the rail launcher too.
Admin feature-flag handling is unaffected: the features-fetch reconcile at
app.js already re-applies `applyUIVis()`, so rail launchers now track admin
disables exactly like their sidebar buttons.

Adds a static regression test asserting every customizable tab pairs its
rail button.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(sidebar): extract UI visibility into testable module

Move UI_VIS_MAP, UI_VIS_DEFAULT_OFF, and a pure resolveVisibility() into
static/js/ui_visibility.js so the icon-rail visibility rules are unit
testable without a DOM. app.js applies resolveVisibility() to the document,
replacing the ad-hoc tools-section override with an inline parent rule
(tools-section off hides every tool rail launcher). Add edge-case tests
covering per-tool off, the tools-section parent rule, parent+child combos,
email-section, and the tool-library <-> #rail-archive mapping.

Refs #5985

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 01:28:30 +01:00
Manuel Cartagena HerreraandAlexandre Teixeira 5a016e492c fix(gallery): handle MPS float64 mask inputs (#5903)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 01:23:43 +01:00
AshvinandAlexandre Teixeira 93653120d6 fix(skills): stop SKILL.md frontmatter escapes compounding on every save (#5883)
_emit_scalar quotes a frontmatter scalar with json.dumps when it holds
punctuation that would change how the line reads back. _parse_scalar undid that
with a bare raw[1:-1]: it stripped the quotes but never decoded the escapes. So
a description containing ü was written as the escape sequence \u00fc, read
back with that escape still sitting literally in the value, and re-escaped on
the next save. The backslash run doubles every save, so a non-English skill
description degrades into backslash noise after a few edits, and the escapes are
shown verbatim in the skills list and the /skills catalog.

This is not limited to non-ASCII. Any description containing a quote takes the
same path, since the quote is itself what forces the quoted form.

Make the two halves symmetric: emit with ensure_ascii=False, since SKILL.md is
UTF-8 at both ends (skills.py reads it, atomic_write_text writes it) and the
ASCII-escaped form bought nothing; and parse double-quoted scalars with
json.loads, falling back to the previous literal reading when the value is not
valid JSON. Files already corrupted heal one level per load.

ensure_ascii=False on its own would open a smaller hole. json.dumps escapes
every C0 control character but passes NEL, LINE SEPARATOR and PARAGRAPH
SEPARATOR through literally, and parse_frontmatter reads one scalar per line via
str.splitlines(), which breaks on all three. Re-escape those three, and add them
plus the remaining splitlines characters to the set that forces a quoted scalar,
so none of them can reach the file bare.

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 01:09:02 +01:00
Léo c2b9666def perf(frontend): preload the two first-paint Fira Code faces (#5992)
The app font faces are declared in static/style.css, so the browser only
discovers FiraCode-Regular.woff2 and FiraCode-SemiBold.woff2 once the
stylesheet has parsed. On a cold load they start about 145 ms in, behind
the module graph. font-display: swap keeps that from blocking render, so
the cost is a visible swap rather than a stall, but the fetch can start
immediately instead.

Two preload hints move the request into the head. Measured cold on a
scratch instance with an empty cache, three runs per arm: request start
142-203 ms becomes 15-19 ms, response end 174-248 ms becomes 46-63 ms.
The total request count is unchanged and each face is still fetched
exactly once.

crossorigin is required even though these are same-origin: fonts are
always fetched in CORS mode, and without it the preload is discarded and
the font fetched again. Dropping the attribute produces four font entries
in the Resource Timing list instead of two.

Only Fira Code 400 and 600 are preloaded. They are the only faces first
paint uses. Inter, OpenDyslexic and Fira Code 300 stay unloaded on both
desktop and mobile, with or without a saved font preference.
2026-08-12 00:50:55 +01:00
1183fe0ff1 fix(llm): normalise Mistral structured content in llm_call_async (#5882)
llm_call_async returned raw list content for Mistral thinking models,
breaking callers that expect a str (e.g. auto-title). Match the sync
and streaming parsers by running list content through
_normalize_mistral_content.

Fixes #5435

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 00:40:16 +01:00
Léo 663d6879b7 fix(ui): stop the whirlpool spinner animating when it is never attached (#5990)
_drawWhirlpool re-armed requestAnimationFrame forever whenever its element
had never been connected to the document. The grace period is there so a
spinner can keep drawing between start() and the caller appending the
element, but it had no deadline: while the element has never been connected
_wpWasConnected stays false, so the guard stays true and the else branch is
unreachable. Any caller that starts a spinner and then takes an early return,
such as an aborted request or a panel that resolved from cache, leaves a loop
redrawing an 84-segment spiral into a detached canvas at one frame per
displayed frame until the tab closes.

Put a 2 second deadline on the grace period. Callers append in the same task
as start(), so that is far more slack than any of them need. A spinner that
is actually in the document is unaffected.

Two supporting changes in the same file:

- Both self-terminate paths now call stop() instead of setting isRunning
  directly, so termination always runs one cancelAnimationFrame and never
  depends solely on inferring DOM connectivity. Both draw functions bail at
  the top when they are no longer running, and _requestFrame() clears rafId
  as the callback enters so it is a truthful "a frame is pending" flag.
- start() arms a visibilitychange listener and stop() removes it. A hidden
  tab cancels the pending frame, a re-shown tab re-arms it. Chrome throttles
  background rAF but does not reliably stop the canvas work, and owning the
  listener from start/stop means a dead spinner never leaves one behind.

Adds tests/test_spinner_stops_when_never_attached_js.py, which drives the
real module under node with a fake clock and a manual frame pump. It covers
all four exits and, importantly, the converse: a spinner that is attached
keeps running well past the grace window.
2026-08-12 00:25:03 +01:00
Léo 3bea7a53ee fix(email): derive the Google OAuth redirect URI scheme from the request (#5995)
Both the authorize and callback routes built the redirect URI with a
hardcoded `http://` and the Host header. Behind any TLS terminator that
produces `http://host:443/api/email/oauth/google/callback` — the wrong
scheme and, on a split-port setup, a dead port. Google then refuses the
authorize request or the token exchange, so OAuth email is unusable on
every HTTPS deployment unless GOOGLE_OAUTH_REDIRECT_URI is pinned by hand.

uvicorn's proxy-headers middleware already rewrites the scheme from
X-Forwarded-Proto for trusted proxies (on by default, trusting 127.0.0.1),
so request.url.scheme is correct both directly and behind a proxy.

Google requires the callback's redirect_uri to match the authorize one
exactly, so both sites change together. An explicit
GOOGLE_OAUTH_REDIRECT_URI still wins, unchanged.
2026-08-12 00:04:32 +01:00
Amir FathiandAlexandre Teixeira 22e0af2a58 fix(core): stop atomic writes from colliding on a constant PID suffix (#5721)
atomic_write_json/atomic_write_text build their temp filename as
"{path}.tmp.{os.getpid()}". os.getpid() is constant for the life of a
process, so it only ever distinguishes concurrent writers that live in
different OS processes. Odysseus runs as a single long-lived process
per container, so two concurrent writers to the same path (e.g. two
request handlers racing a settings save) always compute the identical
temp path. Whichever finishes os.replace() first removes the shared
tmp file out from under the other, which then raises FileNotFoundError
on its own os.replace() instead of landing its write.

Fix: derive the temp suffix from uuid4() instead of the PID, so every
call gets a distinct temp path regardless of process/thread identity.

routes/prefs_routes.py's _save() had an independent, hand-rolled copy
of the exact same PID-suffix logic (not the shared core.atomic_io
helper other routes already use, e.g. routes/auth_routes.py) with the
same bug. Replaced it with a call to atomic_write_json.

Fixes #5596

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-11 13:36:56 +01:00
Tal.Yuan c00ef8f9c2 refactor(routes): move mcp domain into routes/mcp/ subpackage (#5899)
Slice 2o of the route-domain reorganization (#4082/#4071). Moves
mcp_routes.py (697 lines) into routes/mcp/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.

The shim uses sys.modules replacement so sys.modules.pop + re-import,
monkeypatch.setattr(mcp_routes, "MCP_OAUTH_DIR", ...), and __file__
introspection in test_security_regressions.py all reach the canonical
module. One source-introspection path string repointed (line 1001).

Canonical module imports only from core/, src/, and stdlib (zero internal
routes/ coupling). Adds tests/test_mcp_routes_shim.py.

Verified: compileall clean; full suite 4804 passed, 3 skipped.
2026-08-11 02:24:55 -06:00
Boody 1fef4929cf Merge pull request #5920 from adabarbulescu/fix/windows-workspace-access
fix(agent): use Git Bash for Windows workspace shell
2026-08-11 03:50:03 +03:00
RaresKeYandLéo 651bf714de perf(chat): batch live thinking rendering and bound timer updates (#5931)
* perf(chat): batch live thinking DOM updates

* test(chat): cover live thinking scheduler lifecycle

* fix(chat): guard background stop-state, restore live thinking text, drop source-text tests

- _closeOpenThinkingMarkup no longer overwrites currentAccumulated for
  backgrounded streams. It now mirrors the guard the delta path already uses
  (`if (!_isBg) currentAccumulated = accumulated`). Without it a backgrounded
  stream's text is written into the foreground session's stop-state, which
  abortCurrentRequest and detachCurrentStream then put in the wrong bubble.

- Split _extractLiveThinkingText into _liveThinkingText (strip every think tag)
  and _closedThinkingText (via extractThinkingBlocks). Slicing from the first
  <think> to the first </think> pinned the live box to "The" for the rest of the
  stream on the `<think>The</think>` + untagged-thinking pattern that the
  hasUnclosedThink detection deliberately keeps streaming through.

- The background transition now flushes with rich:true, so a stream that
  backgrounds mid-thinking isn't left as pre-wrap plain text permanently.

- Move the throttle to static/js/liveThinkingThrottle.js and import it. The
  .mjs suite imports the module instead of slicing it out of chat.js with
  vm.runInNewContext and marker comments.

- Replace the source-text assertions in tests/test_live_thinking_scheduler_js.py
  with behavioral coverage, per tests/TESTING_STANDARD.md. The .mjs suite grows
  from 3 to 6 cases.

- Collapse the duplicated tool_start/agent_step finalizers into one
  _endLiveThinkingSection().

* fix(chat): hoist thinking teardown out of the try block so catch can reach it

In an ES module a function declared inside `try { }` is scoped to that block,
and `catch` is a sibling scope rather than a nested one. _closeOpenThinkingMarkup
was declared inside the try and called from catch, so the call threw
ReferenceError and killed the rest of the error path: the stream never
finalized and the thinking block was never torn down.

Declare _closeOpenThinkingMarkup and a new _endThinkingOnTerminalPath next to
the existing _flushLiveThinking / _cancelLiveThinkingWork outer lets and assign
them inside the try, which is the pattern those two already use for exactly
this reason.

Verified against a live stream in a browser: before, clicking stop mid-thinking
logged "_closeOpenThinkingMarkup is not defined" and left no finalized thinking
section; after, the block collapses to "View thinking process" correctly.

* perf(chat): extract live thinking at commit cadence

* fix(chat): bound live thinking work

* test(chat): update stream invariant assertions

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 20:11:48 +01:00
RaresKeYandLéo d449a9d431 fix(history): defer full transcript hydration to model sends (#5929)
* fix(history): defer full hydration to model sends

* fix(session): key hydration on real rows, fork through get_session

Two regressions from the display/model-context split, both reproducible
against dev.

The hydration gate compared the cached transcript against the
denormalized sessions.message_count column. That column drifts in normal
operation — _persist_message swallows a failed insert while add_message
has already appended in memory, so the next successful persist writes
rows+1 — and _db_to_session re-read the same column after each reload, so
the shortfall never closed. Every send, edit, delete and truncate on a
warm session re-selected the whole message table: the cost this change
set out to remove, relocated onto the hot path. The other direction was
just as bad — a persist for an uncached session writes message_count = 0,
and a stale-low counter with a partly filled cache meant no hydration at
all and a silently truncated transcript for the model.

sync_session_metadata now reconciles message_count against COUNT(*) on
chat_messages (one indexed count inside the connection it already opens),
and _db_to_session trusts the rows it just loaded. A hydrate always
closes the gap, so the next read is a cache hit.

fork_session read session_manager.sessions directly and never hydrated.
keep_count indexes into source.history, and display pagination no longer
fills that cache, so forking after a restart returned HTTP 200 with an
empty conversation and no error surfaced. It goes through get_session
now.

_hydrate_session_history_from_db is gone with its helper: get_session is
the hydration seam, and rebuilding session.history from raw rows in the
display fallback overwrote the parsed multimodal content and the _db_id
edit/delete keys that had just been set.

Tests drive a real SessionManager over a temp DB instead of a stub that
only proved the stub hydrates — both drift directions, the send path
warm and cold, and a fork taken after a restart. All five fail without
this change. The brittle SQL-text assertions are dropped; the page
bounds are already proven by the response body.

* fix(history): route pagination through canonical handler

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 19:39:21 +01:00
RaresKeYandLéo dbeed4b63f perf(ui): stop session loading from blocking shell (#5927)
* perf(ui): stop session loading from blocking shell

* fix(startup): open routes on their own data, retire the loader for good

Follow-up to review on #5927.

- Route openers are now classified by the data they actually read. Only
  /email touches the hydrated session list (its new-chat path falls back to
  the most recent session's model when no default chat is set), so every
  other route opens as soon as module wiring completes instead of queueing
  behind /api/sessions. This is the deferred-route half of #5926, which the
  first pass left unimplemented.
- index.html's 5s fallback removes the loader node again. Leaving it in the
  DOM indefinitely kept _shouldPreserveStartupComposer true forever on a
  hung /api/sessions, so the composer stopped clearing on session switch.
- A missing session module settles hydration instead of leaving the sidebar
  on "Loading chats…" and dropping the user's route on the floor.
- Startup sequencing moved to static/js/startupShell.js so it can be run by
  tests. The source-text assertions in test_startup_shell_session_loading.py
  are replaced by node-driven behavioural tests, per tests/TESTING_STANDARD.md.
- Reverted the unrequested loader a11y rework, removed the duplicated inert
  writes (the module stops the wave interval through a callback), and moved
  the bootstrap row's inline styles into .session-list-bootstrap.

* fix: preserve session bootstrap failure state

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 19:37:21 +01:00
RaresKeY 96aca52094 perf(email): make library prewarm idle and bounded (#5925)
* perf(email): make library prewarm idle and bounded

* fix(email): preserve idle prewarm and prioritize foreground

* fix(email): retry interrupted idle prewarm safely
2026-08-10 19:14:10 +01:00
RaresKeYandLéo 8f2f483725 fix(email): make unread opens one authoritative IMAP operation (#5923)
* fix(email): mark opened messages seen in one IMAP operation

* fix(email): collapse unread opens and ignore stale responses

* fix(email): send seen flags as an IMAP flag list

Wrap the authoritative \\Seen STORE operand in parentheses so strict IMAP servers such as GreenMail accept both cache-miss and cached-open transitions. Tighten the focused fake IMAP contract to reject the previously emitted bare flag atom.

* fix(email): guard stale authoritative opens

* fix(email): report a failed \Seen instead of withholding the message

The authoritative-open contract made a failed STORE fatal to the read: the
cold path raised after the body was already fetched and parsed, and the
cached path discarded an in-memory message to return
{"error": "Failed to mark email read"}. A transient IMAP failure therefore
turned a readable message into one that could not be opened at all.

Being authoritative should mean the reported flag state is truthful, not
that the body is withheld. The read now always returns the message and
carries mark_seen_failed so the client can roll its optimistic unread
marker back:

- _read_email_sync logs and reports a rejected STORE rather than raising,
  and only writes the local index/list-cache transition when the provider
  accepted it, so local state cannot drift ahead of the mailbox.
- A mailbox that refuses a read-write SELECT (shared archives, some
  provider folders) falls back to a read-only selection and reports the
  flag failure instead of failing the open.
- The route strips mark_seen_failed before caching, so a one-off failure is
  never replayed to later readers.
- mark_seen now defaults to False on _read_email_sync. It was inert before
  this branch and now mutates provider state; the one caller that wants it
  off already passes it explicitly.

emailInbox and emailLibrary keep the message rendered when mark_seen_failed
is set and restore the unread state, rather than showing a failed reader.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 18:45:34 +01:00
Matyas GosztonyiandMatyas Fenyves 42da399b4d fix(email): route summaries through shared LLM adapter (#5841)
* fix(email): route summaries through shared llm adapter

* chore(ci): refresh PR checks

* fix(email): preserve scheduled summary safeguards

---------

Co-authored-by: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com>
2026-08-08 23:06:41 +02:00
adabarbulescu 48cf08328f fix(agent): use Git Bash for Windows workspace shell 2026-08-07 23:46:48 +03:00
Wes HuberandClaude Fable 5 e4fa4ae5dd fix(brain): give the Add Memory form a submit button and reliable Enter handling (#5830)
The Brain > Add tab rendered only a text input and category select with no
submit control, and Enter submission relied on a deprecated keypress
listener that is not guaranteed to fire, so the form could not be
submitted at all (#5828).

Add a labelled submit button styled like the neighbouring Skill Import
button (theme-io-btn, inline SVG icon), switch the Enter handler to
keydown with preventDefault, ignore IME composition, and pin both submit
paths with a source-level regression test.

Fixes #5828

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 22:07:07 +02:00
Samyandsamy 378518f6df Fix #5870: stale skills panel data on tab reopen (#5876)
Remove early-return guard in loadSkills() that skipped both API re-fetch
and renderSkillsList() when the Skills tab was reopened after first load.
The cascade entrance animation is already handled inside renderSkillsList()
via _cascadeNext, so the guard was unnecessary and caused deleted/edited
skills to remain visible until a full page reload.

Co-authored-by: samy <samy@users.noreply.github.com>
2026-08-07 22:06:17 +02:00
Samyandsamy f06a0a30a8 fix(session): restore session URL hash writes (removed in cf4e240a) (#5872)
* Fix: restore session URL hash writes (removed in cf4e240a)

Restores history.replaceState() calls in selectSession() and
materializePendingSession() that were dropped during the July 23 merge.
Without these, chat URLs never update the address bar hash, making
sessions unshareable and causing bare-URL reloads to land on the
welcome screen instead of restoring the last active chat.

Root cause: selectSession() had its hash-write deliberately removed;
materializePendingSession() lost its during a larger refactor that
added the stale-response and incognito guards.

Fixes #5870 (upstream)

* fix: session URL hash lost when sending message mid-stream

Two independent bugs caused the session hash to disappear from the URL:

Bug 1 — ReferenceError in catch block silently killed error recovery
  In handleChatSubmit, two const variables (streamingTTS at line 1922 and
  abortCtrl at line 1741) were declared inside the try block but referenced
  in the catch block. Since const is block-scoped in JavaScript, they were
  undefined in catch, causing a ReferenceError that silently aborted the
  error handler. This prevented materializePendingSession() from ever being
  called, so no hash was written to the URL.
  Fix: Hoisted both as let declarations before the try { block.

Bug 2 — Dual sessions.js ES module instances with mismatched state
  app.js imported sessions.js with a version query string
  (?v=20260722ctxheader4) while every other module imported ./sessions.js
  without one. The browser treated them as different URLs, creating two
  separate module instances with independent _pendingChat and
  currentSessionId state. createDirectChat() set pending on one instance
  while handleChatSubmit() checked hasPendingChat() on the other — so the
  pending session never materialized.
  Fix: Removed the version query string from the sessions.js import in
  app.js and from the modulepreload + script tags in index.html. All
  modules now share a single sessions.js instance.

Bonus guard: _adoptOpenedSessionBeforeAutoCreate() now checks
hasPendingChat() before adopting a stale DOM-active session, preventing
the send path from landing in the wrong session when a New Chat is pending.

---------

Co-authored-by: samy <samy@users.noreply.github.com>
2026-08-07 22:04:53 +02:00
Husam 99566d28b5 fix(chat): stop ArrowUp from eating an unsent multi-line prompt (#5875)
static/app.js carried a near-verbatim copy of the prompt-recall logic in
static/js/composerArrowUpRecall.js, wired as a second capture-phase
keydown listener on the same #message textarea. The copy omitted the
draft guard the module has: it called preventDefault() and
stopImmediatePropagation() unconditionally, then recalled history[0]
over whatever the user had typed.

Because it stopped immediate propagation, the copy won regardless of
registration order — if it ran first the module never saw the event, and
if it ran second the module had already declined to stop propagation on
an unmatched draft. The guard at composerArrowUpRecall.js:109 was
unreachable on the real page, so ArrowUp on a multi-line draft replaced
it with the last sent prompt instead of moving the caret up a line.

Delete the duplicate. The module keeps ownership of ArrowUp/ArrowDown
recall, which is the behavior MODULE_SUMMARY.md documents ("on an empty
composer") and the behavior tests/test_composer_arrow_up_recall_js.py
already pins via test_non_empty_composer_does_not_recall and
test_multiline_caret_navigation_preserved.

Also correct a stale comment in the module that described the deleted
behavior and contradicted the guard 35 lines above it, and add a
regression test asserting app.js does not reintroduce a second handler.

Fixes #5862
2026-08-07 19:34:50 +02:00
Husam f1e96d102e fix(tool_parsing): require a pipe on the Qwen bare end marker (#5829)
The `end` branch of _QWEN_BARE_MARKER_RE had both pipes optional
(`\|?end\|?`), so it also matched a bare `end` between whitespace and
replaced it with a space. Messages containing Ruby, Lua or shell code that
closes a block with a lone `end` had those lines deleted, and ordinary prose
lost the word too.

Require at least one pipe so only real turn markers match; `|end`, `end|`,
`|end|` and `/|end|` strip exactly as before. Applied to the duplicated
pattern in static/js/chatRenderer.js as well.

Fixes #5547
2026-08-07 19:33:14 +02:00
Jakub Grula 36d4098421 fix: Edit box formatting was removing triple tick boxes (#5737) 2026-08-07 19:15:50 +02:00
adabarbulescu 5ddef23d94 fix(welcome): rotate startup tips (#5871) 2026-08-07 19:12:21 +02:00
Mubelotix 45fc3938e0 Fixes Star History section in README
Fixes part of #5563
2026-08-06 19:18:56 +02:00
Ashvin c8a012d4d2 fix(memory): don't let an unreadable store get overwritten with an empty one (#5831)
* fix(memory): don't let an unreadable store get overwritten with an empty one

load_all() answered a failed read the same way it answered an empty store:
with []. Every mutation path is a read-modify-write (load the whole file,
change it, save it back), so a failed read became

    load_all() -> []  ->  [].append(new)  ->  save([new])

and save() is atomic, so the replacement stuck.

The case that actually destroys data is a store that is READABLE but not
parseable - a truncated file, or one holding {} instead of []. Nothing
obstructs the write, so adding a memory returns HTTP 200 and every memory
already stored is gone. Verified end-to-end against a running instance: on the
current code a truncated memory.json plus one add leaves the file holding only
the new entry. Truncation is reachable - core/database.py rewrites memory.json
during migration with a plain open(.., "w") + json.dump, which is not atomic.

A live exclusive lock is not the dangerous case: it blocks the read and the
os.replace alike, so the save fails too and the store survives. That path
currently 500s and loses nothing.

_read_entries() now returns [] only when the file genuinely does not exist and
raises MemoryStoreUnreadable for every other failure, including a store that
parses but is not a JSON array. load_all() keeps the old lenient behaviour so
display, search and context injection still degrade quietly instead of
breaking chat. The read-modify-write callers switch to load_all_for_update(),
which propagates the error: the memory routes turn it into a 503 and change
nothing, backup import refuses rather than saving only the incoming rows, and
auto-extraction and the audit merge skip the write. The audit merge mattered
most - it rebuilds the whole file from one owner's slice plus everyone else's
rows, so an empty read there dropped every other tenant's memories.

The corrupt-JSON path still gets its one shot at the legacy memory.txt
migration before raising, so that recovery is unchanged.

The two updated fakes gained load_all_for_update because the real class has it;
MagicMock would otherwise hand the import path a Mock instead of the seeded list.

Fixes #5673

* fix(memory): fail closed on the remaining read-modify-write add paths

The strict loader landed with the routes, the backup import and the extractor
converted, but three read-modify-write sinks still called load_all(), which
degrades an unreadable store to []. Two of them are the paths users actually
reach, so the data loss in #5673 stayed reproducible:

- src/ai_interaction.py do_manage_memory, action "add" — reached from ordinary
  chat via src/tool_execution.py:793 -> dispatch_ai_tool. "Remember that I
  prefer X" against an unreadable store wrote a one-entry file over it and
  reported success.
- mcp_servers/memory_server.py, action "add" — the same shape through
  _scope_entries(), registered as a built-in in src/builtin_mcp.py.
- src/memory_provider.py NativeMemoryProvider.remember and .delete — wired
  into app state in src/app_initializer.py but not consumed outside tests yet,
  converted here so the pattern is uniform before it goes live.

The MCP server takes _scope_entries(for_update=True) so list keeps the lenient
read. The edit and delete branches on both tool paths were already fail-closed
by accident — an empty view matches nothing and returns before the save — so
they are left alone.

The three new tests drive the real entry points rather than replaying the
shape, and use a truncated store, which is the case that reads back fine so
nothing stops the save. Each asserts memory.json is byte-identical afterwards;
all three fail on the previous commit with the store overwritten.
2026-08-06 02:33:50 -06:00
adabarbulescu 20e7fc0164 fix(skills): require manage_skills action (#5856) 2026-08-04 04:17:45 -06:00
Ashvin 9d686180dd fix(integrations): pin api_call to the SSRF-validated IP (#5727)
* fix(integrations): pin api_call to the SSRF-validated IP

execute_api_call runs check_outbound_url on the target, but that guard only
resolves the host to answer (ok, reason) and hands back no address. The request
right after it opened a plain httpx.AsyncClient, which resolves the host again at
connect time. A base_url host on a low TTL can pass the guard as a public IP and
then flip to 169.254.169.254 for the connect, so the call lands on cloud metadata
with the integration's stored auth headers attached.

Resolve once, remember the IPs the guard actually validated, and pin the client's
socket to that set through a small AnyIO-backed transport. SNI and the Host header
still come from the URL, so TLS and vhost routing are unchanged; connect-time
fallback stays inside the approved address set over one shared deadline. This is
the same pinning the webhook sender and web-fetch paths already do -- api_call was
the last outbound path that skipped it.

Fixes #5513

* fix(integrations): de-duplicate the pinned IP list

_default_resolver calls getaddrinfo(host, None) with no socktype filter, so
glibc returns one record per socktype and a single-homed host comes back three
times over. _validated_ips kept every entry, so the transport pinned the same
address repeatedly and the connect fallback could spend its shared deadline
retrying one dead address instead of moving on to a genuinely different one.

Windows getaddrinfo collapses those duplicate records, which is why the
ip-literal pin test only failed on CI and not locally.
2026-08-04 04:17:41 -06:00
Tal.Yuan bb719f217a refactor(routes): move document domain into routes/document/ subpackage (#5885)
Slice 2m of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves document_routes.py
(1810 lines) and document_helpers.py (243 lines) into routes/document/,
leaving backward-compat sys.modules shims at the old paths. Pure file
reorganization, no behavior change.

Both shims use sys.modules replacement so the `import ... as droutes` +
`droutes.SessionLocal = ...` / `monkeypatch.setattr(droutes, ...)` pattern
in multiple tests, and the `sys.modules.pop("routes.document_helpers")` +
re-import pattern in test_security_regressions.py, all reach the canonical
modules.

The canonical document_routes.py imports helpers from the canonical path
(routes.document.document_helpers), not the legacy shim.

Three source-introspection test sites repointed to the new canonical path:
- test_imap_mailbox_quoting.py
- test_model_helper_owner_scope.py
- test_vision_owner_scope.py (shared with other domains; document entry repointed)

Adds tests/test_document_routes_shim.py to pin the sys.modules shim contract
for both modules.

Verified: compileall clean; full suite 4789 passed, 3 skipped.
2026-08-04 03:54:55 -06:00
Tal.Yuan fb8c391a88 refactor(routes): move webhook domain into routes/webhook/ subpackage (#5781)
Slice 2l of the route-domain reorganization (#4082/#4071). Moves
webhook_routes.py into routes/webhook/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.
One source-introspection test repointed (test_api_chat_security.py).
2026-08-03 20:44:31 +02:00
Tal.Yuan 0de76c4056 refactor(routes): move vault domain into routes/vault/ subpackage (#5780)
Slice 2k of the route-domain reorganization (#4082/#4071). Moves
vault_routes.py into routes/vault/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.
2026-08-03 20:44:00 +02:00
RaresKeY 25c9e735ef fix(email): open settings after OAuth callback (#5803) 2026-07-30 14:57:07 +01:00
RaresKeY 28c333e647 fix(email): preserve OAuth SMTP security (#5802) 2026-07-30 12:24:39 +01:00
HusamandAlexandre Teixeira 84709a00d9 fix(llm): omit temperature for major-only Opus ids (claude-opus-5) (#5761)
The version pattern in _anthropic_rejects_temperature() required a minor
component, so major-only ids like `claude-opus-5` never matched and the
guard reported that the model accepts `temperature`. Anthropic rejects the
field outright on Opus 4.7+, so every such call returned HTTP 400 and the
stream aborted with zero tokens ("the model returned an empty response").

Make the minor optional and read a missing minor as `.0`. The major is also
capped at 1-2 digits with a no-trailing-digit lookahead, mirroring the
minor: once the minor is optional, a greedy major would swallow the date in
`claude-3-opus-20240229` and read it as version 20240229, dropping
temperature from a model that accepts it.

Fixes #5753

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-07-30 11:30:00 +01:00
Husam 578312200a fix(markdown): restore extracted blocks verbatim so $& and $$ survive (#5768)
The placeholder-restore pass in mdToHtml put code, math, mermaid and
allowed-HTML blocks back with a string replacement, so String.replace read
`$&`, `` $` ``, `$'` and `$$` in the *replacement* as substitution patterns.
A fenced block containing them rendered corrupted: `$&` re-inserted the
placeholder (`perl -pe 's/world/$& again/'` became
`s/world/___CODE_BLOCK_0___amp; again/`), `` $` `` and `$'` spliced in the
surrounding document, and `$$` collapsed to a single `$`.

Pass a function replacer at all four sites, matching the inline-code site
below them, which was already fixed this way. A function's return value is
inserted verbatim with no `$` interpretation.

The inline-code comment claimed `echo $1` would be read as a back-reference;
with a string search value there are no capture groups, so `$1` is already
literal. Reworded to name the four sequences that do corrupt.

Fixes #5663
2026-07-30 10:48:31 +01:00
HusamandAlexandre Teixeira f23221420f fix(skills): replace deprecated utcnow in skill timestamp helper (#5777)
* fix(skills): replace deprecated utcnow in skill timestamp helper

_now_iso() builds the 'created' value in skill frontmatter. datetime.utcnow()
returns a naive datetime and has been deprecated since Python 3.12, scheduled
for removal. Switch to the timezone-aware datetime.now(timezone.utc), keeping
the serialized YYYY-MM-DDTHH:MM:SSZ shape unchanged so existing skill files
keep parsing.

timezone.utc is used rather than the datetime.UTC alias, which is 3.11+ only.

Adds regression tests covering the deprecation, the serialized shape, and
UTC correctness under a non-UTC local timezone -- the last guards against a
bare datetime.now(), which yields the same shape but local wall time.

Fixes #5697

* test(skills): skip timezone mutation where unsupported

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-07-30 09:54:59 +01:00
holden093 6a84398e75 fix(skills): use utility model for skill tests instead of chat default (#5746)
Skill tests are background automation tasks (like auto-naming and
memory audit) and should use the configured utility model. Previously
they resolved via resolve_endpoint("default") which returned the
chat model, bypassing the utility model entirely.

This completes the sweep started in PR #4027 which fixed auto-naming
and memory audit but missed skill tests.
2026-07-30 09:06:31 +01:00
RaresKeY 3250a4ce68 fix(ci): clear review label when issues close (#5813)
The issue-close lifecycle change is narrowly scoped and correct. Closed issues remove the stale \`ready for review\` label and return before normal validation can restore it. Focused regressions cover closure and subsequent edits to a closed issue.

The branch was updated onto current \`dev\`. The focused test, merged-result validation, diff checks, and GitHub CI passed. No blocking review threads remain.
2026-07-29 22:04:28 +01:00
Boody cb0f6af002 Merge pull request #5822 from bitboody/tts_cache_fix
feat(tts): implement TTS cache size limit and eviction policy
2026-07-29 16:48:41 +03:00
Boody 9297bed5b9 add ODYSSEUS_TTS_CACHE_MAX_BYTES environment variable to docker-compose 2026-07-29 12:54:55 +03:00
Boody 2e631ad816 improve cache size calculation by filtering file types 2026-07-29 12:47:55 +03:00
Boody d183fe545b add test for cache eviction handling unlink errors gracefully 2026-07-29 12:42:20 +03:00
Boody 9914651cc9 improve cache eviction logic to handle file access errors and ensure stability 2026-07-29 12:41:31 +03:00
Boody 46905ab9b0 added ODYSSEUS_TTS_CACHE_MAX_BYTES env variable to docker compose files 2026-07-29 12:32:43 +03:00
Boody 61c138d9e7 fixed .env.example ODYSSEUS_TTS_CACHE_MAX_BYTES into correct 500 MBs 2026-07-29 12:26:09 +03:00
Tal.Yuan 25a4d134b1 refactor(routes): move search domain into routes/search/ subpackage (#5779)
Slice 2j of the route-domain reorganization (#4082/#4071). Moves
search_routes.py into routes/search/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.
2026-07-28 22:26:29 +02:00
Boody 98e4d8451b fix(tests): update environment variable for TTS cache limit to include ODYSSEUS prefix 2026-07-28 22:00:54 +03:00
Boody 5104a9a967 feat(tts): implement TTS cache size limit and eviction policy 2026-07-28 21:34:03 +03:00
RaresKeY 01790c2f08 fix(mcp): keep built-in servers on SDK v1 (#5820) 2026-07-28 18:11:34 +01:00
Dividesbyzer0 d96c7af3df fix(agent): import Any for tool event helper (#5735) 2026-07-27 17:29:29 +02:00
pewdiepie-archdaemon d8a2059df8 Merge verified Odysseus fixes 2026-07-23 14:49:02 +00:00
Joeseph Grey 4c9a8ca115 fix(rag): skip hidden and junk directories when indexing (#5633)
* fix(rag): skip hidden and junk directories when indexing (#5559)

index_personal_documents walked the whole tree with no pruning, so
pointing RAG at a real-world folder silently swept in .obsidian/ plugin
JS, .git/ internals, node_modules/, and __pycache__/ — multiplying
indexing time and polluting retrieval with junk chunks.

Prune hidden directories and well-known junk directories from the walk,
and skip hidden files. The explicitly passed root is exempt, so a user
who deliberately indexes a hidden directory still gets its contents.

* fix(rag): prune hidden/junk dirs in the keyword index too, via a shared helper

The #5559 fix pruned only VectorRAG.index_personal_documents (the vector index).
The parallel keyword index built by PersonalDocsManager.refresh_index ->
load_personal_index walked the same tree unpruned, so .obsidian/, .git/,
node_modules/ etc. still swept into keyword retrieval and the file listing —
the 'end-to-end' guarantee was only half true.

Single-source the pruning policy in src/index_walk (prune_index_dirs +
is_indexable_file) and use it from both walkers so they cannot drift again.
The junk-dir match is now case-insensitive, so a Node_Modules on a
case-insensitive filesystem is pruned too.

Tests: keyword-path regressions covering hidden/junk dirs, hidden files, junk
at depth (not just top level), case-insensitive junk, and the explicit-hidden-
root exemption. The existing vector tests still pass against the shared helper.
2026-07-23 14:18:08 +02:00
RaresKeY d49629fa14 fix(reminders): support OAuth SMTP accounts (#5649) 2026-07-22 16:03:35 +02:00
65987fc772 fix(email): test saved OAuth accounts through shared transports (#5653)
* fix(email): use XOAUTH2 in test-connection for Google OAuth accounts

The test-connection endpoint was password-only and had no awareness of
OAuth accounts. For Google-connected accounts this caused two failures:
- IMAP: "Need IMAP host, username, and password" because imap_pass is
  empty (no password is stored for OAuth accounts)
- SMTP: 535 BadCredentials because smtp.login() was called with an
  empty password instead of an XOAUTH2 token

Fix: include oauth_provider and token fields in saved_body when hydrating
from the DB, then use conn.authenticate("XOAUTH2") / smtp.auth("XOAUTH2")
for Google accounts in both the IMAP and SMTP test paths, mirroring what
_send_smtp_message already does for real sends.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(email): add OAuth2 tests for test-connection endpoint

Covers the XOAUTH2 changes made to routes/email_routes.py:
- Google OAuth accounts must not be rejected with 'Need IMAP host,
  username, and password' (no stored password for OAuth accounts)
- IMAP and SMTP test paths must use conn.authenticate('XOAUTH2')
  for Google accounts
- Password accounts must still use conn.login() / smtp.login()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(email): bind OAuth account tests to Google transport

---------

Co-authored-by: TNTBA <trynottobreakanything@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:54:38 +02:00
dcc7e52a86 fix(config): forward Google email OAuth settings through Compose (#5650)
* fix(config): forward Google OAuth env vars into Docker container and document setup

GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, and GOOGLE_OAUTH_REDIRECT_URI
were read by the app but never forwarded through docker-compose.yml's explicit
environment allowlist, causing the "not set" error even when the vars existed in .env.

Also adds a documented section to .env.example with step-by-step GCP setup instructions
so users know where to get the credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(config): cover OAuth in standalone compose files

* test(config): parse OAuth compose service env

* test(config): keep checkout skip wording neutral

---------

Co-authored-by: TNTBA <trynottobreakanything@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 12:59:22 +02:00
RaresKeY 93f97e74cd fix(email): hide OAuth tokens from config response (#5651) 2026-07-22 12:58:16 +02:00
RaresKeY 16b04a9792 fix(email): verify mailbox identity on OAuth reconnect (#5648) 2026-07-22 12:56:17 +02:00
nopozandRaresKeY b07c1e3b33 ci: add CodeQL advanced setup to scan pull requests before merge (#5250)
* ci: add CodeQL advanced setup to scan pull requests before merge

* ci(codeql): preserve scheduled scans

Add a weekly advanced-setup scan and update the security CI guide so default setup remains disabled.

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-07-21 10:18:06 -07:00
Boody fe0748308e Merge pull request #5286 from tse-jeff/fix/snap-wsl2-gpu-passthrough
fix(docker): detect snap+WSL2 GPU passthrough incompatibility
2026-07-21 13:57:32 +03:00
Tal.Yuan 483c42bb12 refactor(routes): move compare domain into routes/compare/ subpackage (#5660)
Slice 2i of the route-domain reorganization (#4082/#4071). Moves
compare_routes.py into routes/compare/, leaving a backward-compat
sys.modules shim at the old path. Pure file reorganization, no behavior
change.

The shim uses sys.modules replacement so the `import ... as cr` +
`monkeypatch.setattr(cr, "SessionLocal", ...)` / `"_owned_endpoint_by_url"`
/ `"_owned_endpoint_by_id"` pattern in test_endpoint_owner_scope_followup.py
reaches the canonical module.

Canonical module imports only from core/, src/, and routes.session_routes
(zero dependency on the legacy shim). One source-introspection test site
repointed: test_endpoint_owner_scope_followup.py (shared with other domains;
only the compare entry repointed here).

Adds tests/test_compare_routes_shim.py to pin the sys.modules shim
contract. Verified: compileall clean; targeted tests pass.
2026-07-21 12:40:09 +02:00
Tal.Yuan 833cdbd140 refactor(routes): move admin_wipe domain into routes/admin_wipe/ subpackage (#5659)
Slice 2h of the route-domain reorganization (#4082/#4071). Moves
admin_wipe_routes.py into routes/admin_wipe/, leaving a backward-compat
sys.modules shim at the old path. Pure file reorganization, no behavior
change.

The shim uses sys.modules replacement so the `import ... as
admin_wipe_routes` + `monkeypatch.setattr(admin_wipe_routes, "SessionLocal",
...)` / `"require_admin"` pattern in test_admin_wipe_gallery.py reaches
the canonical module.

Canonical module imports only from core/, src/, and stdlib (zero internal
routes/ coupling). Zero source-introspection landmines.

Adds tests/test_admin_wipe_routes_shim.py to pin the sys.modules shim
contract. Verified: compileall clean; targeted tests pass.
2026-07-21 12:39:27 +02:00
Tal.Yuan b7d3f2a28d refactor(routes): move cleanup domain into routes/cleanup/ subpackage (#5658)
Slice 2g of the route-domain reorganization (#4082/#4071). Moves
cleanup_routes.py into routes/cleanup/, leaving a backward-compat
sys.modules shim at the old path. Pure file reorganization, no behavior
change.

The shim uses sys.modules replacement so string-targeted
monkeypatch.setattr("routes.cleanup_routes.*", ...) in
test_cleanup_owner_scope.py reaches the canonical module.

Canonical module imports only from src/ and stdlib (zero internal
routes/ coupling). Zero source-introspection landmines.

Adds tests/test_cleanup_routes_shim.py to pin the sys.modules shim
contract. Verified: compileall clean; targeted tests pass.
2026-07-21 12:38:32 +02:00
RaresKeY cc4c7f4263 chore: update repository URLs after organization transfer (#5622) 2026-07-20 16:43:47 +02:00
Tal.Yuan 88e0ce3037 refactor(routes): move note domain into routes/note/ subpackage (#5236)
Slice 2f of the route-domain reorganization (#4082/#4071, per
specs/architecture-runtime-inventory.md §6.3). Moves note_routes.py into
routes/note/, leaving a backward-compat sys.modules shim at the old path.
Pure file reorganization, no behavior change.

The shim uses sys.modules replacement (same pattern as the merged gallery
#4903, research #4975, memory #5007, history #5090, and contacts #5227
slices) so that `import routes.note_routes`, `from routes.note_routes import
X`, `importlib.import_module(...)`, and the `import ... as note_routes` +
`monkeypatch.setattr(note_routes, "SessionLocal", ...)` pattern used by
test_note_reminder_fire_scope.py / test_notes_fail_closed_auth.py all
operate on the same module object the application uses.

The canonical module does NOT depend on the shim — routes/note/note_routes.py
imports only from core/, src/, and stdlib. The outbound email cross-domain
imports (routes.email_routes._get_email_config, routes.email_helpers.
_send_smtp_message) are function-local lazy imports that keep resolving
through the email module's own path (email is not yet migrated).

One source-introspection test site repointed to the new canonical path:
- test_model_helper_owner_scope.py (shared with history; history entry
  already repointed in #5090, note entry repointed here)

Adds tests/test_note_routes_shim.py to pin the sys.modules shim contract
(legacy and canonical paths resolve to the same module object; monkeypatch
via legacy alias reaches the canonical module).

Verified: compileall clean; full suite 4487 passed, 3 skipped.
2026-07-20 13:52:30 +02:00
Afonso Coutinho 1aad1db9f6 fix: services research source extraction crashes on a non-dict finding (#1868) 2026-07-20 09:39:16 +02:00
Abhishek Kumbhar d05900cb90 fix(llm): enhance fallback logic to handle empty completions and impr… (#5491)
* fix(llm): enhance fallback logic to handle empty completions and improve metadata handling

* fix(llm): stream tool call deltas immediately
2026-07-18 22:06:14 +01:00
Joeseph Grey b3f8b77317 fix(url-safety): reject RFC 6598 shared address space in strict mode (#5474)
* security(url-safety): reject RFC 6598 shared address space in strict mode

Strict mode (block_private=True) is a full SSRF lockdown, but it only
rejected is_private and is_loopback targets. CPython does not classify RFC
6598 shared/CGNAT space (100.64.0.0/10) as is_private (it is "shared", not
"private"), so a public redirect into 100.64.0.1 passed the per-hop guard
and still issued the request to a potentially internal CGNAT service.

not is_global would also exclude it, but only on CPython 3.11.10+/3.12.4+/
3.13+; the CI matrix runs 3.11/3.12, so reject the range explicitly to stay
correct across patch levels and the 3.14 runtime image. Default local-first
mode is unchanged. Adds strict-mode coverage for shared, non-global, and
public targets.

* docs(url-safety): correct CGNAT is_global rationale in strict-mode comment

The prior comment claimed `not is_global` catches 100.64.0.0/10 only on
CPython 3.11.10+/3.12.4+/3.13+. That is inaccurate for CGNAT: is_global
is False for 100.64.0.1 on every supported version (verified 3.10-3.14).
The version-fragility applies to other ranges gh-113171 touched, not CGNAT.
The explicit range reject is still the right choice; restate the reason as
is_private not covering shared space, and not coupling strict mode to
is_global's broader, cross-version definition. No behavior change.
2026-07-18 12:36:27 -06:00
RaresKeY 23ac3e3e82 chore(release): bump dev version to 1.0.2 (#5473) 2026-07-18 17:06:16 +01:00
RaresKeY 5f481e7db1 test(cookbook): cover adopt remote host validation (#5225) 2026-07-18 12:07:48 +01:00
RaresKeY 0d47b78f47 test(email): cover agent draft owner isolation (#5226) 2026-07-18 11:30:28 +01:00
RaresKeY b9cafd67a1 feat(models): define capability schema and readers (#2739)
* feat(models): define capability schema and readers

* fix(models): harden Google catalog probing

Restrict native catalog probing to the Gemini host, keep provider keys out of request URLs, filter non-chat model resources, and preserve the manual refresh default in the built-in Google add flow.
2026-07-18 09:40:58 +01:00
Boody b4e5ad088a Merge pull request #5580 from abandonrule/main
fix(docker): bump Docker CLI to a patched release
2026-07-18 05:13:03 +03:00
Chris Mayfield 4239bc850b Merge pull request #3 from abandonrule/fix-docker-cli-cves
fix(docker): bump Docker CLI to a patched release
2026-07-17 17:23:24 -05:00
Christopher Mayfield 0f259ea5f6 fix(docker): bump Docker CLI to a patched release 2026-07-17 16:28:46 -05:00
Christopher Mayfield 27ecd41cde Merge branch 'odysseus-dev:main' into main 2026-07-17 16:22:02 -05:00
Christopher Mayfield 3718e61c40 Merge pull request #2 from abandonrule/sync/upstream-20260717-odysseus
chore: sync upstream changes from odysseus-dev/odysseus
2026-07-17 16:18:04 -05:00
Keshav Jindal a57dd37005 docs(setup): document Arch NVIDIA Docker GPU setup
Add Arch-specific package installation and NVIDIA runtime configuration
to the Docker setup guide. Cover passthrough verification, the NVIDIA
Compose overlay, and the distinction between GPU passthrough and
CUDA-backed model serving

Refs #831
2026-07-16 13:06:42 +02:00
Emir ÇobanandClaude Opus 4.8 45b6771e73 fix(skills): block private SSRF targets and revalidate redirects in importer (#5261)
* Harden skill importer against SSRF: block private targets + revalidate redirects per hop

The skill importer validated only the initial URL with the lenient SSRF guard
(block_private=False) and then fetched with follow_redirects=True, so a 3xx to
an internal/metadata address (169.254.169.254, 127.0.0.1, RFC-1918) was still
connected to — inconsistent with the hardened services/search/content.py
:_get_public_url path.

Add a _get_checked() helper that follows redirects manually and re-runs the
SSRF guard with block_private=True on every hop, and route all three fetch
sites (skills.sh unwrap, _fetch_bytes, _list_github_dir) through it. GitHub's
own redirects and the final-host _assert_github_url checks are preserved.

Adds hermetic regression tests (IP-literal hosts, faked HTTP layer) and updates
the existing mock signature for the new block_private kwarg.

Defense-in-depth: the endpoint is admin-gated (require_admin) and admins are
trusted per THREAT_MODEL.md, so this is not a cross-boundary vulnerability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: enforce follow_redirects=False invariant in mock client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:33:10 -06:00
Boody 98bcb64192 fix(mcp_manager): remove timeout from MCP connection attempts and handle registration cleanup 2026-07-13 08:56:46 +02:00
Boody 0d1d28c7d6 fix(mcp_manager): implement concurrent server connections with timeout handling 2026-07-13 08:56:46 +02:00
c7b3b84f24 fix(db): restrict data/app.db to 0600 (#4420)
* fix(db): restrict data/app.db to 0600

app.db holds bearer-token hashes, bcrypt password hashes, and encrypted
provider keys but was created under the default umask (0644 -> world-readable),
unlike .app_key/vault/integrations which are already 0600 via safe_chmod.

init_db() now chmods the SQLite file to 0600 right after create_all (POSIX
only; no-op on Windows, skipped for Postgres / in-memory). Unconditional and
idempotent, so it also re-locks already-deployed 0644 installs on next
startup. The transient rollback journal inherits 0600 from the parent file at
creation - no sidecar handling needed; -wal/-shm don't exist until WAL is
enabled (#4409 C4) and inherit the same mode then.

Satisfies Rule B, unblocking #4413 and the vault/integration secret moves.
Mirrors src/secret_storage.py:43-45.

Verified: security + DB-permission suites pass; 6 pre-existing visual_report
failures (missing markdown/nh3 deps) are unrelated.

Closes #4407

* fix(db): harden SQLite path parsing and re-lock sidecars

Address review feedback on #4420.

P2: derive the file to chmod from engine.url (SQLAlchemy's parsed URL)
via _sqlite_db_path(), instead of DATABASE_URL.replace("sqlite:///", "").
A driver-qualified URL (sqlite+pysqlite://) or one carrying query args
(?cache=shared) previously slipped past the prefix check / string slice
and left the DB world-readable; the parsed path resolves correctly and
drops the query.

P3: re-lock stale -wal/-shm/-journal sidecars to 0o600 at startup. The
main file is chmod'd first, so any sidecar SQLite creates afterward
inherits 0o600, but a -wal/-shm left world-readable by an older 0o644
install (once WAL was enabled) could still expose DB pages. Absent
sidecars are the normal case, not an error.

Tests: unit-test _sqlite_db_path across driver/query/memory/postgres URL
forms, and a subprocess test asserting stale 0o644 -wal/-shm are
re-locked on startup.

* fix(db): handle sqlite file URI app db permissions

* fix(db): close remaining SQLite permission bypasses

---------

Co-authored-by: Ethan <23321960+0xLeathery@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-07-11 21:15:49 +02:00
Astarte bff38a4406 fix(cleanup): update MODULE_SUMMARY and remove dead MEMORY_DOC paths (#4411) (#5160)
* docs: update static/js/MODULE_SUMMARY.md to reflect current ES6 frontend

Rewrite the stale module summary to match the current no-build,
ES6-module frontend architecture. Adds coverage of app.js orchestration,
the chat/SSE pipeline (chat.js, chatStream.js, chatRenderer.js,
streamingRenderer.js), new subsystems (research/, compare/, document
streaming, cookbook*, skills.js), and removes the obsolete <script> load
order assumptions.

* cleanup: remove dead MEMORY_DOC / memory_doc paths (closes #4411)

Removes the unused MEMORY_DOC constant and the matching DataConfig
memory_doc field / set_data_paths entry. No runtime code imports or
references these paths, so this is a no-behavior-change dead-code
cleanup under the storage-architecture tracker #4377.
2026-07-11 17:06:19 +01:00
falabellamichael c2d2075833 fix(stabilization): harden attachment lifecycle and agent guard signals (#5420)
* fix: harden stabilization attachment and agent guards

* fix(uploads): preserve durable references during cleanup

* fix(uploads): close cleanup and compaction races
2026-07-11 15:14:14 +01:00
RaresKeY a02f8d8600 fix(llm): avoid blocking Kimi Code async header probes (#5231) 2026-07-11 15:06:15 +01:00
mashallow d02565ce32 fix(markdown): stop currency dollars rendering as KaTeX inline math (#5132) 2026-07-11 14:45:57 +01:00
RaresKeY 524fa9dce2 fix: preserve pythonpath for built-in mcp servers (#5117) 2026-07-11 14:34:42 +01:00
RaresKeY b3432873fb fix(email): clear bulk selection on context change (#5229) 2026-07-11 14:12:12 +01:00
Peter Karlsson 1c61c358cb fix(email): use UID commands instead of sequence numbers in IMAP fetches (#5149)
conn.search() / conn.fetch() operate on volatile positional sequence
numbers that shift whenever messages are deleted or expunged. Three call
sites in the sig-learner (_pull_headers, _fetch_bodies) and morning-brief
email section were storing these as "uid" and reusing them in subsequent
fetches — causing wrong-message returns or NO responses if another client
modified the mailbox concurrently.

Replaced with conn.uid("SEARCH", ...) / conn.uid("FETCH", ...), which use
persistent RFC 3501 UIDs. _scan_one (urgency action) already did this
correctly; these were the remaining callers.

The reproduction window is narrow (requires concurrent deletion between
search and fetch), so the fix is verified by regression tests rather than
manual end-to-end: _SpyImap raises AssertionError if conn.search() or
conn.fetch() are called instead of conn.uid().
2026-07-11 14:06:40 +01:00
DL Techy e5ef8cf4bf fix(chat): Expand user chat bubble edit textbox width (#3963)
* fix(chat): Expand user chat bubble edit textbox width

- Update user chat bubble width from `fit-content` to `85%` to ensure consistency with the AI chat bubble edit textbox width.

* style(chat): Refine user message bubble width logic

- Change general bubble width to `fit-content`
- Set width to 85% specifically for user messages containing a `textarea`
2026-07-11 13:52:14 +01:00
jagadish-zentiti 410ad9a2fa fix(agent): cancel orphaned tool task when SSE client disconnects mid-call (#5106)
stream_agent_loop's per-tool drain loop had no cleanup path for early
generator close. Starlette throws GeneratorExit into the generator at
whatever await point it's suspended on when the SSE client disconnects
(aclose()) - here that's 'await _progress_q.get()' inside the drain
loop, before the final 'await _tool_task' line ever runs. The task,
which wraps execute_tool_block, was left running unawaited and
uncancelled.

For bash/python tools this orphans the underlying subprocess:
subprocess_tools.py already has correct CancelledError handling that
kills the child process, but only runs if the task is actually
cancelled. A client disconnecting mid long-running command left that
subprocess running server-side for its full duration with nothing
left to reap it.

Wrap the drain loop in try/finally: on early exit, cancel _tool_task
(if not already done) and await it so the existing subprocess-kill
path runs.

Adds a regression test that drives the real stream_agent_loop with a
fake tool handler, closes the generator mid tool-call (mirroring what
Starlette does on disconnect), and asserts the handler observed
cancellation immediately - not merely via asyncio.run()'s own
end-of-run task cleanup, which would mask the bug.

Fixes #5105
2026-07-11 13:44:06 +01:00
c0b032fa10 fix(calendar): trust operator CA bundle in CalDAV test_connection (#4796)
* fix(calendar): trust operator CA bundle in CalDAV test_connection

The pre-flight test used httpx with trust_env=False, which ignored
SSL_CERT_FILE/REQUESTS_CA_BUNDLE. Self-signed CalDAV servers that
the real sync accepts (via caldav lib → requests → honors bundle)
were rejected by the test with CERTIFICATE_VERIFY_FAILED.

Build an explicit SSL context that loads the operator's CA bundle
and clears VERIFY_X509_STRICT (which rejects certs without a
keyUsage extension — common in self-signed setups). SSRF guards
(follow_redirects=False, trust_env=False) are preserved.

Fixes #4795
Fixes #4779

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(calendar): add regression tests and edge case handling for SSL context

Per review: add route-level regression tests covering SSL_CERT_FILE
precedence, VERIFY_X509_STRICT clearing, missing bundle graceful
fallback, and empty env var handling. Also log a warning when the
configured CA bundle path doesn't exist instead of silently falling
back to system CAs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(calendar): rewrite SSL tests to exercise route handler directly

Addresses review feedback: tests now use FastAPI TestClient to hit the
actual test_connection route, capturing the verify= kwarg passed to
httpx.AsyncClient. This ensures the route's SSL context construction
is covered, not a test-side duplicate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: retrigger CI (redirect hardening test is a CI-env flake, passes locally)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): remove module-level sys.modules stubs that leaked into other tests

The collection-time MagicMock stub of `caldav` replaced the real library
for every later test in the same process — test_caldav_redirect_hardening's
DAVClient became a mock that never sent the PROPFIND, failing its
must-reach-the-public-server assertion in CI. conftest already pre-imports
the real sqlalchemy/core.database, and the route's lazy imports are patched
per-request, so the stub block was both harmful and unnecessary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(calendar): verify exact CA bundle precedence

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-07-11 13:25:16 +01:00
tanmayraut45 c161866199 CalDAV: close the DAVClient on sync and write-back paths (#4793)
_sync_blocking (src/caldav_sync.py) and _writeback_blocking
(src/caldav_writeback.py) each open their own caldav.DAVClient via
_build_dav_client, but never close it. The client owns an HTTP session
with a pooled connection; without a close() that connection is held until
process exit.

Previously the fix added explicit client.close() calls before each early
return and at the end of the DB finally block. This still leaked the
client when SessionLocal() raised before the DB try/finally was entered.

Now _sync_blocking wraps the entire post-construction path in an outer
try/finally that calls client.close() unconditionally, covering:
  - AuthorizationError / NotFoundError early return
  - URL-fallback failure early return
  - no-calendars early return
  - normal return after sync
  - SessionLocal() construction failure (new regression coverage)

_writeback_blocking already used a try/finally (unchanged).

- src/caldav_sync.py: replace scattered client.close() calls with a
  single outer try/finally block around the discovery + DB sync path
- tests/test_caldav_client_cleanup.py: add CalendarDeletedEvent to the
  database stub; add regression test for SessionLocal() failure path

Closes #4593
2026-07-11 13:03:24 +01:00
red person 6725d1863c Skip vanished backup list entries (#2006) 2026-07-11 05:26:42 +01:00
jagadish-zentiti 99f0facc2c fix(mcp): guard DbTokenStorage against non-dict oauth_tokens JSON (#5107)
_load() returned whatever json.loads() produced without checking it was a
dict; _update() did the same before assigning data[key] = value. If the
oauth_tokens column ever held a JSON array or primitive (DB corruption,
manual edit, migration drift), _load()'s callers crashed with
AttributeError on .get(), and _update() crashed with TypeError trying to
item-assign into a list/string/int.

Validate the parsed value is a dict in both methods, falling back to {}
otherwise - same recovery behavior already used elsewhere in the codebase
for this exact JSON-blob-is-not-a-dict shape (_parse_tool_args,
_is_sensitive_path's siblings).

Adds 3 regression tests for _load, get_tokens, and _update against a
non-dict oauth_tokens value.

Fixes #5082
2026-07-11 05:26:23 +01:00
jagadish-zentiti 7a8f47e4ab fix(email): atomically claim scheduled emails before sending (#5110)
_scheduled_poll_once selected rows WHERE status='pending' and only wrote
status='sent'/'failed' after the SMTP send and IMAP append completed -
no atomic claim in between. Two overlapping callers (the in-process 30s
poller and an externally cron/systemd-driven 'odysseus-mail
poll-scheduled', or the CLI run manually) can both SELECT the same
pending row before either UPDATEs it, and both send it. _start_poller's
own docstring already names this exact risk ('avoid two copies of
_scheduled_poll_once racing on the same SQLite') but nothing in the code
enforced it - it was advisory only.

Add an atomic per-row claim: UPDATE ... SET status='sending' WHERE
id=? AND status='pending', proceeding only when rowcount == 1. The
loser of the race sees rowcount == 0 and skips the row instead of
sending a duplicate.

Adds a regression test that drives two real threads through the real
_scheduled_poll_once against a shared SQLite file, synchronized with a
barrier and a widened send-path window, and asserts exactly one send
fires. Reverting the fix makes the test fail reliably (5/5 runs); with
the fix it passes reliably (5/5 runs).

Fixes #5109
2026-07-11 04:23:36 +01:00
L1 a6c457f74e fix(email): never fall back to sequence-number IMAP ops for move/flag (#2732)
_store_email_flag and _move_email_message (used by the archive / delete / move /
mark-read endpoints) had an else branch that, when _uid_exists returned False,
ran conn.store(uid, ...) / conn.copy(uid, ...) followed by a folder-wide
conn.expunge(). But imaplib's plain store()/copy() take a message SEQUENCE
NUMBER, not a UID, so the op landed on whichever message occupied sequence
position == the UID value, and the expunge then permanently removed it. A stale
cached UID (or a server whose UID probe misbehaves) therefore deleted an
unrelated email instead of reporting 'not found'.

There is no valid case where treating a UID as a sequence number is correct, so
drop the fallback: when the UID isn't present, return False — callers already
surface 'Email not found'. Only the UID command path remains.

Sibling of #1874 (which fixes the auto-spam poller's _imap_move in
email_helpers.py); this covers the user-facing endpoints in email_routes.py.
Part of #2124.
2026-07-11 03:27:28 +01:00
Afonso Coutinho a4e66bb59f fix: TTS available crashes on non-string tts_provider (#2034) 2026-07-11 03:19:51 +01:00
Afonso Coutinho a6efea5486 fix: _matchesCombo crashes on non-string keybind from server (#2049) 2026-07-11 03:15:19 +01:00
Afonso Coutinho 565c69f40d fix: odysseus-memory cmd_add crashes on non-dict existing memory row (#2091) 2026-07-11 03:05:17 +01:00
Afonso Coutinho 06e038f00e fix: hwfit params_b/is_prequantized crash on non-string catalog fields (#2094) 2026-07-11 03:00:28 +01:00
Ashvin 0cb8db4de4 fix(tasks): scope manage_tasks mutations to an exact task owner (#5264)
The edit/delete/pause/run actions of do_manage_tasks gated ownership with
`if owner and task.owner and task.owner != owner`. The middle term made the
check a no-op whenever task.owner was null/empty — the state a scheduled task
sits in when it was created in no-login mode (or via the localhost middleware
bypass) before the periodic legacy-owner sweep reassigns it to the admin user.
Any authenticated user's agent could then edit, delete, pause, or run another
tenant's owner-less task; edit+run lets an attacker rewrite the task prompt and
execute it in the scheduler's agent context.

The sibling `list` action already scopes with an exact `owner == owner` filter,
so the mutators were strictly more permissive than the reader. Drop the middle
term so the guard fails closed on owner-less rows for authenticated callers,
matching `list` and the calendar/notes/gallery/session null-owner gates. Auth
disabled (owner falsy) and same-owner access are unchanged.
2026-07-11 01:45:14 +01:00
851bf4d0c8 fix(reminders): sanitize ntfy Title header to ASCII (#5208)
* fix(reminders): sanitize ntfy Title header to ASCII

The ntfy notification Title header was set directly from the note title.
HTTP headers must be ASCII, so a title containing emoji or other
non-ASCII characters caused httpx to raise UnicodeEncodeError, which
was swallowed by the surrounding try/except — so the reminder silently
failed and no notification was ever sent.

Sanitize the title with encode('ascii', 'replace') before placing it
into the header, replacing unsupported characters with '?'. This is
standard practice for HTTP header values. The note body is unaffected
(it is sent as request content, not a header) and continues to support
full UTF-8.

* fix(reminders): also truncate ntfy title to 200 chars for header safety

* style: compact ntfy header comment

---------

Co-authored-by: Am-GJ <Am-GJ@users.noreply.github.com>
Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-07-10 22:50:00 +02:00
Wes HuberandClaude Opus 4.6 a0a24058bb docs: remove completed troubleshooting cookbook task from ROADMAP (#4906)
The self-host troubleshooting cookbook has been implemented in
docs/setup.md under "Common self-host traps" (PR #4834).

Fixes #4900

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 22:29:20 +02:00
Boody 582a51054d Merge pull request #5313 from RaresKeY/fix/chat-web-search-explicit-deny
fix(chat): require explicit web search enable
2026-07-09 01:56:32 +03:00
Wes HuberandClaude Fable 5 21c8053505 fix(copilot): guard request_flags against a non-dict last message (#5274)
request_flags derives (agent, vision) and does last.get("role") after only
a truthy check. A client can send a bare-string message element
("messages": ["hi"]), and the vision loop right below already guards each
element with isinstance — so the .get() on a non-dict last element is an
oversight that raises AttributeError on every Copilot-proxied request with
such a body.

Use isinstance(last, dict) to match the loop's own guard.

Fixes #5273

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:57:23 +02:00
RaresKeY 109301be78 docs(docker): polish WSL2 snap GPU guidance 2026-07-08 19:30:41 +00:00
Miraç Duran 93c2501a6d fix(chat): give extensionless image/audio uploads a valid MIME subtype (#5205)
build_user_content derived the data-URL subtype from the file extension
only (image_format = ext[1:]). An extensionless upload (e.g. a pasted
screenshot) has ext == "", producing "data:image/;base64,..." with an
empty subtype (invalid per RFC 2046) that vision/audio endpoints reject,
silently dropping the attachment. Fall back to the resolved MIME subtype
when the extension is missing; present extensions are unchanged.
2026-07-08 21:04:15 +02:00
RaresKeY dadf178ed5 fix(chat): require explicit web search enable 2026-07-08 17:44:28 +00:00
Steve HollowayandClaude Opus 4.8 30e87e3b82 fix(chat): restore missing _explicit_web_intent definition (#5290)
chat_stream() references `_explicit_web_intent` in three places
(disabled-tools gating, global-disabled web allowance, and the
per-turn tool filter) but the assignment was dropped during a
branch merge. Every chat request raised

    NameError: name '_explicit_web_intent' is not defined

at routes/chat_routes.py, surfacing to the client as a bare
"Internal Server Error" before any LLM call was made — chat was
fully broken on dev and main.

Restore the original definition, computed from the already-derived
tool intent, immediately before its first use:

    _explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:14:43 +02:00
Jeffery Tse db1170e63a fix(docker): detect snap+WSL2 GPU passthrough incompatibility, document fix 2026-07-07 01:02:55 -04:00
PewDiePie d9f6341f01 Merge pull request #5283 from pewdiepie-archdaemon/sync-main-into-dev-20260707
chore: sync tested main into dev
2026-07-07 11:32:53 +09:00
612 changed files with 90455 additions and 14354 deletions
+2
View File
@@ -30,6 +30,8 @@ secrets.env~
.idea/ .idea/
dev-docs/ dev-docs/
docs/ docs/
website/
assets/branding/
*.md *.md
*.db *.db
*.sqlite *.sqlite
+51 -2
View File
@@ -76,12 +76,24 @@ SEARXNG_INSTANCE=http://localhost:8080
# Change this if another local service already uses 7000 (macOS AirPlay often does). # Change this if another local service already uses 7000 (macOS AirPlay often does).
# APP_PORT=7000 # APP_PORT=7000
# Optional HTTP address advertised in companion/mobile pairing codes. Set this
# when Docker would otherwise advertise a container address or loopback. Use a
# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local
# name that the phone can reach. HTTPS and public hostnames are not supported
# by the current companion client. Do not include credentials, a path, query,
# or fragment.
# COMPANION_BASE_URL=http://192.168.1.50:7000
# Development-only auth bypass for loopback requests. # Development-only auth bypass for loopback requests.
# Keep false for Docker, LAN, reverse proxy, and any shared deployment. # Keep false for Docker, LAN, reverse proxy, and any shared deployment.
# LOCALHOST_BYPASS=false # LOCALHOST_BYPASS=false
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS # Mark session cookies Secure. Left unset, this follows the request scheme:
# by a trusted reverse proxy or private access gateway. # an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
# force it on, or false to force it off while you still serve plain HTTP.
# Upgrading: this used to default to false. Drop a leftover SECURE_COOKIES=false
# from your .env unless you still need that escape hatch — it keeps HTTPS logins
# on a non-Secure cookie.
# SECURE_COOKIES=true # SECURE_COOKIES=true
# Optional: pre-seed the first admin password during setup. # Optional: pre-seed the first admin password during setup.
@@ -130,6 +142,42 @@ SEARXNG_INSTANCE=http://localhost:8080
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2 # FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed # FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
# ============================================================
# Google OAuth2 (Google Workspace / .edu email accounts)
# ============================================================
# Required to use the "Connect with Google" OAuth flow in email account setup.
# Create credentials at: console.cloud.google.com → APIs & Services → Credentials
# 1. Enable the Gmail API for your project.
# 2. Configure the OAuth consent screen (User Type: Internal for Workspace orgs).
# Add scopes: https://mail.google.com/ and email.
# 3. Create an OAuth 2.0 Client ID (type: Web application).
# Add your redirect URI: http://localhost:7000/api/email/oauth/google/callback
# (replace host/port for hosted installs).
# 4. Copy the Client ID and Client Secret below.
#
# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
# GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret
#
# Set this explicitly for HTTPS, reverse-proxy, or hosted deployments. The
# value must exactly match an authorized redirect URI in the Google client.
# Local HTTP setups may use the callback URL inferred by the application.
# GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback
# Origin the MCP OAuth callback is sent back to, for remote (Streamable HTTP)
# MCP servers that register it dynamically. Defaults to http://localhost:$APP_PORT,
# which is right only when you reach Odysseus directly on that port. Set it for
# HTTPS, reverse-proxy, hosted, and Docker installs — inside the container the
# app always listens on 7000 and cannot see the host port map, so the default is
# wrong there whenever APP_PORT is not 7000.
#
# Not for Google MCP servers. Those use Desktop App credentials, and Google only
# accepts loopback redirect URIs for that client type, so a public origin here is
# rejected with redirect_uri_mismatch. Leave it unset for a Google-only install:
# the loopback default is what Google wants, and remote users finish through the
# paste-back page, which never has to load the redirect.
# https://developers.google.com/identity/protocols/oauth2/native-app
# OAUTH_REDIRECT_BASE_URL=https://your-domain.com
# ============================================================ # ============================================================
# Misc # Misc
# ============================================================ # ============================================================
@@ -168,6 +216,7 @@ SEARXNG_INSTANCE=http://localhost:8080
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB) # ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB)
# ============================================================ # ============================================================
# Host Docker access (explicit opt-in) # Host Docker access (explicit opt-in)
+7
View File
@@ -15,6 +15,13 @@ docker/entrypoint.sh text eol=lf
*.cmd text eol=crlf *.cmd text eol=crlf
*.bat text eol=crlf *.bat text eol=crlf
# Vendored third-party bundles in static/lib/ are published minified artifacts
# and must stay byte-identical to what npm ships — stripping trailing whitespace
# to satisfy `git diff --check` would desync them from the upstream release. Turn
# the whitespace check off for that tree instead, and keep the bundles out of
# GitHub's language statistics.
static/lib/** -whitespace linguist-vendored
# Binary assets — never normalize. # Binary assets — never normalize.
*.png binary *.png binary
*.jpg binary *.jpg binary
+1 -1
View File
@@ -6,4 +6,4 @@
# A per-area ownership map (security/auth, CI, frontend, agent internals, with # 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 # 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 # 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.
+18 -6
View File
@@ -6,26 +6,38 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
**Before submitting:** search [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues) **Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) first. and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
Duplicate reports slow things down. Duplicate reports slow things down.
For security vulnerabilities, **do not open a public issue** — For security vulnerabilities, **do not open a public issue** —
use [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) use [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new)
and read [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md) first. and read [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md) first.
- type: checkboxes - type: checkboxes
id: prerequisites id: prerequisites
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues?q=is%3Aissue+is%3Aopen) and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) and did not find an existing report of this bug. - label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and [discussions](https://github.com/odysseus-dev/odysseus/discussions) and did not find an existing report of this bug.
required: true required: true
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md).) - label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md).)
required: true required: true
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing. - label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
required: true required: true
- type: input
id: revision
attributes:
label: Odysseus Revision
description: |
From the repository root (on the host when using Docker), run
`git show -s --abbrev=12 --format='%h (%cs)' HEAD`
and paste the output exactly.
placeholder: "1fef4929cf1d (2026-08-11)"
validations:
required: true
- type: dropdown - type: dropdown
id: install-method id: install-method
attributes: attributes:
+3 -3
View File
@@ -1,13 +1,13 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Question / Need Help - name: Question / Need Help
url: https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/q-a url: https://github.com/odysseus-dev/odysseus/discussions/categories/q-a
about: Ask how-to questions, setup help, and model configuration questions here. Issues are for confirmed bugs and concrete proposals only. about: Ask how-to questions, setup help, and model configuration questions here. Issues are for confirmed bugs and concrete proposals only.
- name: Idea or Suggestion - name: Idea or Suggestion
url: https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/ideas url: https://github.com/odysseus-dev/odysseus/discussions/categories/ideas
about: Discuss ideas and gauge interest before opening a formal feature request. If there is already a discussion, link it in your feature request. about: Discuss ideas and gauge interest before opening a formal feature request. If there is already a discussion, link it in your feature request.
- name: Security Vulnerability - name: Security Vulnerability
url: https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new url: https://github.com/odysseus-dev/odysseus/security/advisories/new
about: Report vulnerabilities privately via GitHub Security Advisories — never as a public issue. Read SECURITY.md before reporting. about: Report vulnerabilities privately via GitHub Security Advisories — never as a public issue. Read SECURITY.md before reporting.
+6 -6
View File
@@ -6,22 +6,22 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
**Before submitting:** search [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues) **Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) first. and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
Feature requests that duplicate [ROADMAP.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/ROADMAP.md) Feature requests that duplicate [ROADMAP.md](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md)
or an existing open issue will be closed as duplicates. or an existing open issue will be closed as duplicates.
If your idea needs community input before it becomes a concrete proposal, If your idea needs community input before it becomes a concrete proposal,
start a [discussion](https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/ideas) instead. start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
- type: checkboxes - type: checkboxes
id: prerequisites id: prerequisites
attributes: attributes:
label: Prerequisites label: Prerequisites
options: options:
- label: I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues?q=is%3Aissue+is%3Aopen) and this has not already been proposed. - label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and this has not already been proposed.
required: true required: true
- label: I searched [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) and this is not already being debated there. - label: I searched [discussions](https://github.com/odysseus-dev/odysseus/discussions) and this is not already being debated there.
required: true required: true
- label: This is a concrete, actionable proposal — not a vague "it would be nice if..." request. - label: This is a concrete, actionable proposal — not a vague "it would be nice if..." request.
required: true required: true
+2 -1
View File
@@ -24,10 +24,11 @@ Fixes #
## Checklist ## Checklist
- [ ] I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues) and [open PRs](https://github.com/pewdiepie-archdaemon/odysseus/pulls) — this is not a duplicate. - [ ] I searched [open issues](https://github.com/odysseus-dev/odysseus/issues) and [open PRs](https://github.com/odysseus-dev/odysseus/pulls) — this is not a duplicate.
- [ ] This PR targets `dev` - [ ] This PR targets `dev`
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in. - [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough. - [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
- [ ] I did not run the app/runtime validation and stated that gap in **How to Test**. Leave this unchecked when the app-run box above is checked.
## How to Test ## How to Test
+18 -3
View File
@@ -41,6 +41,14 @@ module.exports = async ({ github, context, core }) => {
break; break;
case 'bug': { case 'bug': {
const revisionText = section('Odysseus Revision');
if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) {
failures.push(
'**Odysseus Revision** — paste the 12-character commit SHA and date, ' +
'for example `1fef4929cf1d (2026-08-11)`',
);
}
if (!section('Install Method')) { if (!section('Install Method')) {
failures.push('**Install Method** — select how you installed Odysseus'); failures.push('**Install Method** — select how you installed Odysseus');
} }
@@ -153,6 +161,16 @@ module.exports = async ({ github, context, core }) => {
} }
} }
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
// Closed issues are no longer awaiting review.
// This also prevents later edits to closed issues from restoring the label.
if (issue.state === 'closed') {
await dropLabel(LABEL_GOOD);
return;
}
// ── Find existing bot comment to update in-place ────────────────────────── // ── Find existing bot comment to update in-place ──────────────────────────
const MARKER = '<!-- issue-description-check -->'; const MARKER = '<!-- issue-description-check -->';
const { data: comments } = await github.rest.issues.listComments({ const { data: comments } = await github.rest.issues.listComments({
@@ -160,9 +178,6 @@ module.exports = async ({ github, context, core }) => {
}); });
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER)); const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
if (failures.length === 0) { if (failures.length === 0) {
if (existing) { if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
+135 -25
View File
@@ -21,11 +21,11 @@ module.exports = async ({ github, context, core }) => {
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? ''); return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
} }
const problems = []; const descriptionProblems = [];
// 1. Summary must be filled in. // 1. Summary must be filled in.
if (section('Summary').length < 20) { if (section('Summary').length < 20) {
problems.push('**Summary** is empty or too short — describe what changed and why.'); descriptionProblems.push('**Summary** is empty or too short — describe what changed and why.');
} }
// 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing // 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing
@@ -34,18 +34,18 @@ module.exports = async ({ github, context, core }) => {
const linkedSection = section('Linked Issue'); const linkedSection = section('Linked Issue');
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection); const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
if (!linkedSection || !hasIssueRef) { if (!linkedSection || !hasIssueRef) {
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.'); descriptionProblems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
} }
// 3. At least one Type of Change box must be checked. // 3. At least one Type of Change box must be checked.
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? ''; const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
if (!/- \[x\]/i.test(typeBlock)) { if (!/- \[x\]/i.test(typeBlock)) {
problems.push('**Type of Change** — check at least one box.'); descriptionProblems.push('**Type of Change** — check at least one box.');
} }
// 4. Duplicate-search checklist item must be checked. // 4. Duplicate-search checklist item must be checked.
if (!/- \[x\] I searched/i.test(body)) { if (!/- \[x\] I searched/i.test(body)) {
problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.'); descriptionProblems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
} }
// 5. How to Test must contain enough real detail for a reviewer to act on. // 5. How to Test must contain enough real detail for a reviewer to act on.
@@ -53,7 +53,83 @@ module.exports = async ({ github, context, core }) => {
// code block — so we only require non-trivial content, not a specific shape. // code block — so we only require non-trivial content, not a specific shape.
const howTo = section('How to Test'); const howTo = section('How to Test');
if (howTo.length < 30) { if (howTo.length < 30) {
problems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").'); descriptionProblems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
}
// Classify paths from GitHub's API. This workflow runs in the privileged base
// context, so it must never check out or execute code from the PR branch.
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: prNum, per_page: 100,
});
const changedPaths = changedFiles.map(file => file.filename);
function isUiSensitivePath(filename) {
const path = filename.toLowerCase();
return path.startsWith('static/')
|| path.startsWith('templates/')
|| /\.(?:html?|css|svg)$/.test(path);
}
function isDocsOnlyPath(filename) {
const path = filename.toLowerCase();
return /\.(?:md|mdx|rst|adoc|txt)$/.test(path)
|| (path.startsWith('docs/') && !isUiSensitivePath(path));
}
function isRuntimeSensitivePath(filename) {
const path = filename.toLowerCase();
if (isUiSensitivePath(path)) return false;
if (path.startsWith('tests/') || path.startsWith('.github/')) return false;
return /^(?:app\.py|routes\/|services\/|src\/|core\/|mcp_servers\/|scripts\/|docker\/)/.test(path)
|| /^(?:dockerfile|docker-compose.*\.ya?ml|requirements(?:-optional)?\.txt|pyproject\.toml|setup\.py)$/.test(path)
|| /\.(?:py|sh|ps1|bat)$/.test(path);
}
let classification = 'tooling';
if (changedPaths.some(isUiSensitivePath)) {
classification = 'UI-sensitive';
} else if (changedPaths.some(isRuntimeSensitivePath)) {
classification = 'backend/runtime';
} else if (changedPaths.length > 0 && changedPaths.every(isDocsOnlyPath)) {
classification = 'docs-only';
}
const appRan = /- \[x\]\s+I actually ran the app\b/i.test(body);
const appNotRun = /- \[x\]\s+I did not run the app\/runtime validation\b/i.test(body);
// Anchor on the wording, not the template's emphasis: a ticked box the author
// retyped without the surrounding ** renders identically on the PR page, so
// treating it as unchecked is invisible from their side. Matches the two
// attestations above, which already ignore formatting.
const screenshotChecked = /- \[x\]\s+[*_]{0,2}Screenshot or short clip[*_]{0,2}/i.test(body);
const screenshotSection = section('Screenshots / clips');
const hasVisualEvidence = /!\[[^\]]*\]\([^)]+\)|<(?:img|video|source)\b[^>]*(?:src|href)=|https?:\/\/[^\s)]+/i.test(screenshotSection);
const evidenceGaps = [];
let needsRuntimeValidation = false;
let needsVisualEvidence = false;
if (classification === 'backend/runtime' || classification === 'UI-sensitive') {
if (appRan && appNotRun) {
needsRuntimeValidation = true;
evidenceGaps.push('The app-run and explicit not-run boxes are both checked. Select the one state that is true.');
} else if (!appRan) {
needsRuntimeValidation = true;
if (appNotRun) {
evidenceGaps.push('The author explicitly reports that app/runtime validation was not performed.');
} else {
evidenceGaps.push('App/runtime validation is not author-attested. Check the run box only after running it, or check the explicit not-run box and describe the gap.');
}
}
}
if (classification === 'UI-sensitive') {
if (!screenshotChecked) {
needsVisualEvidence = true;
evidenceGaps.push('The screenshot/clip checkbox is not checked for this UI-sensitive change.');
}
if (!hasVisualEvidence) {
needsVisualEvidence = true;
evidenceGaps.push('The Screenshots / clips section does not contain an actual attachment or link.');
}
} }
// ── Comment ────────────────────────────────────────────────────────────── // ── Comment ──────────────────────────────────────────────────────────────
@@ -62,22 +138,43 @@ module.exports = async ({ github, context, core }) => {
}); });
const existing = comments.find(c => (c.body ?? '').includes(MARKER)); const existing = comments.find(c => (c.body ?? '').includes(MARKER));
if (problems.length === 0) { if (descriptionProblems.length === 0 && evidenceGaps.length === 0) {
if (existing) { if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
} }
} else { } else {
const commentBody = [ const commentLines = [MARKER];
MARKER, if (descriptionProblems.length > 0) {
commentLines.push(
'⚠️ **PR description — action needed**', '⚠️ **PR description — action needed**',
'', '',
'The following required sections are missing or incomplete. Please update the PR description to address them:', 'The following required sections are missing or incomplete. Please update the PR description to address them:',
'', '',
problems.map(p => `- ${p}`).join('\n'), descriptionProblems.map(problem => `- ${problem}`).join('\n'),
);
} else {
commentLines.push(
'⚠️ **PR description is complete; validation evidence is still outstanding**',
'',
`Changed-file classification: **${classification}**.`,
);
}
if (evidenceGaps.length > 0) {
commentLines.push(
'',
'**Author-reported runtime / visual state**',
'',
evidenceGaps.map(gap => `- ${gap}`).join('\n'),
'',
'Checkboxes are author attestations. GitHub Actions results remain the execution evidence for CI; this check does not prove that a local command ran.',
);
}
commentLines.push(
'', '',
'---', '---',
'_This comment is deleted automatically once all sections are complete._', '_This comment updates automatically when the description or changed files change._',
].join('\n'); );
const commentBody = commentLines.join('\n');
if (existing) { if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody }); await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
@@ -97,34 +194,47 @@ module.exports = async ({ github, context, core }) => {
return true; return true;
} catch (e) { } catch (e) {
if (e.status === 404) return false; if (e.status === 404) return false;
if (e.status === 403) {
core.warning(`Could not inspect label "${name}" — token lacks label read access; skipping.`);
return false;
}
throw e; throw e;
} }
} }
async function swapLabel(num, add, remove) { async function setLabel(name, wanted) {
if (await labelExists(add)) { if (wanted && await labelExists(name)) {
try { try {
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] }); await github.rest.issues.addLabels({ owner, repo, issue_number: prNum, labels: [name] });
} catch (e) { } catch (e) {
// Fail soft on a token that can't write labels so a label permission // Fail soft on a token that can't write labels so a label permission
// problem never masks the actual description verdict. // problem never masks the actual description verdict.
if (e.status !== 403) throw e; if (e.status !== 403 && e.status !== 404) throw e;
core.warning(`Could not add "${add}" — token lacks label write here; skipping.`); core.warning(`Could not add "${name}" — label is unavailable or the token lacks label write access; skipping.`);
} }
} else if (wanted) {
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
} else { } else {
core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`);
}
try { try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove }); await github.rest.issues.removeLabel({ owner, repo, issue_number: prNum, name });
} catch (e) { } catch (e) {
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e; if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
} }
} }
}
if (problems.length === 0) { const descriptionComplete = descriptionProblems.length === 0;
await swapLabel(prNum, 'ready for review', 'needs work'); const evidenceComplete = evidenceGaps.length === 0;
} else { const isDraft = Boolean(context.payload.pull_request.draft);
await swapLabel(prNum, 'needs work', 'ready for review'); await setLabel(
core.setFailed(`PR description has ${problems.length} issue(s) — see bot comment for details.`); 'ready for review',
descriptionComplete && evidenceComplete && !isDraft,
);
await setLabel('needs work', !descriptionComplete);
await setLabel('needs runtime validation', needsRuntimeValidation);
await setLabel('needs visual evidence', needsVisualEvidence);
if (!descriptionComplete) {
core.setFailed(`PR description has ${descriptionProblems.length} issue(s) — see bot comment for details.`);
} }
}; };
+13 -15
View File
@@ -2,7 +2,7 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main, dev]
pull_request: pull_request:
# Least privilege: none of the jobs write to the repo. # Least privilege: none of the jobs write to the repo.
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -73,10 +73,10 @@ jobs:
name: Python syntax (compileall) name: Python syntax (compileall)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: "3.11" python-version: "3.11"
# Byte-compile sources — catches syntax errors without installing deps. # Byte-compile sources — catches syntax errors without installing deps.
@@ -86,10 +86,10 @@ jobs:
name: JS syntax (node --check) name: JS syntax (node --check)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: "20" node-version: "20"
# Syntax-check our own JS (skip vendored libs in static/lib). # Syntax-check our own JS (skip vendored libs in static/lib).
@@ -103,17 +103,14 @@ jobs:
python-tests: python-tests:
name: Python tests (pytest) name: Python tests (pytest)
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Informational for now: the suite has known flaky / environment-dependent # Make Python test validation authoritative for the configured scope.
# failures (test isolation + embedding-model assertions). Tracked under the
# ROADMAP "fresh install smoke tests" item; make this required once green.
continue-on-error: true
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false 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. # If so, skip the expensive pytest run while still reporting a passing check.
- name: Check for docs-only changes - name: Check for docs-only changes
id: docs-check id: docs-check
@@ -125,9 +122,10 @@ jobs:
BASE="${{ github.event.before }}" BASE="${{ github.event.before }}"
HEAD="${{ github.sha }}" HEAD="${{ github.sha }}"
fi 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) 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 if [ -z "$non_docs" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT" echo "docs_only=true" >> "$GITHUB_OUTPUT"
echo "Docs-only change detected — skipping pytest." echo "Docs-only change detected — skipping pytest."
@@ -135,7 +133,7 @@ jobs:
echo "docs_only=false" >> "$GITHUB_OUTPUT" echo "docs_only=false" >> "$GITHUB_OUTPUT"
fi 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' if: steps.docs-check.outputs.docs_only != 'true'
with: with:
python-version: "3.11" python-version: "3.11"
+41
View File
@@ -0,0 +1,41 @@
name: CodeQL
# Advanced setup so CodeQL also runs on pull requests (including from forks),
# surfacing findings before merge instead of only after a change lands on dev.
on:
push:
branches: [dev, main]
pull_request:
branches: [dev]
schedule:
- cron: "17 3 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: [actions, javascript-typescript, python]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{ matrix.language }}"
+2 -2
View File
@@ -37,12 +37,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Lint Dockerfile - name: Lint Dockerfile
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
with: with:
dockerfile: Dockerfile dockerfile: Dockerfile
# DL3008: pinning apt package versions is impractical on a -slim base # DL3008: pinning apt package versions is impractical on a -slim base
+11 -7
View File
@@ -23,12 +23,16 @@ on:
paths-ignore: paths-ignore:
- '**.md' - '**.md'
- 'docs/**' - 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**' - '.github/ISSUE_TEMPLATE/**'
push: push:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- '**.md' - '**.md'
- 'docs/**' - 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**' - '.github/ISSUE_TEMPLATE/**'
workflow_dispatch: workflow_dispatch:
@@ -52,17 +56,17 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - 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 # Build without pushing so a broken Dockerfile is caught here, and the
# exact image we ship is what gets scanned. # exact image we ship is what gets scanned.
- name: Build image - name: Build image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
push: false push: false
@@ -93,15 +97,15 @@ jobs:
security-events: write # upload SARIF to the Security tab security-events: write # upload SARIF to the Security tab
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - 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 - name: Build image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
push: false push: false
@@ -119,7 +123,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results - 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: with:
sarif_file: trivy-results.sarif sarif_file: trivy-results.sarif
category: trivy-image category: trivy-image
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -55,12 +55,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: '3.12' 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: paths-ignore:
- '**.md' - '**.md'
- 'docs/**' - 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**' - '.github/ISSUE_TEMPLATE/**'
concurrency: concurrency:
@@ -45,20 +47,20 @@ jobs:
arch: arm64 arch: arm64
runner: ubuntu-24.04-arm runner: ubuntu-24.04-arm
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - 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 - name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with: with:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
@@ -86,7 +88,7 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Read APP_VERSION + short sha - name: Read APP_VERSION + short sha
@@ -103,16 +105,16 @@ jobs:
pattern: digest-* pattern: digest-*
merge-multiple: true merge-multiple: true
- name: Set up Buildx - 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 - name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags - name: Compute tags
id: meta id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
@@ -2,7 +2,7 @@ name: ci / issue description check
on: on:
issues: issues:
types: [opened, edited, reopened] types: [opened, edited, reopened, closed]
permissions: permissions:
issues: write issues: write
@@ -14,7 +14,7 @@ jobs:
# Skip bots (Dependabot, release-drafter, etc.) # Skip bots (Dependabot, release-drafter, etc.)
if: ${{ github.event.issue.user.type != 'Bot' }} if: ${{ github.event.issue.user.type != 'Bot' }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
sparse-checkout: .github/scripts sparse-checkout: .github/scripts
persist-credentials: false persist-credentials: false
+10 -4
View File
@@ -5,7 +5,11 @@ on:
# works on fork PRs. Safe here: the checkout pins to the base branch (no fork # works on fork PRs. Safe here: the checkout pins to the base branch (no fork
# code runs) and the scripts only read context.payload and call the GitHub API. # code runs) and the scripts only read context.payload and call the GitHub API.
pull_request_target: # zizmor: ignore[dangerous-triggers] pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, synchronize, reopened, ready_for_review] types: [opened, edited, synchronize, reopened, ready_for_review, converted_to_draft]
concurrency:
group: pr-description-${{ github.event.pull_request.number }}
cancel-in-progress: true
# Default-deny at the workflow level; each job opts into only the scopes it needs. # Default-deny at the workflow level; each job opts into only the scopes it needs.
# Note: modifying a PR's labels/comments needs pull-requests:write even though the # Note: modifying a PR's labels/comments needs pull-requests:write even though the
@@ -23,7 +27,7 @@ jobs:
# Skip bots: they open PRs programmatically and have their own process. # Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot' if: github.event.pull_request.user.type != 'Bot'
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
ref: ${{ github.base_ref }} ref: ${{ github.base_ref }}
sparse-checkout: .github/scripts sparse-checkout: .github/scripts
@@ -59,12 +63,14 @@ jobs:
check-mergeable: check-mergeable:
name: Flag unmergeable PRs name: Flag unmergeable PRs
needs: check-description
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
pull-requests: write pull-requests: write
issues: write issues: write
# Skip bots: they open PRs programmatically and have their own process. # Run after description validation failures, but never from an obsolete
if: github.event.pull_request.user.type != 'Bot' # workflow run canceled by a newer PR event.
if: ${{ !cancelled() && github.event.pull_request.user.type != 'Bot' }}
steps: steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
# Full history so a secret committed in an earlier commit (and later # Full history so a secret committed in an earlier commit (and later
# deleted) is still caught -- deletion does not remove it from Git. # deleted) is still caught -- deletion does not remove it from Git.
+3 -3
View File
@@ -36,7 +36,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -61,12 +61,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: '3.12' python-version: '3.12'
+18
View File
@@ -85,6 +85,24 @@ output.txt.txt
!docs/**/*.gif !docs/**/*.gif
!docs/**/*.webp !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 and temp files
reports/ reports/
tasks/ tasks/
+10 -2
View File
@@ -65,6 +65,16 @@ Vendored in `static/lib/` and served directly:
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT | | [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT | | [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT | | [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than
from `index.html`, so a session that renders no math and no diagram never fetches
either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the
`.woff` and `.ttf` variants its stylesheet also lists are never requested by a
browser that supports `woff2`. The bundles are the published npm artifacts,
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
so they can stay byte-identical to upstream.
## Front-end libraries loaded at runtime (CDN) ## Front-end libraries loaded at runtime (CDN)
@@ -72,8 +82,6 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v
| Library | Purpose | License | | Library | Purpose | License |
|---|---|---| |---|---|---|
| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT |
| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT |
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 | | [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT | | [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
+1 -1
View File
@@ -25,7 +25,7 @@ End-users cloning the repo will land on `dev` by default. To run the curated/sta
Docker is the recommended path for normal testing: Docker is the recommended path for normal testing:
```bash ```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git git clone https://github.com/odysseus-dev/odysseus.git
cd odysseus cd odysseus
cp .env.example .env cp .env.example .env
docker compose up -d --build docker compose up -d --build
+4 -2
View File
@@ -16,7 +16,8 @@ FROM python:3.14-slim
# downloads, and serves from Docker installs. # downloads, and serves from Docker installs.
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp # git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
# launch inside Docker. # launch inside Docker.
# nodejs/npm provide npx for the optional built-in Browser MCP server. # nodejs/npm provide npx for the built-in Browser MCP server.
# chromium provides the actual browser binary used by that MCP server.
# gosu lets the entrypoint drop privileges cleanly so signals still reach # gosu lets the entrypoint drop privileges cleanly so signals still reach
# uvicorn directly (no extra shell layer like `su`/`sudo` would add). # uvicorn directly (no extra shell layer like `su`/`sudo` would add).
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -26,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \ git \
nodejs \ nodejs \
npm \ npm \
chromium \
tmux \ tmux \
openssh-client \ openssh-client \
gosu \ gosu \
@@ -54,7 +56,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# /var/run/docker.sock mount). The Debian `docker.io` package ships # /var/run/docker.sock mount). The Debian `docker.io` package ships
# dockerd but not the client binary on slim, so grab the static client # dockerd but not the client binary on slim, so grab the static client
# tarball from download.docker.com instead. # tarball from download.docker.com instead.
ARG DOCKER_CLI_VERSION=27.5.1 ARG DOCKER_CLI_VERSION=29.6.2
RUN ARCH="$(dpkg --print-architecture)" \ RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \ && case "$ARCH" in \
amd64) DARCH=x86_64 ;; \ amd64) DARCH=x86_64 ;; \
+17 -12
View File
@@ -1,5 +1,5 @@
<p align="center"> <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>
<p align="center"> <p align="center">
@@ -8,7 +8,7 @@
<p align="center"> <p align="center">
<a href="#quick-start">Quick Start</a> · <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="CONTRIBUTING.md">Contributing</a> ·
<a href="ROADMAP.md">Roadmap</a> <a href="ROADMAP.md">Roadmap</a>
</p> </p>
@@ -18,17 +18,17 @@
</p> </p>
<p align="center"> <p align="center">
<img src="docs/odysseus-browser.jpg" alt="Odysseus interface"> <img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
</p> </p>
--- ---
## Quick Start ## Quick Start
> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main) if you want the more curated branch. > `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/odysseus-dev/odysseus/tree/main) if you want the more curated branch.
```bash ```bash
git clone https://github.com/pewdiepie-archdaemon/odysseus.git git clone https://github.com/odysseus-dev/odysseus.git
cd odysseus cd odysseus
cp .env.example .env cp .env.example .env
docker compose up -d --build docker compose up -d --build
@@ -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`. 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 ## Features
@@ -51,7 +51,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
## Demo ## 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 ## Contributing
@@ -59,15 +59,20 @@ Help is welcome. The best entry points are fresh-install testing, provider setup
## Security ## Security
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes). Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly.
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
Deployment details are in the [setup guide](website/setup.md#security-notes).
## Star History ## Star History
<a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left"> <a href="https://star-history.dera.page/#odysseus-dev/odysseus&type=date&legend=top-left">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&theme=dark&legend=top-left" /> <source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" /> <source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" /> <img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
</picture> </picture>
</a> </a>
+8 -1
View File
@@ -12,7 +12,6 @@ the codebase, you are probably right to stay away.
and WSL all need coverage. and WSL all need coverage.
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden. - Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps.
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments. - Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works - Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
@@ -33,6 +32,14 @@ the codebase, you are probably right to stay away.
before the user request really starts. We need slimmer prompts, better tool before the user request really starts. We need slimmer prompts, better tool
selection, smaller default tool sets, and clearer guidance for models with selection, smaller default tool sets, and clearer guidance for models with
4k/8k/16k context windows. 4k/8k/16k context windows.
- Local model speculative decoding support. For Odysseus-tuned local models,
plan to ship or recommend a small same-tokenizer draft model when the serving
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
DSpark conversion performed poorly. Treat this as a supported draft-model lane
first; keep MTP-specific packaging as future work only when the architecture
and runtime support are real. Judge this by time-to-success, tool correctness,
grammar, and unchanged target output, not tokens/sec alone.
- Skill/tool prompt-injection audit. User-editable skills, notes, documents, - Skill/tool prompt-injection audit. User-editable skills, notes, documents,
fetched pages, and memories should be treated as untrusted data. Keep testing fetched pages, and memories should be treated as untrusted data. Keep testing
whether models follow malicious instructions from those surfaces. whether models follow malicious instructions from those surfaces.
+1 -1
View File
@@ -10,7 +10,7 @@ Security fixes are handled on the default branch until formal releases are cut.
- Keep `AUTH_ENABLED=true` for any network-accessible deployment. - Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development. - Keep `LOCALHOST_BYPASS=false` outside local development.
- Set `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway. - Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Set `SECURE_COOKIES=true` to force it on (for a proxy Odysseus cannot see the scheme of), or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
- Use HTTPS when exposing the app beyond localhost. - Use HTTPS when exposing the app beyond localhost.
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN. - Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. - Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
+1 -1
View File
@@ -37,7 +37,7 @@ Non-admin defaults are in `core/auth.py:DEFAULT_PRIVILEGES`. Tool enforcement is
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`. - **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance. - **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
- **Reserved usernames:** `internal-tool`, `api`, `demo`, `system` cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`. - **Reserved usernames:** request sentinels and the Default/Local storage owner cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check. - `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate. - **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
+78 -27
View File
@@ -67,7 +67,13 @@ from core.constants import (
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE, REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
) )
from core.database import SessionLocal, ApiToken from core.database import SessionLocal, ApiToken
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight from core.middleware import (
SecurityHeadersMiddleware,
get_application_route_path,
is_cors_preflight,
path_is_route_or_child,
with_asgi_root_path,
)
from core.auth import AuthManager, normalize_known_username from core.auth import AuthManager, normalize_known_username
from core.exceptions import ( from core.exceptions import (
SessionNotFoundError, InvalidFileUploadError, SessionNotFoundError, InvalidFileUploadError,
@@ -78,6 +84,7 @@ import bcrypt as _bcrypt
from src.app_helpers import abs_join, serve_html_with_nonce from src.app_helpers import abs_join, serve_html_with_nonce
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from src.owner_identity import auth_disabled
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
# ========= LOGGING ========= # ========= LOGGING =========
@@ -248,7 +255,7 @@ from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
auth_manager = AuthManager() auth_manager = AuthManager()
app.state.auth_manager = auth_manager app.state.auth_manager = auth_manager
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() != "false" AUTH_ENABLED = not auth_disabled()
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true" LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
if LOCALHOST_BYPASS: if LOCALHOST_BYPASS:
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.") logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
@@ -284,7 +291,7 @@ if AUTH_ENABLED:
def _is_auth_exempt(path: str) -> bool: def _is_auth_exempt(path: str) -> bool:
if path in AUTH_EXEMPT_EXACT: if path in AUTH_EXEMPT_EXACT:
return True return True
if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES): if any(path_is_route_or_child(path, p) for p in AUTH_EXEMPT_PREFIXES):
return True return True
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS) return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
@@ -355,7 +362,7 @@ if AUTH_ENABLED:
class AuthMiddleware(BaseHTTPMiddleware): class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
path = request.url.path path = get_application_route_path(request.scope)
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method) # A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
# carries no credentials by design and must reach CORSMiddleware to be # carries no credentials by design and must reach CORSMiddleware to be
# answered. AuthMiddleware is the outermost middleware, so gating the # answered. AuthMiddleware is the outermost middleware, so gating the
@@ -399,7 +406,10 @@ if AUTH_ENABLED:
if not auth_manager.is_configured: if not auth_manager.is_configured:
# No users yet — redirect to login for first-time setup # No users yet — redirect to login for first-time setup
if not path.startswith("/api/"): if not path.startswith("/api/"):
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(
url=with_asgi_root_path(request.scope, "/login"),
status_code=302,
)
return JSONResponse(status_code=401, content={"error": "Setup required"}) return JSONResponse(status_code=401, content={"error": "Setup required"})
# --- Bearer token auth (API tokens for external integrations) --- # --- Bearer token auth (API tokens for external integrations) ---
@@ -461,7 +471,10 @@ if AUTH_ENABLED:
if not auth_manager.validate_token(token): if not auth_manager.validate_token(token):
if path.startswith("/api/"): if path.startswith("/api/"):
return JSONResponse(status_code=401, content={"error": "Not authenticated"}) return JSONResponse(status_code=401, content={"error": "Not authenticated"})
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(
url=with_asgi_root_path(request.scope, "/login"),
status_code=302,
)
# Attach current username to request state for downstream routes # Attach current username to request state for downstream routes
request.state.current_user = auth_manager.get_username_for_token(token) request.state.current_user = auth_manager.get_username_for_token(token)
@@ -503,23 +516,45 @@ async def serve_generated_image(filename: str, request: Request):
# SECURITY: filename is the only key, so anyone who knows / guesses a # SECURITY: filename is the only key, so anyone who knows / guesses a
# 12-hex content hash could pull another user's image bytes. Require # 12-hex content hash could pull another user's image bytes. Require
# auth and verify ownership via the gallery row (when one exists). # auth and verify ownership via the gallery row (when one exists).
_is_bearer = False
try: 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 from core.database import SessionLocal as _SL, GalleryImage as _GI
_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) _user = get_current_user(request)
if _user: if _user:
_db = _SL() _db = _SL()
try: try:
_row = _db.query(_GI).filter(_GI.filename == filename).first() _row = _db.query(_GI).filter(_GI.filename == filename).first()
# Generated-but-not-yet-imported images have no row → allow. # Generated-but-not-yet-imported images have no row → allow.
# Row exists with a different owner → 404 (don't confirm existence). # A bearer gallery row must have the exact token owner; cookie
if _row is not None and _row.owner and _row.owner != _user: # 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") raise HTTPException(status_code=404, detail="Image not found")
finally: finally:
_db.close() _db.close()
except HTTPException: except HTTPException:
raise raise
except Exception as _e: 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) logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
ext = filename.rsplit('.', 1)[-1].lower() ext = filename.rsplit('.', 1)[-1].lower()
mime = { mime = {
@@ -630,13 +665,24 @@ app.include_router(auth_router)
@app.post("/api/activity/heartbeat") @app.post("/api/activity/heartbeat")
async def activity_heartbeat(): async def activity_heartbeat():
from src.interactive_gate import mark_browser_activity from src.interactive_gate import (
mark_browser_activity,
maybe_stop_background_tasks_for_heartbeat,
)
await mark_browser_activity() await mark_browser_activity()
async def _stop_background(): async def _stop_background():
try: try:
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat") await maybe_stop_background_tasks_for_heartbeat(
task_scheduler.stop_background_tasks_for_foreground
)
except Exception: except Exception:
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True) logging.getLogger("app.foreground_gate").debug(
"heartbeat task stop failed",
exc_info=True,
)
asyncio.create_task(_stop_background()) asyncio.create_task(_stop_background())
return {"ok": True} return {"ok": True}
@@ -655,10 +701,15 @@ app.include_router(setup_emoji_routes())
# Sessions # Sessions
from routes.session_routes import setup_session_routes from routes.session_routes import setup_session_routes
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE} session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager)) app.include_router(setup_session_routes(
session_manager,
session_config,
webhook_manager=webhook_manager,
upload_handler=upload_handler,
))
# Admin Danger Zone wipes (Settings → System → Danger Zone) # Admin Danger Zone wipes (Settings → System → Danger Zone)
from routes.admin_wipe_routes import setup_admin_wipe_routes from routes.admin_wipe.admin_wipe_routes import setup_admin_wipe_routes
app.include_router(setup_admin_wipe_routes(session_manager)) app.include_router(setup_admin_wipe_routes(session_manager))
# Memory # Memory
@@ -684,10 +735,10 @@ app.include_router(setup_research_routes(research_handler, session_manager=sessi
# History # History
from routes.history.history_routes import setup_history_routes from routes.history.history_routes import setup_history_routes
app.include_router(setup_history_routes(session_manager)) app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
# Search # Search
from routes.search_routes import setup_search_routes from routes.search.search_routes import setup_search_routes
app.include_router(setup_search_routes(config)) app.include_router(setup_search_routes(config))
# Presets # Presets
@@ -699,7 +750,7 @@ from routes.diagnostics_routes import setup_diagnostics_routes
app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector)) app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector))
# Cleanup # Cleanup
from routes.cleanup_routes import setup_cleanup_routes from routes.cleanup.cleanup_routes import setup_cleanup_routes
app.include_router(setup_cleanup_routes(session_manager)) app.include_router(setup_cleanup_routes(session_manager))
# Personal docs # Personal docs
@@ -734,7 +785,7 @@ app.include_router(setup_stt_routes(stt_service))
logger.info("STT service initialized (provider managed via settings)") logger.info("STT service initialized (provider managed via settings)")
# Documents (artifacts/canvas) # Documents (artifacts/canvas)
from routes.document_routes import setup_document_routes from routes.document.document_routes import setup_document_routes
document_router = setup_document_routes(session_manager, upload_handler) document_router = setup_document_routes(session_manager, upload_handler)
app.include_router(document_router) app.include_router(document_router)
@@ -755,7 +806,7 @@ from src.task_scheduler import TaskScheduler
task_scheduler = TaskScheduler(session_manager) task_scheduler = TaskScheduler(session_manager)
from src.event_bus import set_task_scheduler from src.event_bus import set_task_scheduler
set_task_scheduler(task_scheduler) set_task_scheduler(task_scheduler)
from routes.task_routes import setup_task_routes from routes.task.task_routes import setup_task_routes
app.include_router(setup_task_routes(task_scheduler)) app.include_router(setup_task_routes(task_scheduler))
from routes.assistant_routes import setup_assistant_routes from routes.assistant_routes import setup_assistant_routes
@@ -763,7 +814,7 @@ app.include_router(setup_assistant_routes(task_scheduler))
# Calendar (CalDAV) # Calendar (CalDAV)
from routes.calendar_routes import setup_calendar_routes from routes.calendar_routes import setup_calendar_routes
calendar_router = setup_calendar_routes() calendar_router = setup_calendar_routes(upload_handler=upload_handler)
app.include_router(calendar_router) app.include_router(calendar_router)
# Shell (user-facing command execution) # Shell (user-facing command execution)
@@ -782,7 +833,7 @@ from routes.hwfit_routes import setup_hwfit_routes
app.include_router(setup_hwfit_routes()) app.include_router(setup_hwfit_routes())
# Model A/B Comparison # Model A/B Comparison
from routes.compare_routes import setup_compare_routes from routes.compare.compare_routes import setup_compare_routes
app.include_router(setup_compare_routes(session_manager)) app.include_router(setup_compare_routes(session_manager))
# User Preferences # User Preferences
@@ -800,7 +851,7 @@ app.include_router(setup_font_routes())
# MCP (Model Context Protocol) # MCP (Model Context Protocol)
from src.mcp_manager import McpManager from src.mcp_manager import McpManager
from src.agent_tools import set_mcp_manager from src.agent_tools import set_mcp_manager
from routes.mcp_routes import setup_mcp_routes from routes.mcp.mcp_routes import setup_mcp_routes
mcp_manager = McpManager() mcp_manager = McpManager()
set_mcp_manager(mcp_manager) set_mcp_manager(mcp_manager)
@@ -815,7 +866,7 @@ set_ai_rag_manager(rag_manager, personal_docs_mgr)
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)") logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
# Webhooks # Webhooks
from routes.webhook_routes import setup_webhook_routes from routes.webhook.webhook_routes import setup_webhook_routes
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager)) app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
# API Tokens # API Tokens
@@ -825,8 +876,8 @@ app.include_router(setup_api_token_routes())
logger.info("Webhook & API token routes initialized") logger.info("Webhook & API token routes initialized")
# Notes (Google Keep-style notes/todos) # Notes (Google Keep-style notes/todos)
from routes.note_routes import setup_note_routes from routes.note.note_routes import setup_note_routes
app.include_router(setup_note_routes(task_scheduler)) app.include_router(setup_note_routes(task_scheduler, upload_handler=upload_handler))
# Email # Email
from routes.email_routes import setup_email_routes from routes.email_routes import setup_email_routes
@@ -847,7 +898,7 @@ app.include_router(setup_codex_routes(
)) ))
app.include_router(setup_claude_routes()) app.include_router(setup_claude_routes())
from routes.vault_routes import setup_vault_routes from routes.vault.vault_routes import setup_vault_routes
app.include_router(setup_vault_routes()) app.include_router(setup_vault_routes())
# Contacts (CardDAV) # Contacts (CardDAV)
@@ -1043,7 +1094,7 @@ async def _startup_event():
except BaseException as e: except BaseException as e:
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}") logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
try: try:
await asyncio.wait_for(mcp_manager.connect_all_enabled(), timeout=20) await mcp_manager.connect_all_enabled()
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning("User MCP startup timed out (non-critical)") logger.warning("User MCP startup timed out (non-critical)")
except BaseException as e: except BaseException as e:

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

+8 -4
View File
@@ -27,13 +27,13 @@ echo " port: $PORT"
rm -rf "$APP" rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# ── Icon (best effort) — center-crop docs/odysseus.jpg to a square .icns ── # ── Icon (best effort) — center-crop the branding image to a square .icns ──
if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
TMPIMG="$(mktemp -d)" TMPIMG="$(mktemp -d)"
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and # 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 # let sips emit the .icns directly — more robust across macOS versions than
# building an .iconset by hand. # 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 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 if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
echo " icon: odysseus.icns" echo " icon: odysseus.icns"
@@ -42,7 +42,7 @@ if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
fi fi
rm -rf "$TMPIMG" rm -rf "$TMPIMG"
else else
echo " icon: (skipped — no docs/odysseus.jpg)" echo " icon: (skipped — no assets/branding/odysseus.jpg)"
fi fi
# ── Info.plist ── # ── Info.plist ──
@@ -73,6 +73,10 @@ cat > "$APP/Contents/MacOS/$APP_NAME.tmpl" <<'LAUNCHER'
INSTALL_DIR="__INSTALL_DIR__" INSTALL_DIR="__INSTALL_DIR__"
PORT="__PORT__" PORT="__PORT__"
URL="http://127.0.0.1:${PORT}" URL="http://127.0.0.1:${PORT}"
# uvicorn is started with --port below, but APP_PORT is what the app itself
# reads when it needs to build a URL for this instance (internal_api_base(),
# companion pairing, the MCP OAuth callback), so export it as well.
export APP_PORT="$PORT"
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
UVICORN="$INSTALL_DIR/venv/bin/uvicorn" UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
+99
View File
@@ -6,11 +6,14 @@ units so the route layer stays thin and the logic is directly testable.
from __future__ import annotations from __future__ import annotations
import ipaddress
import json import json
import os import os
import re
import secrets import secrets
import socket import socket
import uuid import uuid
from urllib.parse import urlsplit
import bcrypt import bcrypt
@@ -20,6 +23,102 @@ PAIRING_VERSION = 1
COMPANION_SCOPE = "chat" COMPANION_SCOPE = "chat"
_COMPANION_IPV4_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
)
)
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
def _valid_companion_client_host(host: str) -> bool:
"""Match the host forms supported by the current v1 Expo client."""
if not host or len(host) > 253 or not host.isascii() or "%" in host:
return False
try:
address = ipaddress.ip_address(host)
except ValueError:
labels = host.split(".")
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
return False
if any(label.startswith("xn--") for label in labels):
return False
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
# as an IPv4 number even though Python's strict ``ipaddress`` parser
# rejects that spelling. The v1 client interpolates this host back
# into a URL, so accepting e.g. ``134744072`` would make the phone send
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
if len(labels) == 1 and (
labels[0].isdigit()
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
):
return False
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
return isinstance(address, ipaddress.IPv4Address) and any(
address in network for network in _COMPANION_IPV4_NETWORKS
)
def parse_companion_base_url(value: str) -> tuple[str, int]:
"""Validate a v1 companion address and return its legacy (host, port).
The deployed client understands only HTTP plus a LAN-style host and port.
Reject anything outside that exact contract instead of advertising a URL
the client would reject, downgrade, or interpret differently.
"""
if not isinstance(value, str) or not value:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if not value.isascii():
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
if any(
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
for char in value
):
raise ValueError(
"COMPANION_BASE_URL contains a forbidden character"
)
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as exc:
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
host = parsed.hostname
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
if parsed.username is not None or parsed.password is not None:
raise ValueError("COMPANION_BASE_URL must not contain credentials")
if parsed.path or parsed.query or parsed.fragment:
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
if port is not None and not 1 <= port <= 65535:
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
if not _valid_companion_client_host(host):
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
netloc = f"{host}:{port}" if port is not None else host
origin = f"http://{netloc}"
if value != origin:
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
return host, port or 80
def configured_companion_origin() -> tuple[str, int] | None:
"""Return the validated operator-configured v1 address, if any."""
value = os.environ.get("COMPANION_BASE_URL")
if value is None or value == "":
return None
return parse_companion_base_url(value)
def default_port() -> int: def default_port() -> int:
"""Best guess at the port the server is reachable on. Callers that know the """Best guess at the port the server is reachable on. Callers that know the
real request port should pass it explicitly.""" real request port should pass it explicitly."""
+20 -5
View File
@@ -23,7 +23,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from core.middleware import require_admin from core.middleware import require_admin
from src.auth_helpers import get_current_user from src.auth_helpers import _auth_disabled, get_current_user
from companion import pairing as _pairing from companion import pairing as _pairing
@@ -113,8 +113,9 @@ def setup_companion_routes() -> APIRouter:
The stock /api/models route scopes to get_current_user, which for a The stock /api/models route scopes to get_current_user, which for a
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
scope to the token's real owner instead, plus legacy null-owner shared scope to the token's real owner instead, plus legacy null-owner shared
rows -- the same rule as owner_filter. Read-only; never returns api_key rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
material. the stock route's single-user all-endpoints view. Read-only; never
returns api_key material.
""" """
require_models_scope(request) require_models_scope(request)
import json as _json import json as _json
@@ -123,6 +124,11 @@ def setup_companion_routes() -> APIRouter:
from src.endpoint_resolver import build_chat_url from src.endpoint_resolver import build_chat_url
owner = token_owner(request) owner = token_owner(request)
single_user_mode = (
owner is None
and not getattr(request.state, "api_token", False)
and _auth_disabled()
)
out = [] out = []
db = SessionLocal() db = SessionLocal()
try: try:
@@ -133,7 +139,7 @@ def setup_companion_routes() -> APIRouter:
if owner: if owner:
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711 q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
for ep in q.all(): for ep in q.all():
if not owner_can_see(ep.owner, owner): if not single_user_mode and not owner_can_see(ep.owner, owner):
continue continue
try: try:
model_ids = _json.loads(ep.cached_models) if ep.cached_models else [] model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
@@ -194,10 +200,18 @@ def setup_companion_routes() -> APIRouter:
the code works immediately, no restart. `?format=json` returns the the code works immediately, no restart. `?format=json` returns the
payload for an in-app pairing screen.""" payload for an in-app pairing screen."""
require_admin(request) require_admin(request)
try:
configured_origin = _pairing.configured_companion_origin()
except ValueError as exc:
raise HTTPException(500, str(exc)) from None
owner = get_current_user(request) owner = get_current_user(request)
invalidate = getattr(request.app.state, "invalidate_token_cache", None) invalidate = getattr(request.app.state, "invalidate_token_cache", None)
token_id, raw_token = mint_pairing_token(owner, invalidate) token_id, raw_token = mint_pairing_token(owner, invalidate)
if configured_origin:
host, port = configured_origin
hosts = [host]
else:
hosts = _pairing.lan_ip_candidates() hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1" host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port() port = request.url.port or _pairing.default_port()
@@ -206,7 +220,7 @@ def setup_companion_routes() -> APIRouter:
qr_ok = bool(qr and qr.startswith("data:image/png;base64,")) qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
if (request.query_params.get("format") or "").lower() == "json": if (request.query_params.get("format") or "").lower() == "json":
return { response = {
"host": host, "host": host,
"port": port, "port": port,
"token": raw_token, "token": raw_token,
@@ -215,6 +229,7 @@ def setup_companion_routes() -> APIRouter:
"payload": payload, "payload": payload,
"qr": qr if qr_ok else None, "qr": qr if qr_ok else None,
} }
return response
import json as _json import json as _json
payload_json = _json.dumps(payload, separators=(",", ":")) payload_json = _json.dumps(payload, separators=(",", ":"))
+26 -4
View File
@@ -15,31 +15,53 @@ from __future__ import annotations
import json import json
import os import os
import uuid
from typing import Any, Optional from typing import Any, Optional
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None: def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
"""Atomically persist `data` as JSON at `path`. """Atomically persist `data` as JSON at `path`.
The temp file uses the live PID as a suffix so two processes saving the The temp file uses a random suffix so two concurrent writers saving the
same file (e.g. unit tests) don't collide on the rename target. same file don't collide on the rename target. A PID suffix does not do
this: the PID is constant for the life of a process, so two writers on
the same path within one process (or one single-process container, where
the PID never changes at all) still race for the same temp file.
""" """
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}" tmp = f"{path}.tmp.{uuid.uuid4().hex}"
try:
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent) json.dump(data, f, indent=indent)
f.flush() f.flush()
os.fsync(f.fileno()) os.fsync(f.fileno())
os.replace(tmp, path) os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
def atomic_write_text(path: str, text: str) -> None: def atomic_write_text(path: str, text: str) -> None:
if not isinstance(text, str): if not isinstance(text, str):
raise TypeError("atomic_write_text expects a string") raise TypeError("atomic_write_text expects a string")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}" tmp = f"{path}.tmp.{uuid.uuid4().hex}"
try:
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
f.write(text) f.write(text)
f.flush() f.flush()
os.fsync(f.fileno()) os.fsync(f.fileno())
os.replace(tmp, path) os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
+9 -16
View File
@@ -20,7 +20,6 @@ logger = logging.getLogger(__name__)
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402 from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
DEFAULT_PRIVILEGES = { DEFAULT_PRIVILEGES = {
"can_use_agent": True, "can_use_agent": True,
@@ -49,24 +48,18 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
ADMIN_PRIVILEGES["block_all_models"] = False ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
from src.owner_identity import RESERVED_AUTH_USERNAMES
DEFAULT_AUTH_PATH = AUTH_FILE DEFAULT_AUTH_PATH = AUTH_FILE
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
# Usernames the auth + middleware layer reserve as internal "synthetic owner" # Usernames the auth + middleware layer reserves for request sentinels and
# sentinels; they must never belong to a real account. The most dangerous is # internal storage owners; they must never belong to a real login account.
# "internal-tool": `core.middleware.require_admin` treats any request whose # "internal-tool" is the most dangerous because `core.middleware.require_admin`
# `current_user == "internal-tool"` as the in-process tool loopback and grants # treats it as the in-process tool loopback. "api" collides with bearer-token
# admin, and because the cookie auth path sets `current_user` to the raw # attribution. "demo"/"system" are synthetic owners already special-cased by
# username, an account literally named "internal-tool" would be silently # scheduler/assistant/research paths. The Default/Local owner is a storage
# treated as an admin by every `require_admin`-gated route. "api" collides with # bucket for explicit auth-disabled no-login mode, not a login username.
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
# synthetic-owner set the rest of the codebase already special-cases (see
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
# of those names would be denied an assistant and inconsistently owner-scoped.
# Refuse to create or rename into any of them so the sentinels can't be
# impersonated. (Keep this in sync with that synthetic-owner set.)
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]: def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
+447 -32
View File
@@ -3,13 +3,16 @@ import logging
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text from typing import Optional
from sqlalchemy.engine import Engine from urllib.parse import unquote, urlparse
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.types import TypeDecorator
from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import relationship, sessionmaker, backref from sqlalchemy.orm import relationship, sessionmaker, backref
from src.runtime_paths import get_app_root from src.runtime_paths import get_app_root
from core.platform_compat import safe_chmod, IS_WINDOWS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,12 +45,28 @@ def _default_database_url() -> str:
def _normalize_sqlite_url(url: str) -> str: def _normalize_sqlite_url(url: str) -> str:
if not url.startswith("sqlite:///"): """Resolve relative ordinary SQLite paths without rewriting URI filenames."""
try:
parsed = make_url(url)
except Exception:
return url return url
db_path = url.replace("sqlite:///", "", 1)
if db_path == ":memory:" or os.path.isabs(db_path): if parsed.get_backend_name() != "sqlite":
return url return url
return f"sqlite:///{(Path(get_app_root()) / db_path).resolve().as_posix()}"
db_path = parsed.database
if (
not db_path
or db_path == ":memory:"
or str(db_path).lower().startswith("file:")
or os.path.isabs(str(db_path))
):
return url
absolute_path = (Path(get_app_root()) / str(db_path)).resolve().as_posix()
return parsed.set(database=absolute_path).render_as_string(
hide_password=False
)
# Get database URL from environment, default to SQLite in DATA_DIR # Get database URL from environment, default to SQLite in DATA_DIR
@@ -59,6 +78,59 @@ engine = create_engine(
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {} connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
) )
# Sidecar files SQLite can create next to the main DB. -journal is the default
# rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
# secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
_SQLITE_SIDECARS = ("-journal", "-wal", "-shm")
def _sqlite_db_path(url) -> Optional[str]:
"""Return the filesystem path for a file-backed SQLite URL.
SQLite query parameters such as ``mode=memory`` only affect filename
semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
file URLs must therefore remain file-backed even when they contain a query
parameter named ``mode``.
For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
local path. Other authorities are retained as UNC-style paths.
"""
if url.get_backend_name() != "sqlite":
return None
db_path = url.database
if not db_path or db_path == ":memory:":
return None
db_path = str(db_path)
query = {
str(key).lower(): str(value).strip().lower()
for key, value in dict(getattr(url, "query", {}) or {}).items()
}
uri_enabled = query.get("uri") in {"1", "true", "yes", "on"}
is_file_uri = db_path.lower().startswith("file:")
if not uri_enabled or not is_file_uri:
return db_path
if (
db_path.lower().startswith("file::memory:")
or query.get("mode") == "memory"
):
return None
parsed = urlparse(db_path)
fs_path = parsed.path or ""
if not fs_path or fs_path == ":memory:":
return None
authority = parsed.netloc
if authority and authority.lower() != "localhost":
fs_path = f"//{authority}{fs_path}"
return unquote(fs_path)
# Create session factory # Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -116,6 +188,13 @@ class Session(TimestampMixin, Base):
model = Column(String, nullable=False) model = Column(String, nullable=False)
owner = Column(String, nullable=True, index=True) # username; null = legacy/shared 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 # Configuration flags
rag = Column(Boolean, default=False) rag = Column(Boolean, default=False)
archived = Column(Boolean, default=False) archived = Column(Boolean, default=False)
@@ -208,6 +287,47 @@ class ChatMessage(Base):
Index('ix_messages_session_time', 'session_id', 'timestamp'), # Composite for efficient message retrieval 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): class Document(TimestampMixin, Base):
"""Living document that the AI can create and edit in-place.""" """Living document that the AI can create and edit in-place."""
__tablename__ = "documents" __tablename__ = "documents"
@@ -358,6 +478,93 @@ class EmailAccount(TimestampMixin, Base):
) )
class EmailAccountOwnerLock(Base):
"""Durable per-owner mutex for email-account default mutations.
Row-locking databases serialize mutations by locking this row before they
inspect or stage EmailAccount changes. SQLite uses ``BEGIN IMMEDIATE``
instead, because it ignores ``SELECT ... FOR UPDATE``; keeping the table in
the shared metadata still makes the non-SQLite path available without a
separate migration. The empty key represents the normalized legacy /
unconfigured scope shared by ``owner IS NULL`` and ``owner = ''`` rows.
"""
__tablename__ = "email_account_owner_locks"
owner_key = Column(String, primary_key=True)
_EMAIL_ACCOUNT_DEFAULT_INDEX = "ux_email_accounts_one_default_per_owner"
_EMAIL_ACCOUNT_DEFAULT_INDEX_DDL = {
"sqlite": (
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
"ON email_accounts (COALESCE(owner, '')) WHERE is_default = 1"
),
"postgresql": (
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
"ON email_accounts ((COALESCE(owner, ''))) WHERE is_default IS TRUE"
),
}
# SQLAlchemy cannot express one portable partial, functional index across the
# two supported database families. Register dialect-specific DDL so fresh
# databases get the invariant as part of create_all(); the startup migration
# below installs the same index on existing databases after normalizing legacy
# duplicate rows.
for _dialect_name, _index_ddl in _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.items():
event.listen(
EmailAccount.__table__,
"after_create",
DDL(_index_ddl).execute_if(dialect=_dialect_name),
)
def lock_email_account_owner_mutations(db, *owners: str) -> None:
"""Lock normalized email-account owner scopes in canonical order.
``NULL`` and the empty string are one legacy/single-user owner partition,
matching the unique default-account index. SQLite has only a database
writer reservation, while row-locking databases use durable mutex rows.
Sorting all requested owner keys keeps multi-owner operations such as user
rename from deadlocking with another mutation that requests the same keys
in the opposite order.
"""
from sqlalchemy.exc import IntegrityError
owner_keys = sorted({owner or "" for owner in owners} or {""})
if db.get_bind().dialect.name == "sqlite":
db.execute(text("BEGIN IMMEDIATE"))
return
for owner_key in owner_keys:
lock_row = db.get(
EmailAccountOwnerLock,
owner_key,
with_for_update=True,
)
if lock_row is not None:
continue
inserted = False
try:
with db.begin_nested():
db.add(EmailAccountOwnerLock(owner_key=owner_key))
db.flush()
inserted = True
except IntegrityError:
# A competing transaction created the mutex row first. Once its
# insert commits, lock that durable row before touching accounts.
pass
if not inserted:
(
db.query(EmailAccountOwnerLock)
.filter(EmailAccountOwnerLock.owner_key == owner_key)
.with_for_update()
.one()
)
class ModelEndpoint(TimestampMixin, Base): class ModelEndpoint(TimestampMixin, Base):
"""Admin-configured model endpoints. Models are auto-discovered via /v1/models.""" """Admin-configured model endpoints. Models are auto-discovered via /v1/models."""
__tablename__ = "model_endpoints" __tablename__ = "model_endpoints"
@@ -799,6 +1006,40 @@ def _migrate_add_owner_column():
except Exception: except Exception:
pass 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(): def _migrate_model_endpoints():
"""Recreate model_endpoints table if schema changed (url->base_url).""" """Recreate model_endpoints table if schema changed (url->base_url)."""
import sqlite3 import sqlite3
@@ -1332,8 +1573,25 @@ def _migrate_assign_legacy_owner():
with open(prefs_path, "r", encoding="utf-8") as f: with open(prefs_path, "r", encoding="utf-8") as f:
prefs = _json.load(f) prefs = _json.load(f)
if "_users" not in prefs and prefs: if "_users" not in prefs and prefs:
# Flat format → nest under admin user # Flat format → nest ordinary preferences under the admin
new_prefs = {"_users": {admin_user: prefs}} # user. Foreground fallback is an explicit per-owner opt-in,
# so auth-disabled consent must remain inert at the flat root
# rather than becoming consent for the first named owner.
foreground_keys = {
"foreground_fallback_enabled",
"foreground_model_fallbacks",
}
named_prefs = {
key: value
for key, value in prefs.items()
if key not in foreground_keys
}
new_prefs = {
key: prefs[key]
for key in foreground_keys
if key in prefs
}
new_prefs["_users"] = {admin_user: named_prefs}
with open(prefs_path, "w", encoding="utf-8") as f: with open(prefs_path, "w", encoding="utf-8") as f:
_json.dump(new_prefs, f, indent=2) _json.dump(new_prefs, f, indent=2)
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'") logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
@@ -1740,24 +1998,79 @@ class Integration(TimestampMixin, Base):
def _migrate_seed_email_account(): def _migrate_email_account_default_invariant():
"""If email_accounts is empty and settings.json has legacy flat imap_host/smtp_host """Normalize legacy duplicates and install durable at-most-one enforcement.
keys, create a single default account from them so nothing breaks for users who
upgraded. Safe to run repeatedly — it short-circuits once any row exists.""" Older databases only had a non-unique ``(owner, is_default)`` lookup index.
try: Keep the oldest default deterministically in each normalized owner scope,
with engine.connect() as conn: then add the same partial functional unique index used for fresh schemas.
tables = [r[0] for r in conn.execute(text( """
"SELECT name FROM sqlite_master WHERE type='table' AND name='email_accounts'" dialect_name = engine.dialect.name
))] index_ddl = _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.get(dialect_name)
if "email_accounts" not in tables: if index_ddl is None:
return logger.warning(
existing = conn.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0 "Email-account default uniqueness is not available for database "
if existing > 0: "dialect %s; mutations remain serialized but are not protected by "
"a database constraint",
dialect_name,
)
return return
try:
with engine.begin() as conn:
if not inspect(conn).has_table(EmailAccount.__tablename__):
return
default_rows = conn.execute(text("""
SELECT id, owner
FROM email_accounts
WHERE is_default IS TRUE
ORDER BY
COALESCE(owner, ''),
CASE WHEN created_at IS NULL THEN 1 ELSE 0 END,
created_at,
id
""")).mappings()
seen_owner_keys = set()
duplicate_ids = []
for row in default_rows:
owner_key = row["owner"] or ""
if owner_key in seen_owner_keys:
duplicate_ids.append(row["id"])
else:
seen_owner_keys.add(owner_key)
for account_id in duplicate_ids:
conn.execute(
text("UPDATE email_accounts SET is_default = :value WHERE id = :id"),
{"value": False, "id": account_id},
)
conn.execute(text(index_ddl))
if duplicate_ids:
logger.warning(
"Normalized %d duplicate default email account(s) before "
"installing %s",
len(duplicate_ids),
_EMAIL_ACCOUNT_DEFAULT_INDEX,
)
except Exception:
# Starting without the constraint would silently retain the race this
# migration is intended to close. Fail startup so an operator sees and
# can repair an incompatible schema instead of accepting unsafe writes.
logger.exception("Failed to enforce the email-account default invariant")
raise
def _migrate_seed_email_account():
"""Atomically seed one legacy default account when no account exists.
Reading settings is intentionally done before taking the owner mutex. The
decisive emptiness check and insert share one locked transaction, so two
application workers starting together cannot both seed a default row.
"""
import json as _json import json as _json
import uuid as _uuid import uuid as _uuid
from pathlib import Path
settings_file = Path(SETTINGS_FILE) settings_file = Path(SETTINGS_FILE)
if not settings_file.exists(): if not settings_file.exists():
return return
@@ -1769,11 +2082,20 @@ def _migrate_seed_email_account():
imap_host = (s.get("imap_host") or "").strip() imap_host = (s.get("imap_host") or "").strip()
smtp_host = (s.get("smtp_host") or "").strip() smtp_host = (s.get("smtp_host") or "").strip()
if not imap_host and not smtp_host: if not imap_host and not smtp_host:
return # nothing to migrate return
db = None
try:
if not inspect(engine).has_table(EmailAccount.__tablename__):
return
db = SessionLocal()
lock_email_account_owner_mutations(db, "")
existing = db.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
if existing > 0:
return
now = utcnow_naive() now = utcnow_naive()
with engine.begin() as conn: db.execute(text("""
conn.execute(text("""
INSERT INTO email_accounts INSERT INTO email_accounts
(id, owner, name, is_default, enabled, (id, owner, name, is_default, enabled,
imap_host, imap_port, imap_user, imap_password, imap_starttls, imap_host, imap_port, imap_user, imap_password, imap_starttls,
@@ -1803,9 +2125,15 @@ def _migrate_seed_email_account():
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
}) })
logging.getLogger(__name__).info("Seeded email_accounts 'Default' from settings.json") db.commit()
logger.info("Seeded email_accounts 'Default' from settings.json")
except Exception as e: except Exception as e:
logging.getLogger(__name__).warning(f"seed email account migration: {e}") if db is not None:
db.rollback()
logger.warning("seed email account migration: %s", e)
finally:
if db is not None:
db.close()
# WARNING: Foreign-key enforcement is enabled globally for all SQLite connections. # WARNING: Foreign-key enforcement is enabled globally for all SQLite connections.
@@ -1819,6 +2147,41 @@ def init_db():
""" """
_migrate_model_endpoints() _migrate_model_endpoints()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Lock the DB file (and any SQLite sidecars) to 0o600 — it holds bearer-token
# + bcrypt hashes and encrypted provider keys. POSIX only; safe_chmod no-ops
# on Windows (ACL-restricted profile dir) and the path helper returns None for
# Postgres / in-memory. Must stay AFTER create_all: the file is born here at
# the umask default, and nothing below resets the mode. The path comes from
# engine.url (SQLAlchemy's parsed URL), so a driver-qualified or query-tagged
# DATABASE_URL still resolves to the real file instead of slipping through.
db_path = _sqlite_db_path(engine.url)
if db_path is not None:
# Fail closed-loud on the main file: this is the only access control on
# it, so if the chmod genuinely fails (read-only FS, foreign owner) an
# operator should hear about it. safe_chmod also returns False as a
# Windows no-op, so guard on IS_WINDOWS to avoid a spurious warning there.
if not safe_chmod(db_path, 0o600) and not IS_WINDOWS:
logger.warning(
"Could not restrict %s to 0o600; it holds secrets and may be "
"world-readable. Check filesystem permissions and ownership.",
db_path,
)
# Re-lock any sidecars present at startup. New ones inherit the main
# file's mode (now 0o600, since we set it first), and they're usually
# absent here, but a stale -wal/-shm/-journal left by an older 0o644
# install could still expose secret pages. Absent sidecars are the
# normal case, not an error — only a failed chmod warrants a warning.
for suffix in _SQLITE_SIDECARS:
sidecar = db_path + suffix
if (
os.path.exists(sidecar)
and not safe_chmod(sidecar, 0o600)
and not IS_WINDOWS
):
logger.warning(
"Could not restrict %s to 0o600; it may expose DB pages.",
sidecar,
)
_migrate_add_hidden_models_column() _migrate_add_hidden_models_column()
_migrate_add_cached_models_column() _migrate_add_cached_models_column()
_migrate_add_pinned_models_column() _migrate_add_pinned_models_column()
@@ -1830,6 +2193,7 @@ def init_db():
_migrate_add_supports_tools_column() _migrate_add_supports_tools_column()
_migrate_add_task_run_model_column() _migrate_add_task_run_model_column()
_migrate_add_owner_column() _migrate_add_owner_column()
_migrate_add_session_endpoint_provenance_columns()
_migrate_add_document_archived_column() _migrate_add_document_archived_column()
_migrate_add_last_message_at_column() _migrate_add_last_message_at_column()
_migrate_add_folder_column() _migrate_add_folder_column()
@@ -1853,6 +2217,7 @@ def init_db():
_migrate_add_crew_member_id() _migrate_add_crew_member_id()
_migrate_add_assistant_columns() _migrate_add_assistant_columns()
_migrate_add_email_smtp_security() _migrate_add_email_smtp_security()
_migrate_email_account_default_invariant()
_migrate_seed_email_account() _migrate_seed_email_account()
_migrate_add_calendar_metadata() _migrate_add_calendar_metadata()
_migrate_add_calendar_is_utc() _migrate_add_calendar_is_utc()
@@ -1904,6 +2269,20 @@ def _migrate_chat_messages_fts():
conn = None conn = None
try: try:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
fts_content_expr_new = (
"CASE WHEN instr(COALESCE(new.content, ''), ';base64,') > 0 "
"OR instr(COALESCE(new.content, ''), 'data:image/') > 0 "
"OR instr(COALESCE(new.content, ''), 'data:audio/') > 0 "
"THEN '[inline media omitted from search index]' "
"ELSE COALESCE(new.content, '') END"
)
fts_content_expr_cm = (
"CASE WHEN instr(COALESCE(cm.content, ''), ';base64,') > 0 "
"OR instr(COALESCE(cm.content, ''), 'data:image/') > 0 "
"OR instr(COALESCE(cm.content, ''), 'data:audio/') > 0 "
"THEN '[inline media omitted from search index]' "
"ELSE COALESCE(cm.content, '') END"
)
try: try:
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)") conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)")
conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe") conn.execute("DROP TABLE IF EXISTS temp._odysseus_fts5_probe")
@@ -1912,7 +2291,7 @@ def _migrate_chat_messages_fts():
return return
conn.executescript( conn.executescript(
""" f"""
CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
content, content,
message_id UNINDEXED, message_id UNINDEXED,
@@ -1920,10 +2299,14 @@ def _migrate_chat_messages_fts():
role UNINDEXED role UNINDEXED
); );
DROP TRIGGER IF EXISTS chat_messages_fts_ai;
DROP TRIGGER IF EXISTS chat_messages_fts_ad;
DROP TRIGGER IF EXISTS chat_messages_fts_au;
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
AFTER INSERT ON chat_messages BEGIN AFTER INSERT ON chat_messages BEGIN
INSERT INTO chat_messages_fts(content, message_id, session_id, role) INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role); VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
END; END;
CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
@@ -1935,14 +2318,14 @@ def _migrate_chat_messages_fts():
AFTER UPDATE ON chat_messages BEGIN AFTER UPDATE ON chat_messages BEGIN
DELETE FROM chat_messages_fts WHERE message_id = old.id; DELETE FROM chat_messages_fts WHERE message_id = old.id;
INSERT INTO chat_messages_fts(content, message_id, session_id, role) INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (COALESCE(new.content, ''), new.id, new.session_id, new.role); VALUES ({fts_content_expr_new}, new.id, new.session_id, new.role);
END; END;
""" """
) )
conn.execute( conn.execute(
""" f"""
INSERT INTO chat_messages_fts(content, message_id, session_id, role) INSERT INTO chat_messages_fts(content, message_id, session_id, role)
SELECT COALESCE(cm.content, ''), cm.id, cm.session_id, cm.role SELECT {fts_content_expr_cm}, cm.id, cm.session_id, cm.role
FROM chat_messages cm FROM chat_messages cm
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM chat_messages_fts fts SELECT 1 FROM chat_messages_fts fts
@@ -1950,6 +2333,7 @@ def _migrate_chat_messages_fts():
) )
""" """
) )
_scrub_legacy_chat_message_fts_media(conn)
conn.commit() conn.commit()
except Exception as e: except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}") logging.getLogger(__name__).warning(f"chat_messages FTS migration failed: {e}")
@@ -1960,6 +2344,37 @@ def _migrate_chat_messages_fts():
pass pass
def _scrub_legacy_chat_message_fts_media(conn) -> None:
"""Replace already-indexed inline media rows with searchable text only."""
try:
from src.attachment_refs import search_index_text
except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub skipped: {e}")
return
try:
rows = conn.execute(
"""
SELECT id, session_id, role, content
FROM chat_messages
WHERE instr(COALESCE(content, ''), ';base64,') > 0
OR instr(COALESCE(content, ''), 'data:image/') > 0
OR instr(COALESCE(content, ''), 'data:audio/') > 0
"""
).fetchall()
for message_id, session_id, role, content in rows:
conn.execute("DELETE FROM chat_messages_fts WHERE message_id = ?", (message_id,))
conn.execute(
"""
INSERT INTO chat_messages_fts(content, message_id, session_id, role)
VALUES (?, ?, ?, ?)
""",
(search_index_text(content), message_id, session_id, role),
)
except Exception as e:
logging.getLogger(__name__).warning(f"chat_messages FTS media scrub failed: {e}")
def _migrate_add_email_smtp_security(): def _migrate_add_email_smtp_security():
"""Add explicit SMTP security mode for Proton Bridge/custom local SMTP.""" """Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
import sqlite3 import sqlite3
+37 -3
View File
@@ -3,10 +3,15 @@
import os import os
import secrets import secrets
from collections.abc import Mapping
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response 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 # Per-process token that lets the in-app tool layer hit admin-gated
@@ -15,8 +20,30 @@ from starlette.responses import Response
# same value from this module. Never persisted or exposed externally. # same value from this module. Never persisted or exposed externally.
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32) INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token" INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
# Pseudo-username on in-process tool-loopback requests; require_admin trusts it and it is reserved.
INTERNAL_TOOL_USER = "internal-tool"
def get_application_route_path(scope: Mapping[str, object]) -> str:
"""Return the application-relative path used by Starlette routing.
Uvicorn prefixes ``scope["path"]`` with a configured ASGI ``root_path``;
Starlette removes that prefix before matching routes. Middleware policy
must use the same path form or a deployment prefix can change which policy
applies to an otherwise unchanged application route.
"""
return get_route_path(scope)
def with_asgi_root_path(scope: Mapping[str, object], path: str) -> str:
"""Prefix an application path for a client-facing redirect target."""
root_path = scope.get("root_path", "")
if not isinstance(root_path, str) or not root_path:
return path
return f"{root_path.rstrip('/')}{path}"
def path_is_route_or_child(path: str, prefix: str) -> bool:
"""Return whether ``path`` is exactly ``prefix`` or below that route."""
return path == prefix or path.startswith(prefix + "/")
def is_cors_preflight(method: str, headers) -> bool: def is_cors_preflight(method: str, headers) -> bool:
@@ -33,6 +60,13 @@ def require_admin(request: Request):
Allows access when auth is explicitly disabled, or when the request carries Allows access when auth is explicitly disabled, or when the request carries
the in-process internal-tool token used by loopback agent tools. 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: # In-process bypass for tool-layer loopback calls. Two paths:
# (a) header-direct (caller set X-Odysseus-Internal-Token), or # (a) header-direct (caller set X-Odysseus-Internal-Token), or
# (b) the auth middleware already validated the token and stamped # (b) the auth middleware already validated the token and stamped
@@ -47,7 +81,7 @@ def require_admin(request: Request):
pass pass
auth_mgr = getattr(request.app.state, "auth_manager", None) auth_mgr = getattr(request.app.state, "auth_manager", None)
if os.getenv("AUTH_ENABLED", "true").lower() == "false": if auth_disabled():
return return
if not auth_mgr or not auth_mgr.is_configured: if not auth_mgr or not auth_mgr.is_configured:
raise HTTPException(403, "Admin only") raise HTTPException(403, "Admin only")
+56 -5
View File
@@ -8,6 +8,12 @@ These are simple datacontainers. All persistence is handled by SessionManager.
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Any, Optional, TYPE_CHECKING from typing import Dict, List, Any, Optional, TYPE_CHECKING
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
)
from src.message_metadata import sanitize_projected_message_metadata
from src.tool_approval_provenance import has_chat_session_approval_grant
if TYPE_CHECKING: if TYPE_CHECKING:
from .session_manager import SessionManager from .session_manager import SessionManager
@@ -31,6 +37,18 @@ set_session_manager = set_session_manager_instance
get_session_manager = get_session_manager_instance get_session_manager = get_session_manager_instance
def _history_grants_chat_session_approval(
history: List["ChatMessage"],
session_id: str,
) -> bool:
"""Compatibility shim: durable history is never an authority source.
Keep the old private symbol for downstream imports, but deliberately return
false. The live projection checks the separate server-owned grant table.
"""
return False
@dataclass @dataclass
class ChatMessage: class ChatMessage:
"""A single chat message.""" """A single chat message."""
@@ -72,6 +90,8 @@ class Session:
headers: Optional[Dict[str, str]] = None headers: Optional[Dict[str, str]] = None
history: List[ChatMessage] = None history: List[ChatMessage] = None
owner: Optional[str] = None owner: Optional[str] = None
model_endpoint_id: Optional[str] = None
endpoint_provenance: Optional[str] = None
is_important: bool = False is_important: bool = False
message_count: int = 0 message_count: int = 0
@@ -116,11 +136,42 @@ class Session:
the model. Display/history-load paths use the raw ``history`` and are the model. Display/history-load paths use the raw ``history`` and are
unaffected. unaffected.
""" """
return [ messages = []
msg.to_dict() for msg in self.history:
for msg in self.history raw_metadata = getattr(msg, "metadata", None)
if (msg.metadata or {}).get("source") != "slash" 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
# compaction/trimming preserves it. Copy the metadata instead of
# mutating the durable transcript object.
for index in range(len(messages) - 1, -1, -1):
if messages[index].get("role") != "user":
continue
message = dict(messages[index])
metadata = dict(message.get("metadata") or {})
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
message["metadata"] = metadata
messages[index] = message
break
return messages
def get(self, key: str, default=None): def get(self, key: str, default=None):
"""Dict-like access for compatibility.""" """Dict-like access for compatibility."""
+179 -34
View File
@@ -14,8 +14,12 @@ import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Optional from typing import Dict, Optional
from sqlalchemy import func
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
from .models import Session, ChatMessage from .models import Session, ChatMessage
from src.attachment_refs import persistable_message_content
from src.upload_handler import reserve_message_upload_references
# Re-export singleton accessors from models for convenience # Re-export singleton accessors from models for convenience
from .models import set_session_manager_instance, get_session_manager_instance from .models import set_session_manager_instance, get_session_manager_instance
@@ -58,6 +62,22 @@ def _parse_msg_content(raw):
return 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: class SessionManager:
""" """
Manages chat sessions with database persistence. Manages chat sessions with database persistence.
@@ -72,6 +92,7 @@ class SessionManager:
def __init__(self, sessions_file: str = None): def __init__(self, sessions_file: str = None):
# sessions_file kept for backward compat, not used # sessions_file kept for backward compat, not used
self.sessions: Dict[str, Session] = {} self.sessions: Dict[str, Session] = {}
self.upload_handler = None
self.load_sessions() self.load_sessions()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -89,14 +110,28 @@ class SessionManager:
try: try:
db_sessions = db.query(DbSession).filter( db_sessions = db.query(DbSession).filter(
DbSession.archived == False, DbSession.archived == False,
DbSession.message_count > 0, DbSession.messages.any(),
).order_by(DbSession.last_accessed.desc()).limit(100).all() ).order_by(DbSession.last_accessed.desc()).limit(100).all()
# message_count is derived metadata and can drift after interrupted
# or legacy writes. Count only the bounded discovery set so startup
# remains metadata-only while lazy hydration sees an authoritative
# positive count for every discovered non-empty session.
message_counts = {}
if db_sessions:
message_counts = dict(
db.query(DbChatMessage.session_id, func.count(DbChatMessage.id))
.filter(DbChatMessage.session_id.in_([row.id for row in db_sessions]))
.group_by(DbChatMessage.session_id)
.all()
)
loaded_count = 0 loaded_count = 0
for db_session in db_sessions: for db_session in db_sessions:
try: try:
session = self._db_to_session_meta(db_session) session = self._db_to_session_meta(db_session)
if session is not None: if session is not None:
session.message_count = message_counts[db_session.id]
self.sessions[db_session.id] = session self.sessions[db_session.id] = session
loaded_count += 1 loaded_count += 1
except Exception as e: except Exception as e:
@@ -130,6 +165,8 @@ class SessionManager:
headers=headers, headers=headers,
history=[], history=[],
owner=getattr(db_session, "owner", None), 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, is_important=getattr(db_session, "is_important", False) or False,
) )
session.message_count = getattr(db_session, "message_count", 0) or 0 session.message_count = getattr(db_session, "message_count", 0) or 0
@@ -142,8 +179,7 @@ class SessionManager:
# Try relationship first, then direct query # Try relationship first, then direct query
if db_session.messages: if db_session.messages:
for db_msg in db_session.messages: for db_msg in db_session.messages:
meta = json.loads(db_msg.meta_data) if db_msg.meta_data else {} meta = _parse_message_metadata(db_msg.meta_data)
if meta is None: meta = {}
meta['_db_id'] = db_msg.id meta['_db_id'] = db_msg.id
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp)) meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
history.append(ChatMessage( history.append(ChatMessage(
@@ -157,8 +193,7 @@ class SessionManager:
).order_by(DbChatMessage.timestamp).all() ).order_by(DbChatMessage.timestamp).all()
for db_msg in db_messages: for db_msg in db_messages:
meta = json.loads(db_msg.meta_data) if db_msg.meta_data else {} meta = _parse_message_metadata(db_msg.meta_data)
if meta is None: meta = {}
meta['_db_id'] = db_msg.id meta['_db_id'] = db_msg.id
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp)) meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
history.append(ChatMessage( history.append(ChatMessage(
@@ -188,10 +223,17 @@ class SessionManager:
headers=headers, headers=headers,
history=history, history=history,
owner=getattr(db_session, 'owner', None), 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, is_important=getattr(db_session, 'is_important', False) or False,
) )
session.message_count = getattr(db_session, 'message_count', len(history)) # The rows just loaded are the whole transcript, so they — not the
# denormalized sessions.message_count column — are the truth for this
# cached object. get_session's hydration gate compares against this
# number; seeding it from a drifted column would ask for a reload that
# can never close the gap.
session.message_count = len(history)
return session return session
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -230,17 +272,28 @@ class SessionManager:
logger.warning("Dropping message for deleted session %s", session_id) logger.warning("Dropping message for deleted session %s", session_id)
return 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),
message.content,
message.metadata,
)
if missing_upload_id:
raise ValueError(
f"Referenced upload is no longer available: {missing_upload_id}"
)
msg_id = str(uuid.uuid4()) msg_id = str(uuid.uuid4())
msg_time = datetime.utcnow() msg_time = datetime.utcnow()
if message.metadata is None: if message.metadata is None:
message.metadata = {} message.metadata = {}
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time)) message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
# Multimodal content (image/audio attachments) is a list — serialize # Multimodal content may contain provider data URLs for the live
# to JSON so the Text column can store it. On reload, _db_to_session # model call. Persist only readable text plus attachment references
# detects the JSON-array prefix and parses it back. # so chat_messages/FTS do not duplicate upload bytes.
_content = message.content _content = persistable_message_content(message.content, message.metadata)
if isinstance(_content, list):
_content = json.dumps(_content)
db_message = DbChatMessage( db_message = DbChatMessage(
id=msg_id, id=msg_id,
session_id=session_id, session_id=session_id,
@@ -322,6 +375,30 @@ class SessionManager:
session = self.get_session(session_id) session = self.get_session(session_id)
db = SessionLocal() db = SessionLocal()
try: try:
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session is None:
logger.warning("Cannot replace history for missing session %s", session_id)
return False
# Reserve every incoming attachment before removing any durable
# message row. reserve_upload() shares the upload lifecycle lock
# with cleanup, so an upload cannot be deleted between this
# 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),
message.content,
message.metadata,
)
if missing_upload_id:
raise ValueError(
f"Referenced upload is no longer available: {missing_upload_id}"
)
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete() db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for i, message in enumerate(messages): for i, message in enumerate(messages):
@@ -330,15 +407,9 @@ class SessionManager:
id=msg_id, id=msg_id,
session_id=session_id, session_id=session_id,
role=message.role, role=message.role,
# Multimodal content (image/audio attachments) is a list; # Mirrors _persist_message: keep raw media bytes out of the
# serialize to JSON so the Text column round-trips via # persisted transcript and search index.
# _parse_msg_content. Storing the raw list let SQLAlchemy content=persistable_message_content(message.content, message.metadata),
# bind its single-quoted repr, which _parse_msg_content
# cannot parse (it looks for double-quoted "type"), so the
# attachment was destroyed on reload. Mirrors _persist_message.
content=(json.dumps(message.content)
if isinstance(message.content, list)
else message.content),
meta_data=json.dumps(message.metadata) if message.metadata else None, meta_data=json.dumps(message.metadata) if message.metadata else None,
timestamp=now + timedelta(microseconds=i), timestamp=now + timedelta(microseconds=i),
) )
@@ -347,8 +418,6 @@ class SessionManager:
message.metadata = {} message.metadata = {}
message.metadata["_db_id"] = msg_id message.metadata["_db_id"] = msg_id
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
if db_session:
db_session.message_count = len(messages) db_session.message_count = len(messages)
db_session.updated_at = now db_session.updated_at = now
db_session.last_accessed = now db_session.last_accessed = now
@@ -372,30 +441,50 @@ class SessionManager:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def get_session(self, session_id: str) -> Session: def get_session(self, session_id: str) -> Session:
"""Get a session by ID, loading from DB if needed. """Get a session by ID, loading complete DB history when needed.
Sessions seeded by `load_sessions` start with empty history. The Sessions seeded by ``load_sessions`` start with empty history, and a
first read here hydrates them with the message rows. cached session can also become partially stale. Refresh metadata first,
then hydrate whenever the cached transcript is short of the stored rows.
Model-send routes enter through this method before building context,
while paginated display history reads SQLite directly.
The gate compares against ``sync_session_metadata``'s reconciled count
(the real ``chat_messages`` total), never the denormalized column, so a
hydrate always closes the gap and the next read is a cache hit.
""" """
if session_id not in self.sessions: if session_id not in self.sessions:
self._load_session_from_db(session_id) self._load_session_from_db(session_id)
else:
cached = self.sessions[session_id]
# Lazy hydrate: metadata-only entries get their messages on first read.
if not cached.history and getattr(cached, "message_count", 0) > 0:
self._load_session_from_db(session_id)
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the # Keep model/endpoint metadata fresh. Endpoint deletion can clear the
# DB row while a session object is still cached in RAM. # DB row while a session object is still cached in RAM. Refreshing first
# also exposes the authoritative message count before completeness is
# checked.
self.sync_session_metadata(session_id) self.sync_session_metadata(session_id)
cached = self.sessions[session_id]
cached_count = len(cached.history or [])
stored_count = int(getattr(cached, "message_count", 0) or 0)
if cached_count < stored_count:
self._load_session_from_db(session_id)
# Update last_accessed # Update last_accessed
self._touch_session(session_id) self._touch_session(session_id)
return self.sessions[session_id] return self.sessions[session_id]
def sync_session_metadata(self, session_id: str) -> bool: def sync_session_metadata(self, session_id: str) -> bool:
"""Refresh non-message session fields from the DB into the cached object.""" """Refresh non-message session fields from the DB into the cached object.
``message_count`` is reconciled against the real ``chat_messages`` rows
rather than copied from the denormalized ``sessions.message_count``
column. That column drifts in normal operation — ``_persist_message``
swallows a failed insert but ``add_message`` has already appended in
memory, so the next successful persist writes rows+1, and a persist for
an uncached session writes 0. Hydration keys off this number: a
drifted-high column would reload the whole transcript on every warm
read, and a drifted-low one would leave the model a truncated one.
"""
session = self.sessions.get(session_id) session = self.sessions.get(session_id)
if session is None: if session is None:
return False return False
@@ -417,8 +506,14 @@ class SessionManager:
session.rag = db_session.rag session.rag = db_session.rag
session.archived = db_session.archived session.archived = db_session.archived
session.owner = getattr(db_session, "owner", None) 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.is_important = getattr(db_session, "is_important", False) or False
session.message_count = getattr(db_session, "message_count", session.message_count) or 0 session.message_count = (
db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id)
.count()
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error syncing session metadata {session_id}: {e}") logger.error(f"Error syncing session metadata {session_id}: {e}")
@@ -513,10 +608,60 @@ class SessionManager:
finally: finally:
db.close() 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: def delete_session(self, session_id: str) -> bool:
"""Permanently delete a session and all its messages.""" """Permanently delete a session and all its messages."""
db = SessionLocal() db = SessionLocal()
try: try:
try:
from src.session_image_cleanup import cleanup_session_images
cleanup_session_images(session_id, db=db)
except Exception as e:
logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}")
# Detach documents so they survive as orphans in the library # Detach documents so they survive as orphans in the library
db.query(DbDocument).filter(DbDocument.session_id == session_id).update( db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
{DbDocument.session_id: None}, synchronize_session=False {DbDocument.session_id: None}, synchronize_session=False
+16 -1
View File
@@ -46,10 +46,11 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db} - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true} - AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false} - LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin} - ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-} - ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false} - SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-} - EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-} - EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-} - EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
@@ -67,9 +68,18 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-} - TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-} - SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before # PUID / PGID — the user/group the container drops to before
@@ -125,12 +135,17 @@ services:
fi fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh exec /usr/local/searxng/entrypoint.sh
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
volumes: volumes:
- searxng-data:/etc/searxng - searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment: environment:
- SEARXNG_BASE_URL=http://localhost:8080/ - SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-} - SEARXNG_SECRET=${SEARXNG_SECRET:-}
+16 -1
View File
@@ -45,10 +45,11 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db} - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true} - AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false} - LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin} - ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-} - ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false} - SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-} - EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-} - EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-} - EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
@@ -66,9 +67,18 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-} - TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-} - SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before # PUID / PGID — the user/group the container drops to before
@@ -128,12 +138,17 @@ services:
fi fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh exec /usr/local/searxng/entrypoint.sh
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
volumes: volumes:
- searxng-data:/etc/searxng - searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment: environment:
- SEARXNG_BASE_URL=http://localhost:8080/ - SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-} - SEARXNG_SECRET=${SEARXNG_SECRET:-}
+16 -1
View File
@@ -34,10 +34,11 @@ services:
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db} - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
- AUTH_ENABLED=${AUTH_ENABLED:-true} - AUTH_ENABLED=${AUTH_ENABLED:-true}
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false} - LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin} - ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-} - ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
- SECURE_COOKIES=${SECURE_COOKIES:-false} - SECURE_COOKIES=${SECURE_COOKIES:-}
- EMBEDDING_URL=${EMBEDDING_URL:-} - EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-} - EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-} - EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
@@ -55,9 +56,18 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
# Externally reachable origin for MCP OAuth callbacks. The container
# always listens on 7000 and cannot see the host port map above, so
# remote MCP OAuth needs this set whenever the browser reaches
# Odysseus on anything other than http://localhost:7000.
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
- TAVILY_API_KEY=${TAVILY_API_KEY:-} - TAVILY_API_KEY=${TAVILY_API_KEY:-}
- SERPER_API_KEY=${SERPER_API_KEY:-} - SERPER_API_KEY=${SERPER_API_KEY:-}
# PUID / PGID — the user/group the container drops to before # PUID / PGID — the user/group the container drops to before
@@ -106,12 +116,17 @@ services:
fi fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh exec /usr/local/searxng/entrypoint.sh
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
volumes: volumes:
- searxng-data:/etc/searxng - searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment: environment:
- SEARXNG_BASE_URL=http://localhost:8080/ - SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-} - SEARXNG_SECRET=${SEARXNG_SECRET:-}
+4
View File
@@ -163,6 +163,10 @@ if (Test-Path $cudaBase) {
} }
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH) # 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
# -Port only reaches uvicorn as a flag. Everything that builds a URL for this
# instance - internal_api_base(), companion pairing, the MCP OAuth callback -
# reads APP_PORT, so set it too or they all assume 7000.
$env:APP_PORT = $Port
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port) Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
Write-Host "Press Ctrl+C to stop." Write-Host "Press Ctrl+C to stop."
Write-Host "" Write-Host ""
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+369 -14
View File
@@ -24,6 +24,7 @@ from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
import uuid import uuid
from contextvars import ContextVar from contextvars import ContextVar
from urllib.parse import parse_qs, unquote, urlparse
from mcp.server import Server from mcp.server import Server
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
@@ -129,20 +130,36 @@ def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
return _has_owner_scoped_accounts(rows) return _has_owner_scoped_accounts(rows)
def _load_email_writing_style() -> str: def _load_email_writing_style(account: str | None = None) -> str:
"""Return the existing Settings > Email > Writing Style value.""" """Return the saved Settings > Email > Writing Style value.
Prefer the selected account's style when one exists; fall back to the
legacy global style so older installs keep behaving as before.
"""
try: try:
settings_path = DATA_DIR / "settings.json" settings_path = DATA_DIR / "settings.json"
if not settings_path.exists(): if not settings_path.exists():
return "" return ""
settings = json.loads(settings_path.read_text(encoding="utf-8")) settings = json.loads(settings_path.read_text(encoding="utf-8"))
account_id = ""
if account:
try:
cfg = _load_config(account)
account_id = str(cfg.get("account_id") or account or "").strip()
except Exception:
account_id = str(account or "").strip()
by_account = settings.get("email_writing_styles_by_account") or {}
if account_id and isinstance(by_account, dict):
style = by_account.get(account_id)
if isinstance(style, str) and style.strip():
return style.strip()
return str(settings.get("email_writing_style") or "").strip() return str(settings.get("email_writing_style") or "").strip()
except Exception: except Exception:
return "" return ""
def _writing_style_guidance() -> str: def _writing_style_guidance(account: str | None = None) -> str:
style = _load_email_writing_style() style = _load_email_writing_style(account)
if not style: if not style:
return ( return (
"No saved writing style is configured in Settings > Email > Writing Style. " "No saved writing style is configured in Settings > Email > Writing Style. "
@@ -509,6 +526,251 @@ def _decode_header(raw):
return "".join(decoded) return "".join(decoded)
def _uid_from_fetch_meta(meta_b: bytes) -> str:
m = re.search(rb"UID\s+(\d+)", meta_b or b"")
return m.group(1).decode("ascii", errors="ignore") if m else ""
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
raw = str(value or "").strip()
if not raw:
return []
pieces = re.findall(r"<([^>]+)>", raw)
if not pieces:
pieces = [p.strip() for p in raw.split(",") if p.strip()]
out: list[dict] = []
seen = set()
for piece in pieces:
target = piece.strip().strip("<>").strip()
if not target:
continue
parsed = urlparse(target)
scheme = parsed.scheme.lower()
key = target.lower()
if key in seen:
continue
seen.add(key)
if scheme == "mailto":
addr = unquote(parsed.path or "").strip()
if not addr or "\r" in addr or "\n" in addr:
continue
query = parse_qs(parsed.query or "", keep_blank_values=True)
subject = unquote((query.get("subject") or ["unsubscribe"])[0] or "unsubscribe")
body = unquote((query.get("body") or ["unsubscribe"])[0] or "unsubscribe")
subject = re.sub(r"[\r\n]+", " ", subject).strip() or "unsubscribe"
body = re.sub(r"[\r\n]+", "\n", body).strip() or "unsubscribe"
out.append({
"kind": "mailto",
"target": addr,
"subject": subject[:200],
"body": body[:1000],
"executable": True,
})
elif scheme in {"http", "https"}:
out.append({
"kind": "url",
"target": target,
"executable": False,
})
return out
def _email_unsubscribe_candidate_from_msg(msg, uid: str, folder: str) -> dict | None:
sender = _decode_header(msg.get("From", ""))
sender_name, sender_addr = email.utils.parseaddr(sender)
subject = _decode_header(msg.get("Subject", "(no subject)"))
list_id = _decode_header(msg.get("List-Id", ""))
precedence = (msg.get("Precedence") or "").strip().lower()
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
methods = _parse_list_unsubscribe_header(msg.get("List-Unsubscribe"))
if not methods:
return None
reasons: list[str] = ["has unsubscribe header"]
score = 45
if list_id:
score += 20
reasons.append("mailing-list header")
if precedence in {"bulk", "junk", "list"}:
score += 20
reasons.append(f"precedence={precedence}")
if auto_submitted and auto_submitted != "no":
score += 10
reasons.append(f"auto-submitted={auto_submitted}")
if re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", (subject or "").lower()):
score += 10
reasons.append("promotional subject")
executable = [m for m in methods if m.get("executable")]
return {
"uid": str(uid),
"folder": folder,
"message_id": (msg.get("Message-ID") or "").strip(),
"subject": subject,
"from_name": sender_name or sender_addr,
"from_address": sender_addr,
"list_id": list_id,
"score": min(score, 100),
"reasons": reasons[:5],
"methods": methods,
"can_execute": bool(executable),
"recommended_method": executable[0] if executable else methods[0],
}
def _unsubscribe_candidate_dedupe_key(candidate: dict) -> tuple[str, str, str]:
list_id = str(candidate.get("list_id") or "").strip().lower()
method = candidate.get("recommended_method") or {}
method_kind = str(method.get("kind") or "").strip().lower()
method_target = str(method.get("target") or "").strip().lower()
sender = str(candidate.get("from_address") or "").strip().lower()
if list_id:
return ("list", list_id, method_target or sender)
if method_target:
return ("method", method_kind, method_target)
return ("sender", sender, str(candidate.get("subject") or "").strip().lower())
def _dedupe_unsubscribe_candidates(candidates: list[dict]) -> list[dict]:
deduped: dict[tuple[str, str, str], dict] = {}
for candidate in candidates or []:
key = _unsubscribe_candidate_dedupe_key(candidate)
existing = deduped.get(key)
if not existing:
copy = dict(candidate)
copy["duplicate_count"] = 1
copy["duplicate_uids"] = [str(candidate.get("uid") or "")]
deduped[key] = copy
continue
existing["duplicate_count"] = int(existing.get("duplicate_count") or 1) + 1
uid = str(candidate.get("uid") or "")
if uid:
existing.setdefault("duplicate_uids", []).append(uid)
if int(candidate.get("score") or 0) > int(existing.get("score") or 0):
keep_count = existing.get("duplicate_count")
keep_uids = existing.get("duplicate_uids")
replacement = dict(candidate)
replacement["duplicate_count"] = keep_count
replacement["duplicate_uids"] = keep_uids
deduped[key] = replacement
return list(deduped.values())
def _scan_unsubscribe_candidates(folder="INBOX", account=None, limit=25, max_scan=150) -> dict:
limit = max(1, min(int(limit or 25), 100))
max_scan = max(limit, min(int(max_scan or 150), 500))
folder = folder or "INBOX"
candidates: list[dict] = []
conn = _imap_connect(account)
try:
status, _ = conn.select(_q(folder), readonly=True)
if status != "OK":
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
status, data = conn.uid("SEARCH", None, "ALL")
if status != "OK" or not data or not data[0]:
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
uids = []
for raw_uid in data[0].split():
try:
uids.append(int(raw_uid))
except Exception:
continue
uids = sorted(uids, reverse=True)[:max_scan]
if not uids:
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
status, msg_data = conn.uid("FETCH", _b(",".join(str(u) for u in uids)), "(UID RFC822.HEADER)")
finally:
try:
conn.logout()
except Exception:
pass
if status != "OK":
return {"success": False, "error": "Failed to fetch email headers", "candidates": []}
for item in msg_data or []:
if not isinstance(item, tuple) or len(item) < 2:
continue
meta_b = item[0] if isinstance(item[0], bytes) else str(item[0]).encode()
uid = _uid_from_fetch_meta(meta_b)
if not uid:
continue
try:
msg = email.message_from_bytes(item[1] or b"")
except Exception:
continue
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
if candidate:
candidates.append(candidate)
raw_total = len(candidates)
candidates = _dedupe_unsubscribe_candidates(candidates)
candidates.sort(key=lambda c: (int(c.get("score") or 0), int(c.get("duplicate_count") or 1), int(c.get("uid") or 0)), reverse=True)
return {
"success": True,
"candidates": candidates[:limit],
"total": len(candidates),
"raw_total": raw_total,
"scanned": len(uids),
"folder": folder,
"account": account or "",
}
def _unsubscribe_email(uid, folder="INBOX", account=None, method_index=0, allow_web=False) -> dict:
uid = str(uid or "").strip()
if not uid:
return {"success": False, "error": "uid is required"}
conn = _imap_connect(account)
try:
status, _ = conn.select(_q(folder), readonly=True)
if status != "OK":
return {"success": False, "error": f"Folder not found: {folder}"}
status, msg_data = conn.uid("FETCH", _b(uid), "(UID RFC822.HEADER)")
finally:
try:
conn.logout()
except Exception:
pass
if status != "OK" or not msg_data:
return {"success": False, "error": f"Email not found: {uid}"}
raw_header = b""
for item in msg_data or []:
if isinstance(item, tuple) and len(item) >= 2:
raw_header = item[1] or b""
break
msg = email.message_from_bytes(raw_header)
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
if not candidate:
return {"success": False, "error": "No List-Unsubscribe header found"}
methods = candidate.get("methods") or []
method_index = int(method_index or 0)
method = methods[method_index] if 0 <= method_index < len(methods) else (candidate.get("recommended_method") or methods[0])
if method.get("kind") == "url":
return {
"success": False,
"requires_browser": True,
"url": method.get("target"),
"candidate": candidate,
"instructions": (
"This unsubscribe is a web link. Ask the user for approval, then use the browser/web tool "
"to open the exact URL and complete the unsubscribe page. Do not fetch unrelated links."
),
}
if method.get("kind") != "mailto" or not method.get("executable"):
return {"success": False, "error": "Unsupported unsubscribe method", "candidate": candidate}
result = _send_email(
to=method.get("target"),
subject=method.get("subject") or "unsubscribe",
body=method.get("body") or "unsubscribe",
account=account,
)
if "error" in result:
return {"success": False, "error": result["error"], "candidate": candidate}
return {
"success": True,
"method": method,
"candidate": candidate,
"send_result": result,
"pending": bool(result.get("pending")),
}
def _extract_text(msg): def _extract_text(msg):
"""Extract plain text body from email message.""" """Extract plain text body from email message."""
if msg.is_multipart(): if msg.is_multipart():
@@ -1540,14 +1802,12 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
from src.endpoint_resolver import ( from src.endpoint_resolver import (
resolve_endpoint, resolve_endpoint,
resolve_utility_fallback_candidates, resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
) )
from src.llm_core import llm_call_async_with_fallback from src.llm_core import llm_call_async_with_fallback
except Exception as exc: except Exception as exc:
return {"error": f"AI reply helpers unavailable: {exc}"} return {"error": f"AI reply helpers unavailable: {exc}"}
settings = _load_settings() style = _load_email_writing_style(account)
style = settings.get("email_writing_style", "")
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
if style: if style:
system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}" system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}"
@@ -1582,13 +1842,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
utility_fallbacks = resolve_utility_fallback_candidates() or [] utility_fallbacks = resolve_utility_fallback_candidates() or []
for cand in utility_fallbacks: for cand in utility_fallbacks:
_add(*cand) _add(*cand)
try:
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
except TypeError:
chat_fallbacks = resolve_chat_fallback_candidates() or []
for cand in chat_fallbacks:
_add(*cand)
if not candidates: if not candidates:
return {"error": "No LLM endpoint configured for AI reply"} return {"error": "No LLM endpoint configured for AI reply"}
@@ -1890,6 +2143,45 @@ async def list_tools() -> list[Tool]:
"required": [], "required": [],
}, },
), ),
Tool(
name="scan_email_unsubscribes",
description=(
"Scan recent email headers for likely spam/newsletter unsubscribe candidates. "
"Returns reviewable candidates with UID, sender, subject, score, reasons, and "
"List-Unsubscribe methods. This does not unsubscribe anything. For mailto "
"methods, use unsubscribe_email after user approval. For web URL methods, use "
"browser/web tools after user approval to open the exact URL and complete the page."
),
inputSchema={
"type": "object",
"properties": {
"folder": {"type": "string", "description": "IMAP folder to scan", "default": "INBOX"},
"limit": {"type": "integer", "description": "Maximum candidates to return", "default": 25},
"max_scan": {"type": "integer", "description": "How many newest messages to inspect", "default": 150},
**ACCOUNT_PROP,
},
"required": [],
},
),
Tool(
name="unsubscribe_email",
description=(
"Execute one approved unsubscribe action for an email UID. Supports safe mailto "
"List-Unsubscribe directly. If the selected method is a web URL, this returns "
"requires_browser with the exact URL; use browser/web tools only after user approval."
),
inputSchema={
"type": "object",
"properties": {
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
"folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"},
"method_index": {"type": "integer", "description": "Unsubscribe method index from scan_email_unsubscribes", "default": 0},
"allow_web": {"type": "boolean", "description": "Return web unsubscribe URL instructions when the method is URL", "default": False},
**ACCOUNT_PROP,
},
"required": ["uid"],
},
),
Tool( Tool(
name="download_attachment", name="download_attachment",
description=( description=(
@@ -2264,6 +2556,69 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
lines.append(line) lines.append(line)
return [TextContent(type="text", text="\n\n".join(lines))] return [TextContent(type="text", text="\n\n".join(lines))]
elif name == "scan_email_unsubscribes":
try:
result = _scan_unsubscribe_candidates(
folder=arguments.get("folder", "INBOX"),
account=acct,
limit=arguments.get("limit", 25),
max_scan=arguments.get("max_scan", 150),
)
except Exception as e:
return [TextContent(type="text", text=f"Unsubscribe scan failed: {e}")]
if not result.get("success"):
return [TextContent(type="text", text=f"Unsubscribe scan failed: {result.get('error', 'unknown error')}")]
candidates = result.get("candidates") or []
if not candidates:
return [TextContent(type="text", text=f"No unsubscribe candidates found in {result.get('scanned', 0)} recent emails.")]
lines = [
f"Found {len(candidates)} unsubscribe candidate(s) from {result.get('scanned', 0)} recent emails.",
"Review these with the user before executing. Mailto methods can use unsubscribe_email; URL methods require browser/web tools after approval.\n",
]
for i, cand in enumerate(candidates, 1):
lines.append(
f"{i}. **{cand.get('subject') or '(no subject)'}**\n"
f" From: {cand.get('from_name') or cand.get('from_address') or ''} ({cand.get('from_address') or ''})\n"
f" UID: {cand.get('uid')} Folder: {cand.get('folder')}\n"
f" Score: {cand.get('score')} Matching emails: {cand.get('duplicate_count', 1)} Reasons: {', '.join(cand.get('reasons') or [])}"
)
for j, method in enumerate(cand.get("methods") or []):
if method.get("kind") == "mailto":
lines.append(f" Method {j}: mailto {method.get('target')} (executable via unsubscribe_email)")
elif method.get("kind") == "url":
lines.append(f" Method {j}: web URL {method.get('target')} (use browser/web tools after approval)")
return [TextContent(type="text", text="\n".join(lines))]
elif name == "unsubscribe_email":
result = _unsubscribe_email(
uid=arguments.get("uid"),
folder=arguments.get("folder", "INBOX"),
account=acct,
method_index=arguments.get("method_index", 0),
allow_web=bool(arguments.get("allow_web", False)),
)
if result.get("requires_browser"):
return [TextContent(
type="text",
text=(
"Web unsubscribe requires browser/web navigation.\n"
f"URL: {result.get('url')}\n"
f"{result.get('instructions')}"
),
)]
if not result.get("success"):
return [TextContent(type="text", text=f"Unsubscribe failed: {result.get('error', 'unknown error')}")]
method = result.get("method") or {}
if result.get("pending"):
return [TextContent(
type="text",
text=(
f"Unsubscribe email staged for approval to {method.get('target')}. "
"Nothing has been sent until the user approves the pending email."
),
)]
return [TextContent(type="text", text=f"Unsubscribe email sent to {method.get('target')}.")]
elif name == "download_attachment": elif name == "download_attachment":
uid = arguments.get("uid") uid = arguments.get("uid")
index = arguments.get("index") index = arguments.get("index")
+6
View File
@@ -81,6 +81,12 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if not model_spec: if not model_spec:
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")] return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
try:
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, model_type="image")
except ValueError:
_lower_model_spec = model_spec.lower()
if not any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e")):
raise
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec) url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec)
is_gpt_image = "gpt-image" in model_id.lower() is_gpt_image = "gpt-image" in model_id.lower()
+21 -3
View File
@@ -17,6 +17,8 @@ from mcp.types import Tool, TextContent
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.memory import MemoryStoreUnreadable
server = Server("memory") server = Server("memory")
# Late-initialized managers (set during first tool call) # Late-initialized managers (set during first tool call)
@@ -29,6 +31,10 @@ _OWNER_SCOPE_ERROR = (
"Error: Memory MCP owner is not configured for an owner-scoped memory store. " "Error: Memory MCP owner is not configured for an owner-scoped memory store. "
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool." "Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
) )
_UNREADABLE_STORE_ERROR = (
"Error: Memory store is temporarily unreadable — nothing was saved. "
"Repair or restore memory.json, then retry."
)
def _configured_owner() -> str | None: def _configured_owner() -> str | None:
@@ -51,8 +57,20 @@ def _owner_scoped_store(entries: list[dict]) -> bool:
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict)) return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]: def _scope_entries(for_update: bool = False) -> tuple[str | None, list[dict], list[dict], str | None]:
"""Return configured owner, all entries, visible entries, and optional error.""" """Return configured owner, all entries, visible entries, and optional error.
``for_update=True`` is for read-modify-write callers. They save the ``all
entries`` list back, so an unreadable store must be reported as an error
instead of degrading to ``[]`` — otherwise the save writes their one new
entry over the whole store (issue #5673).
"""
if for_update:
try:
entries = _memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
return None, [], [], f"{_UNREADABLE_STORE_ERROR} ({e})"
else:
entries = _memory_manager.load_all() entries = _memory_manager.load_all()
owner = _configured_owner() owner = _configured_owner()
if owner is None and _owner_scoped_store(entries): if owner is None and _owner_scoped_store(entries):
@@ -161,7 +179,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
category = arguments.get("category", "fact") category = arguments.get("category", "fact")
if not text: if not text:
return _text_result("Error: Memory text cannot be empty") return _text_result("Error: Memory text cannot be empty")
owner, memories, _visible, scope_error = _scope_entries() owner, memories, _visible, scope_error = _scope_entries(for_update=True)
if scope_error: if scope_error:
return _text_result(scope_error) return _text_result(scope_error)
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner) entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
+4 -4
View File
@@ -5,13 +5,13 @@
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"@antithesishq/bombadil": "^0.6.1" "@antithesishq/bombadil": "^0.7.0"
} }
}, },
"node_modules/@antithesishq/bombadil": { "node_modules/@antithesishq/bombadil": {
"version": "0.6.1", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.6.1.tgz", "resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.0.tgz",
"integrity": "sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA==", "integrity": "sha512-alJmnphJ/iUoL5mCsnV3DwtajGy/sEQ3NJJCiMhgjqXshSq2BUtAs0vqdXEiiSkB8HbsOX5CLrAcaogYdwfAJg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git" "url": "https://github.com/odysseus-dev/odysseus.git"
}, },
"devDependencies": { "devDependencies": {
"@antithesishq/bombadil": "^0.6.1" "@antithesishq/bombadil": "^0.7.0"
} }
} }
+10
View File
@@ -12,6 +12,16 @@
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise. # GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
faster-whisper faster-whisper
# Local text-to-speech via Kokoro-82M for the "local" TTS provider.
# Kokoro 0.9.4 declares Python >=3.10,<3.13; Odysseus itself requires 3.11+,
# so pip installs these extras on 3.11-3.12 and deliberately skips them on
# Python 3.13+ (including the Python 3.14 container image). Kokoro declares
# torch; the local provider still
# requires a CUDA-enabled torch build and GPU at runtime. SoundFile is separate
# in Kokoro's official install instructions and is not a transitive dependency.
kokoro==0.9.4; python_version >= "3.11" and python_version < "3.13"
soundfile; python_version >= "3.11" and python_version < "3.13"
# DuckDuckGo as a search provider option. # DuckDuckGo as a search provider option.
# Install if you want DDG in the search-provider dropdown. # Install if you want DDG in the search-provider dropdown.
# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE. # Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE.
+4 -1
View File
@@ -38,7 +38,10 @@ python-dateutil
caldav caldav
cryptography cryptography
bcrypt bcrypt
mcp # Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
# servers are migrated together.
mcp<2
pyotp pyotp
qrcode[pil] qrcode[pil]
croniter croniter
+5
View File
@@ -0,0 +1,5 @@
"""Admin wipe route domain package (slice 2h, #4082/#4071).
Contains admin_wipe_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/admin_wipe_routes.py re-exports from here.
"""
+176
View File
@@ -0,0 +1,176 @@
"""Admin Danger Zone — per-category wipes.
Each endpoint is admin-only and truncates exactly one domain so the
user can selectively reset memory / skills / notes / etc. without
nuking everything. The catch-all `chats` endpoint mirrors the
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
URL shape: DELETE /api/admin/wipe/{kind}
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
"""
import json
import logging
import os
import shutil
from fastapi import APIRouter, HTTPException, Request
from core.middleware import require_admin
from core.database import (
SessionLocal,
Session as DbSession,
ChatMessage as DbChatMessage,
Memory,
Note,
ScheduledTask,
TaskRun,
Document,
DocumentVersion,
GalleryImage,
GalleryAlbum,
CalendarEvent,
CalendarCal,
)
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
logger = logging.getLogger(__name__)
def _wipe_memory_files():
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
next audit doesn't try to diff against gone memories."""
for name in ("memory.json", "memory_tidy_state.json"):
p = os.path.join(DATA_DIR, name)
if not os.path.exists(p):
continue
try:
if name == "memory.json":
with open(p, "w", encoding="utf-8") as f:
json.dump([], f)
else:
os.remove(p)
except OSError as e:
logger.warning(f"Could not reset {name}: {e}")
def _rmtree_quiet(path: str):
"""rmtree that doesn't crash if the path doesn't exist."""
if os.path.isdir(path):
try:
shutil.rmtree(path)
except OSError as e:
logger.warning(f"Could not remove {path}: {e}")
def setup_admin_wipe_routes(session_manager):
"""The session_manager is passed in so we can also clear its
in-memory cache when wiping chats — without it the DB is empty
but the next /api/sessions returns stale entries."""
router = APIRouter(prefix="/api/admin")
@router.delete("/wipe/{kind}")
def wipe(kind: str, request: Request):
require_admin(request)
kind = (kind or "").strip().lower()
db = SessionLocal()
try:
if kind == "chats":
count = db.query(DbSession).count()
db.query(DbChatMessage).delete()
db.query(DbSession).delete()
db.commit()
try:
session_manager.sessions.clear()
except Exception:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "memory":
count = db.query(Memory).count()
db.query(Memory).delete()
db.commit()
_wipe_memory_files()
# Drop the vector store too so semantic search doesn't
# return ghosts. Lazy import — chromadb may not be
# initialised in every deployment.
try:
from src.memory_vector import get_memory_vector_store
mv = get_memory_vector_store()
if mv and hasattr(mv, "clear"):
mv.clear()
except Exception as e:
logger.info(f"Memory vector clear skipped: {e}")
return {"status": "deleted", "kind": kind, "count": count}
if kind == "skills":
# Skills live as SKILL.md files under data/skills/. Drop
# the entire directory; the SkillsManager re-creates the
# tree on next write.
skills_dir = SKILLS_DIR
count = 0
if os.path.isdir(skills_dir):
# Count SKILL.md files for the response — quick walk.
for _, _, files in os.walk(skills_dir):
count += sum(1 for f in files if f == "SKILL.md")
_rmtree_quiet(skills_dir)
# Legacy fallback file
legacy = SKILLS_FILE
if os.path.exists(legacy):
try:
os.remove(legacy)
except OSError:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "notes":
count = db.query(Note).count()
db.query(Note).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "tasks":
# TaskRun rows reference tasks via FK — clear them first.
db.query(TaskRun).delete()
count = db.query(ScheduledTask).count()
db.query(ScheduledTask).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "documents":
# DocumentVersion FKs Document — clear children first.
db.query(DocumentVersion).delete()
count = db.query(Document).count()
db.query(Document).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "gallery":
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
db.query(GalleryImage).delete()
db.query(GalleryAlbum).delete()
db.commit()
# Also drop the upload dir so disk doesn't keep orphans.
_rmtree_quiet(GALLERY_DIR)
_rmtree_quiet(GALLERY_UPLOADS_DIR)
return {"status": "deleted", "kind": kind, "count": count}
if kind == "calendar":
# Events FK calendars — clear children first, then both.
db.query(CalendarEvent).delete()
count = db.query(CalendarCal).count()
db.query(CalendarCal).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.exception(f"Wipe {kind} failed")
raise HTTPException(500, f"Wipe {kind} failed: {e}")
finally:
db.close()
return router
+12 -171
View File
@@ -1,176 +1,17 @@
"""Admin Danger Zone — per-category wipes. """Backward-compat shim — canonical location is routes/admin_wipe/admin_wipe_routes.py.
Each endpoint is admin-only and truncates exactly one domain so the This module is replaced in ``sys.modules`` by the canonical module object so
user can selectively reset memory / skills / notes / etc. without that ``import routes.admin_wipe_routes``, ``from routes.admin_wipe_routes
nuking everything. The catch-all `chats` endpoint mirrors the import X``, ``importlib.import_module("routes.admin_wipe_routes")``, and the
existing /api/sessions/all so the Danger Zone speaks one URL pattern. ``import ... as admin_wipe_routes`` + ``monkeypatch.setattr(admin_wipe_routes,
"SessionLocal", ...)`` / ``"require_admin"`` pattern used by
URL shape: DELETE /api/admin/wipe/{kind} test_admin_wipe_gallery.py all operate on the *same* object the application
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar. actually uses. Keeps existing import paths working after slice 2h
(#4082/#4071).
""" """
import json import sys as _sys
import logging
import os
import shutil
from fastapi import APIRouter, HTTPException, Request
from core.middleware import require_admin from routes.admin_wipe import admin_wipe_routes as _canonical # noqa: F401
from core.database import (
SessionLocal,
Session as DbSession,
ChatMessage as DbChatMessage,
Memory,
Note,
ScheduledTask,
TaskRun,
Document,
DocumentVersion,
GalleryImage,
GalleryAlbum,
CalendarEvent,
CalendarCal,
)
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
logger = logging.getLogger(__name__) _sys.modules[__name__] = _canonical
def _wipe_memory_files():
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
next audit doesn't try to diff against gone memories."""
for name in ("memory.json", "memory_tidy_state.json"):
p = os.path.join(DATA_DIR, name)
if not os.path.exists(p):
continue
try:
if name == "memory.json":
with open(p, "w", encoding="utf-8") as f:
json.dump([], f)
else:
os.remove(p)
except OSError as e:
logger.warning(f"Could not reset {name}: {e}")
def _rmtree_quiet(path: str):
"""rmtree that doesn't crash if the path doesn't exist."""
if os.path.isdir(path):
try:
shutil.rmtree(path)
except OSError as e:
logger.warning(f"Could not remove {path}: {e}")
def setup_admin_wipe_routes(session_manager):
"""The session_manager is passed in so we can also clear its
in-memory cache when wiping chats — without it the DB is empty
but the next /api/sessions returns stale entries."""
router = APIRouter(prefix="/api/admin")
@router.delete("/wipe/{kind}")
def wipe(kind: str, request: Request):
require_admin(request)
kind = (kind or "").strip().lower()
db = SessionLocal()
try:
if kind == "chats":
count = db.query(DbSession).count()
db.query(DbChatMessage).delete()
db.query(DbSession).delete()
db.commit()
try:
session_manager.sessions.clear()
except Exception:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "memory":
count = db.query(Memory).count()
db.query(Memory).delete()
db.commit()
_wipe_memory_files()
# Drop the vector store too so semantic search doesn't
# return ghosts. Lazy import — chromadb may not be
# initialised in every deployment.
try:
from src.memory_vector import get_memory_vector_store
mv = get_memory_vector_store()
if mv and hasattr(mv, "clear"):
mv.clear()
except Exception as e:
logger.info(f"Memory vector clear skipped: {e}")
return {"status": "deleted", "kind": kind, "count": count}
if kind == "skills":
# Skills live as SKILL.md files under data/skills/. Drop
# the entire directory; the SkillsManager re-creates the
# tree on next write.
skills_dir = SKILLS_DIR
count = 0
if os.path.isdir(skills_dir):
# Count SKILL.md files for the response — quick walk.
for _, _, files in os.walk(skills_dir):
count += sum(1 for f in files if f == "SKILL.md")
_rmtree_quiet(skills_dir)
# Legacy fallback file
legacy = SKILLS_FILE
if os.path.exists(legacy):
try:
os.remove(legacy)
except OSError:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "notes":
count = db.query(Note).count()
db.query(Note).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "tasks":
# TaskRun rows reference tasks via FK — clear them first.
db.query(TaskRun).delete()
count = db.query(ScheduledTask).count()
db.query(ScheduledTask).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "documents":
# DocumentVersion FKs Document — clear children first.
db.query(DocumentVersion).delete()
count = db.query(Document).count()
db.query(Document).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "gallery":
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
db.query(GalleryImage).delete()
db.query(GalleryAlbum).delete()
db.commit()
# Also drop the upload dir so disk doesn't keep orphans.
_rmtree_quiet(GALLERY_DIR)
_rmtree_quiet(GALLERY_UPLOADS_DIR)
return {"status": "deleted", "kind": kind, "count": count}
if kind == "calendar":
# Events FK calendars — clear children first, then both.
db.query(CalendarEvent).delete()
count = db.query(CalendarCal).count()
db.query(CalendarCal).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.exception(f"Wipe {kind} failed")
raise HTTPException(500, f"Wipe {kind} failed: {e}")
finally:
db.close()
return router
+12 -7
View File
@@ -11,12 +11,12 @@ import json
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from core.database import SessionLocal, CrewMember, ScheduledTask from core.database import SessionLocal, CrewMember, ScheduledTask
from src.auth_helpers import get_current_user from src.auth_helpers import require_interactive_request
from core.auth import RESERVED_USERNAMES from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.task_scheduler import compute_next_run 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: 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: def _owner(request: Request) -> str:
owner = get_current_user(request) owner = require_interactive_request(request)
if not owner: if not owner:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
return owner return owner
@@ -90,11 +94,12 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
# check-in tasks seeded. Hitting any /assistant route under one of these # check-in tasks seeded. Hitting any /assistant route under one of these
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that # used to seed a full CrewMember + Morning/Midday/Evening tasks under that
# owner, which then double-fired alongside the real user's check-ins. # owner, which then double-fired alongside the real user's check-ins.
# RESERVED_USERNAMES covers the same set; the `not owner` guard handles "". # REQUEST_SENTINEL_OWNERS covers request-only identities; Default/Local is a
# reserved login name but remains a valid storage owner.
async def _get_or_create(owner: str) -> CrewMember: async def _get_or_create(owner: str) -> CrewMember:
"""Return the per-owner assistant CrewMember, creating it on demand.""" """Return the per-owner assistant CrewMember, creating it on demand."""
if not owner or owner in RESERVED_USERNAMES: if not owner or owner in REQUEST_SENTINEL_OWNERS:
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}") raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
db = SessionLocal() db = SessionLocal()
try: try:
+87 -4
View File
@@ -22,6 +22,8 @@ from src.settings import (
load_features as _load_features, load_features as _load_features,
save_features as _save_features, save_features as _save_features,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
RETIRED_SETTING_KEYS,
without_retired_settings,
) )
from src.integrations import ( from src.integrations import (
load_integrations, load_integrations,
@@ -84,6 +86,33 @@ class SetOpenRegistrationRequest(BaseModel):
SESSION_COOKIE = "odysseus_session" SESSION_COOKIE = "odysseus_session"
def _secure_cookie(request: Request) -> bool:
"""Decide the ``Secure`` attribute of the session cookie.
``SECURE_COOKIES`` stays authoritative when it holds an explicit value:
``true`` always marks the cookie Secure (the documented knob for a TLS
proxy), ``false`` never does, which is the escape hatch for an install
that still answers on plain HTTP alongside HTTPS. Anything else —
unset, or the present-but-empty value docker-compose injects for a
variable the host has not defined — derives it from the request, so an
HTTPS login gets a Secure cookie without any configuration.
Either the connection scheme or ``X-Forwarded-Proto`` saying https is
enough, which is the same test ``core/middleware.py`` applies before it
sends HSTS. Uvicorn's proxy-headers middleware already folds that header
into the scheme for the proxies it trusts, so reading it here only adds
the case of a terminator that is not on a trusted address; the cost is
that a client talking to the app directly can set the header and lock
its own session out over plain HTTP.
"""
configured = os.getenv("SECURE_COOKIES", "").strip().lower()
if configured in ("true", "false"):
return configured == "true"
# A chained proxy sends a list — the client-facing hop comes first.
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0]
return request.url.scheme == "https" or forwarded_proto.strip().lower() == "https"
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -157,7 +186,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
value=token, value=token,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true", secure=_secure_cookie(request),
path="/", path="/",
) )
if body.remember: if body.remember:
@@ -345,9 +374,61 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
# docs, email accounts, tasks, etc. # docs, email accounts, tasks, etc.
try: try:
from sqlalchemy import func from sqlalchemy import func
from core.database import Base, SessionLocal from core.database import (
Base,
EmailAccount,
SessionLocal,
lock_email_account_owner_mutations,
)
db = SessionLocal() db = SessionLocal()
try: try:
# Email-account defaults are protected by per-owner mutex rows.
# A rename crosses two owner partitions, so lock both in the
# shared helper's canonical order before inspecting either.
lock_email_account_owner_mutations(
db, old_username, new_username
)
source_default_ids = [
row[0]
for row in (
db.query(EmailAccount.id)
.filter(
func.lower(EmailAccount.owner) == old_username,
EmailAccount.is_default == True, # noqa: E712
)
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
.all()
)
]
destination_default_ids = [
row[0]
for row in (
db.query(EmailAccount.id)
.filter(
func.lower(EmailAccount.owner) == new_username,
EmailAccount.is_default == True, # noqa: E712
)
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
.all()
)
]
if destination_default_ids:
clear_default_ids = (
destination_default_ids[1:] + source_default_ids
)
else:
clear_default_ids = source_default_ids[1:]
if clear_default_ids:
(
db.query(EmailAccount)
.filter(EmailAccount.id.in_(clear_default_ids))
.update(
{EmailAccount.is_default: False},
synchronize_session=False,
)
)
for mapper in Base.registry.mappers: for mapper in Base.registry.mappers:
model = mapper.class_ model = mapper.class_
if not hasattr(model, "owner"): if not hasattr(model, "owner"):
@@ -637,7 +718,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
a scrubbed copy with secret keys blanked. The frontend uses this a scrubbed copy with secret keys blanked. The frontend uses this
for keybinds + TTS prefs, so it stays callable without admin.""" for keybinds + TTS prefs, so it stays callable without admin."""
user = _get_current_user(request) user = _get_current_user(request)
settings = _load_settings() settings = without_retired_settings(_load_settings())
if user and auth_manager.is_admin(user): if user and auth_manager.is_admin(user):
return settings return settings
return scrub_settings(settings) return scrub_settings(settings)
@@ -657,6 +738,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited "agent_max_tool_calls": (0, 1000), # 0 = unlimited
} }
for key in DEFAULT_SETTINGS: for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS:
continue
if key not in body: if key not in body:
continue continue
val = body[key] val = body[key]
@@ -669,7 +752,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
val = max(lo, min(val, hi)) val = max(lo, min(val, hi))
current[key] = val current[key] = val
_save_settings(current) _save_settings(current)
return current return without_retired_settings(current)
# ---- Integrations CRUD ---- # ---- Integrations CRUD ----
+10 -1
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from core.middleware import require_admin from core.middleware import require_admin
from services.memory import MemoryStoreUnreadable
from src.auth_helpers import get_current_user from src.auth_helpers import get_current_user
from src.settings import load_settings, save_settings, load_features, save_features from src.settings import load_settings, save_settings, load_features, save_features
@@ -76,7 +77,15 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
# ── Memories ── # ── Memories ──
if "memories" in body and isinstance(body["memories"], list): if "memories" in body and isinstance(body["memories"], list):
existing = memory_manager.load_all() # Strict load: importing on top of an unreadable store would write
# only the incoming rows back and drop everything already saved.
try:
existing = memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Refusing to import memories: %s", e)
raise HTTPException(
503, "Memory store is temporarily unreadable — nothing was imported."
)
# Dedup against THIS user's own memories only. Using every tenant's # Dedup against THIS user's own memories only. Using every tenant's
# rows (load_all) meant a memory whose text matched any other # rows (load_all) meant a memory whose text matched any other
# user's was silently skipped, so the importing user lost their own # user's was silently skipped, so the importing user lost their own
+147 -8
View File
@@ -10,11 +10,13 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Request, UploadFile, File from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_
from sqlalchemy.exc import IntegrityError
from dateutil.rrule import rrulestr from dateutil.rrule import rrulestr
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
from src.auth_helpers import require_user from src.auth_helpers import effective_user, require_user
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
from src.upload_handler import reserve_upload_references
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -220,23 +222,126 @@ class EventUpdate(BaseModel):
# ── Helpers ── # ── Helpers ──
_DEFAULT_CALENDAR_NAMESPACE = uuid.UUID("4840613a-9847-4a3b-bd75-19e6bc5fc3ce")
def _default_calendar_id(owner: str, collision_index: int = 0) -> str:
"""Return one stable primary-key candidate for an owner's lazy default.
Slot zero preserves the original owner-derived identifier. Later slots
let a username be reused after its prior calendar was migrated to another
owner during a rename, without making concurrent first use choose random
and therefore divergent identifiers.
"""
if collision_index == 0:
candidate_name = owner
else:
candidate_name = json.dumps(
[owner, collision_index],
ensure_ascii=False,
separators=(",", ":"),
)
return str(uuid.uuid5(_DEFAULT_CALENDAR_NAMESPACE, candidate_name))
def _begin_sqlite_default_write(db) -> None:
"""Serialize an absent-default check with other SQLite writers.
SQLite's default deferred transactions allow two workers to both read an
empty calendar set before either writes. ``BEGIN IMMEDIATE`` acquires the
writer reservation before the second, authoritative lookup. We issue it
only when the driver has not already opened a write transaction; a caller
with a pending write already owns the required reservation.
"""
connection = db.connection()
dbapi_connection = connection.connection
driver_connection = getattr(
dbapi_connection,
"driver_connection",
dbapi_connection,
)
if not getattr(driver_connection, "in_transaction", False):
connection.exec_driver_sql("BEGIN IMMEDIATE")
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal: def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
"""Create default calendar if none exist for this owner.""" """Return the owner's calendar, staging a default in the caller's transaction.
A stable owner-derived primary key makes concurrent first-use inserts
converge on one row on every SQL backend. SQLite additionally serializes
the absent-row check because its deferred transactions otherwise permit
both workers to read the gap before either writes. Other backends recover
a lost insert race inside a savepoint so the caller's event transaction
remains usable and atomic.
"""
owner = owner or FALLBACK_OWNER owner = owner or FALLBACK_OWNER
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first() cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
if not cal: if cal:
return cal
dialect = db.get_bind().dialect.name
if dialect == "sqlite":
_begin_sqlite_default_write(db)
# Another worker may have committed while BEGIN IMMEDIATE waited.
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
if cal:
return cal
collision_index = 0
while True:
default_id = _default_calendar_id(owner, collision_index)
if dialect == "sqlite":
# BEGIN IMMEDIATE above makes this occupancy check authoritative:
# another SQLite writer cannot rename, delete, or claim this slot
# until the caller commits or rolls back.
occupant = db.query(CalendarCal).filter(
CalendarCal.id == default_id,
).first()
if occupant is not None:
if occupant.owner == owner:
return occupant
collision_index += 1
continue
cal = CalendarCal( cal = CalendarCal(
id=str(uuid.uuid4()), id=default_id,
owner=owner, owner=owner,
name="Personal", name="Personal",
color="#5b8abf", color="#5b8abf",
source="local", source="local",
) )
if dialect == "sqlite":
db.add(cal) db.add(cal)
db.commit() db.flush()
db.refresh(cal)
return cal return cal
try:
# A uniqueness failure rolls back only this savepoint, not an event
# or reminder already staged by the caller's outer transaction.
with db.begin_nested():
db.add(cal)
db.flush()
return cal
except IntegrityError:
# Use a locking/current read so repeatable-read backends can observe
# the row that won after our transaction's original empty snapshot.
occupant = db.query(CalendarCal).filter(
CalendarCal.id == default_id,
).with_for_update().first()
if occupant is None:
# Do not misclassify an unrelated integrity failure as an ID
# collision and loop forever. A concurrently deleted winner is
# safe for the caller to retry as a fresh transaction.
raise
if occupant.owner == owner:
return occupant
# A renamed calendar owns this deterministic slot. Advance to the
# next stable slot; concurrent callers for this owner will still
# converge there.
collision_index += 1
# Per-request user time context. chat_routes sets this from browser timezone # Per-request user time context. chat_routes sets this from browser timezone
# headers so natural-language times the LLM emits ("today at 9pm") are parsed # headers so natural-language times the LLM emits ("today at 9pm") are parsed
@@ -697,9 +802,18 @@ def _expand_rrule(
# ── Routes ── # ── Routes ──
def setup_calendar_routes() -> APIRouter: def setup_calendar_routes(upload_handler=None) -> APIRouter:
router = APIRouter(prefix="/api/calendar", tags=["calendar"]) router = APIRouter(prefix="/api/calendar", tags=["calendar"])
def _reserve_calendar_uploads(request: Request, *values) -> None:
missing_id = reserve_upload_references(
upload_handler,
effective_user(request),
*values,
)
if missing_id:
raise HTTPException(409, f"Referenced upload is no longer available: {missing_id}")
# ── CalDAV multi-account helpers ───────────────────────────────────────── # ── CalDAV multi-account helpers ─────────────────────────────────────────
def _get_caldav_accounts(owner: str) -> list: def _get_caldav_accounts(owner: str) -> list:
@@ -913,7 +1027,24 @@ def setup_calendar_routes() -> APIRouter:
'</d:prop></d:propfind>' '</d:prop></d:propfind>'
) )
try: try:
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False) as cx: # Build an SSL context that trusts the operator's custom CA bundle
# (SSL_CERT_FILE / REQUESTS_CA_BUNDLE) so self-signed CalDAV servers
# pass the pre-flight the same way they pass the real sync.
# trust_env=False is kept to block proxy/auth env leakage; the CA
# bundle is loaded explicitly instead.
import ssl as _ssl
_ssl_ctx = _ssl.create_default_context()
# Disable VERIFY_X509_STRICT so certs without a keyUsage extension
# (common in self-signed setups) are accepted, matching the
# requests/urllib3 behavior used by the CalDAV sync path.
_ssl_ctx.verify_flags &= ~_ssl.VERIFY_X509_STRICT
_ca_bundle = _os.environ.get("SSL_CERT_FILE") or _os.environ.get("REQUESTS_CA_BUNDLE")
if _ca_bundle:
if _os.path.isfile(_ca_bundle):
_ssl_ctx.load_verify_locations(_ca_bundle)
else:
logger.warning("CalDAV test: CA bundle %s not found, using system CAs", _ca_bundle)
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False, verify=_ssl_ctx) as cx:
r = await cx.request( r = await cx.request(
"PROPFIND", url, "PROPFIND", url,
auth=(user, pw), auth=(user, pw),
@@ -988,6 +1119,9 @@ def setup_calendar_routes() -> APIRouter:
db = SessionLocal() db = SessionLocal()
try: try:
_ensure_default_calendar(db, owner) _ensure_default_calendar(db, owner)
# Listing calendars intentionally lazily creates a durable default.
# Other callers commit it with the event they are creating.
db.commit()
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all() cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
return {"calendars": [ return {"calendars": [
{"name": c.name, "href": c.id, "color": c.color, "source": c.source} {"name": c.name, "href": c.id, "color": c.color, "source": c.source}
@@ -996,6 +1130,7 @@ def setup_calendar_routes() -> APIRouter:
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error("Failed to list calendars: %s", e) logger.error("Failed to list calendars: %s", e)
raise HTTPException(500, "Failed to list calendars") raise HTTPException(500, "Failed to list calendars")
finally: finally:
@@ -1070,6 +1205,7 @@ def setup_calendar_routes() -> APIRouter:
@router.post("/events") @router.post("/events")
async def create_event(request: Request, data: EventCreate): async def create_event(request: Request, data: EventCreate):
owner = _require_user(request) owner = _require_user(request)
_reserve_calendar_uploads(request, data.color, data.description, data.location)
db = SessionLocal() db = SessionLocal()
try: try:
cal = None cal = None
@@ -1131,6 +1267,7 @@ def setup_calendar_routes() -> APIRouter:
@router.put("/events/{uid}") @router.put("/events/{uid}")
async def update_event(request: Request, uid: str, data: EventUpdate): async def update_event(request: Request, uid: str, data: EventUpdate):
owner = _require_user(request) owner = _require_user(request)
_reserve_calendar_uploads(request, data.color, data.description, data.location)
try: try:
base_uid = _resolve_base_uid(uid) base_uid = _resolve_base_uid(uid)
except ValueError as e: except ValueError as e:
@@ -1224,6 +1361,7 @@ def setup_calendar_routes() -> APIRouter:
@router.post("/calendars") @router.post("/calendars")
async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"): async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"):
owner = _require_user(request) owner = _require_user(request)
_reserve_calendar_uploads(request, color)
db = SessionLocal() db = SessionLocal()
try: try:
cal = CalendarCal( cal = CalendarCal(
@@ -1246,6 +1384,7 @@ def setup_calendar_routes() -> APIRouter:
@router.put("/calendars/{cal_id}") @router.put("/calendars/{cal_id}")
async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None): async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None):
owner = _require_user(request) owner = _require_user(request)
_reserve_calendar_uploads(request, color)
db = SessionLocal() db = SessionLocal()
try: try:
cal = _get_or_404_calendar(db, cal_id, owner) cal = _get_or_404_calendar(db, cal_id, owner)
+375 -130
View File
@@ -5,6 +5,7 @@ import json
import logging import logging
import os import os
import re import re
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Optional from typing import Any, Optional
@@ -14,9 +15,16 @@ from core.database import Session as DBSession, ModelEndpoint
from src.llm_core import normalize_model_id from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context from src.context_compactor import maybe_compact, trim_for_context
from src.model_context import estimate_tokens 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.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 from routes.prefs_routes import _load_for_user as load_prefs_for_user
from fastapi import HTTPException from fastapi import HTTPException
@@ -55,6 +63,9 @@ def _is_casual_low_signal(text: str) -> bool:
# the background work (extraction, auto-naming) silently never runs. # the background work (extraction, auto-naming) silently never runs.
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py. # Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
_BG_TASKS: set[asyncio.Task] = set() _BG_TASKS: set[asyncio.Task] = set()
_INCOGNITO_CONTEXTS: dict[str, dict[str, Any]] = {}
_INCOGNITO_CONTEXT_TTL_SECONDS = 6 * 60 * 60
_INCOGNITO_CONTEXT_MAX_MESSAGES = 80
def _spawn_bg(coro) -> asyncio.Task: def _spawn_bg(coro) -> asyncio.Task:
@@ -65,6 +76,67 @@ def _spawn_bg(coro) -> asyncio.Task:
return task return task
def _prune_incognito_contexts(now: float | None = None):
now = now or time.time()
stale = [
sid for sid, bundle in _INCOGNITO_CONTEXTS.items()
if now - float(bundle.get("updated_at") or 0) > _INCOGNITO_CONTEXT_TTL_SECONDS
]
for sid in stale:
_INCOGNITO_CONTEXTS.pop(sid, None)
def _incognito_messages(session_id: str) -> list[dict[str, Any]]:
_prune_incognito_contexts()
bundle = _INCOGNITO_CONTEXTS.get(str(session_id or ""))
if not bundle:
return []
return [dict(m) for m in bundle.get("messages", []) if isinstance(m, dict)]
def _append_incognito_message(session_id: str, role: str, content: Any, metadata: dict | None = None):
sid = str(session_id or "").strip()
if not sid:
return
_prune_incognito_contexts()
bundle = _INCOGNITO_CONTEXTS.setdefault(sid, {"messages": [], "updated_at": time.time()})
msg: dict[str, Any] = {"role": role, "content": content}
if metadata:
msg["metadata"] = dict(metadata)
messages = bundle.setdefault("messages", [])
messages.append(msg)
if len(messages) > _INCOGNITO_CONTEXT_MAX_MESSAGES:
del messages[:-_INCOGNITO_CONTEXT_MAX_MESSAGES]
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 ────────────────────────────────────────────────────── # # ── Data containers ────────────────────────────────────────────────────── #
@dataclass @dataclass
@@ -113,10 +185,43 @@ class ChatContext:
# Uploads attached to this user turn, resolved and owner-checked for the # Uploads attached to this user turn, resolved and owner-checked for the
# agent's private context. This is not emitted to the browser. # agent's private context. This is not emitted to the browser.
uploaded_files: list = field(default_factory=list) uploaded_files: list = field(default_factory=list)
# Route-neutral prompt before any model-window compaction/trimming. This is
# retained only when explicit foreground fallbacks are enabled so each
# concrete candidate can apply its own context budget independently.
route_messages: list = field(default_factory=list)
# ── Helpers ────────────────────────────────────────────────────────────── # # ── Helpers ────────────────────────────────────────────────────────────── #
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
if privs.get("block_all_models"):
return frozenset()
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
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:
user = None
if not user:
return None
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not auth_manager:
return None
privs = auth_manager.get_privileges(user) or {}
return _allowed_models_from_privileges(privs)
def _enforce_chat_privileges(request, sess) -> None: def _enforce_chat_privileges(request, sess) -> None:
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day) """Apply the per-user privilege gates (allowed_models + max_messages_per_day)
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work. that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
@@ -127,6 +232,12 @@ def _enforce_chat_privileges(request, sess) -> None:
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges, (single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
which means unrestricted allowed_models / zero cap -> no-op for them. 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: try:
user = effective_user(request) user = effective_user(request)
except Exception: except Exception:
@@ -146,10 +257,8 @@ def _enforce_chat_privileges(request, sess) -> None:
if privs.get("block_all_models"): if privs.get("block_all_models"):
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.") raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
allowed_raw = privs.get("allowed_models") allowed_models = _allowed_models_from_privileges(privs)
allowed = allowed_raw if isinstance(allowed_raw, list) else [] if allowed_models is not None and sess.model and sess.model not in allowed_models:
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
if restricted and sess.model and sess.model not in allowed:
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.") raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
cap = int(privs.get("max_messages_per_day") or 0) cap = int(privs.get("max_messages_per_day") or 0)
@@ -248,96 +357,6 @@ async def auto_name_session(session_manager, sess):
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}") logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
"""Find an alternative working endpoint when the current one fails.
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
"""
import requests as _req
from src.endpoint_resolver import (
build_chat_url,
build_headers,
build_models_url,
normalize_base,
resolve_endpoint_runtime,
)
from src.chatgpt_subscription import is_chatgpt_subscription_base
current_url = sess.endpoint_url or ""
owner = getattr(sess, "owner", None)
db = SessionLocal()
try:
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()
finally:
db.close()
for ep in endpoints:
base = normalize_base(ep.base_url)
# Skip current endpoint
if current_url and base in current_url:
continue
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception:
continue
ping_url = build_models_url(base)
headers = build_headers(api_key, base)
try:
if ping_url:
r = _req.get(ping_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
models = json.loads(ep.cached_models or "[]")
if not models:
continue
# Found a working endpoint — update session
new_model = models[0]
chat_url = build_chat_url(base)
new_headers = build_headers(api_key, base)
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
sess.model = new_model
sess.endpoint_url = chat_url
sess.headers = new_headers
# Persist
_db = SessionLocal()
try:
_db.query(DBSession).filter(DBSession.id == session_id).update({
"model": new_model,
"endpoint_url": chat_url,
"headers": persisted_headers,
})
_db.commit()
finally:
_db.close()
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
return {
"model": new_model,
"endpoint_url": chat_url,
"endpoint_name": ep.name,
}
except Exception:
continue
return None
def extract_preset(chat_handler, preset_id) -> PresetInfo: def extract_preset(chat_handler, preset_id) -> PresetInfo:
"""Extract preset parameters via chat_handler.""" """Extract preset parameters via chat_handler."""
temperature, max_tokens, system_prompt, char_name = ( temperature, max_tokens, system_prompt, char_name = (
@@ -418,28 +437,50 @@ def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[
except Exception: except Exception:
path = None path = None
manifest.append({ ref = attachment_ref({**info, "id": info.get("id") or str(att_id)})
"id": info.get("id") or str(att_id), ref.update({
"name": info.get("name") or info.get("original_name") or str(att_id), "id": ref["attachment_id"],
"mime": info.get("mime", ""), "uri": f"odysseus://attachment/{ref['attachment_id']}",
"size": info.get("size", 0), "read_policy": "owner_checked_upload",
# Transitional compatibility: existing built-in tools can still use
# this path, but only after owner, upload-root, and tool-root checks.
"path": path, "path": path,
}) })
manifest.append(ref)
return manifest 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. """Add user message to session history and update session name.
In incognito mode, still add to in-memory history (for conversation context) Incognito messages must not mutate persistent session history, even in
but skip session name update (which would persist).""" memory, because a later normal turn can persist the same session object."""
if incognito:
return
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta)) sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
if not incognito: if capability is None or capability.allow_auto_naming:
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context) 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.""" """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: if webhook_manager and not compare_mode:
webhook_manager.fire_and_forget("chat.message", { webhook_manager.fire_and_forget("chat.message", {
"session_id": session_id, "model": sess.model, "message": message[:2000], "session_id": session_id, "model": sess.model, "message": message[:2000],
@@ -473,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.""" """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: try:
from src.chatgpt_subscription import is_chatgpt_subscription_base from src.chatgpt_subscription import is_chatgpt_subscription_base
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "") is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
except Exception: except Exception:
is_chatgpt_subscription = False 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) 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 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: try:
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
@@ -498,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. # with similar endpoint URLs can borrow each other's API key.
from src.auth_helpers import owner_filter from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner) 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(): for ep in q.all():
if not _session_url_matches_endpoint(target_url, ep.base_url or ""): if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
continue continue
@@ -553,7 +619,7 @@ def _match_cached_model_id(requested: str, models) -> Optional[str]:
def _normalize_model_id_from_cache(sess) -> 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 "" endpoint_url = getattr(sess, "endpoint_url", "") or ""
requested = getattr(sess, "model", "") or "" requested = getattr(sess, "model", "") or ""
if not endpoint_url or not requested: if not endpoint_url or not requested:
@@ -566,6 +632,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
if not session_base: if not session_base:
return None 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() db = SessionLocal()
try: try:
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
@@ -573,6 +645,10 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
if owner: if owner:
from src.auth_helpers import owner_filter from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner) 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() endpoints = q.all()
for ep in endpoints: for ep in endpoints:
try: try:
@@ -581,11 +657,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
except Exception: except Exception:
continue continue
raw_models = getattr(ep, "cached_models", None)
if not raw_models:
continue
try: 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: except Exception:
continue continue
@@ -600,6 +677,91 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
return None 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: def _session_is_research_spinoff(sess) -> bool:
"""True if this session was created via research "Discuss" spin-off. """True if this session was created via research "Discuss" spin-off.
@@ -644,12 +806,18 @@ async def build_chat_context(
use_enhanced_message: bool = False, use_enhanced_message: bool = False,
agent_mode: bool = False, agent_mode: bool = False,
allow_tool_preprocessing: bool = True, allow_tool_preprocessing: bool = True,
defer_context_shaping: bool = False,
continuation_context_message: str | None = None,
persist_user_message: bool = True,
capability: RequestCapability | None = None,
) -> ChatContext: ) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call. """Build the full context (preface + messages) for an LLM call.
This is the shared logic between /chat and /chat_stream — preset extraction, This is the shared logic between /chat and /chat_stream — preset extraction,
message preprocessing, memory/RAG/web injection, compaction, normalization. message preprocessing, memory/RAG/web injection, compaction, normalization.
""" """
capability = capability or build_request_capability(request)
# Preset # Preset
preset = extract_preset(chat_handler, preset_id) preset = extract_preset(chat_handler, preset_id)
@@ -664,12 +832,32 @@ async def build_chat_context(
allow_tool_preprocessing=allow_tool_preprocessing, allow_tool_preprocessing=allow_tool_preprocessing,
) )
# Add user message to history # Add user message to history. Nobody/incognito uses a request-local
add_user_message(sess, chat_handler, preprocessed, incognito=incognito) # transcript store instead of session history so stale saved chats cannot
# bleed into context and the turn is not persisted.
if persist_user_message and incognito:
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,
capability=capability,
)
# Fire events # Fire events
if not incognito: 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; # Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
# bearer-token chat requests use the token owner instead of the "api" sentinel. # bearer-token chat requests use the token owner instead of the "api" sentinel.
@@ -680,7 +868,12 @@ async def build_chat_context(
getattr(chat_handler, "upload_handler", None), getattr(chat_handler, "upload_handler", None),
getattr(sess, "owner", None), getattr(sess, "owner", None),
) )
casual_low_signal = _is_casual_low_signal(message) context_message = (
str(continuation_context_message).strip()
if continuation_context_message
else message
)
casual_low_signal = _is_casual_low_signal(context_message)
# Memory enabled? # Memory enabled?
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True) mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
@@ -717,7 +910,15 @@ async def build_chat_context(
# Build context preface # Build context preface
# The stream path uses enhanced_message (with CoT/preprocessing applied), # The stream path uses enhanced_message (with CoT/preprocessing applied),
# the sync path uses text_for_context. # the sync path uses text_for_context.
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context _ctx_msg = (
context_message
if continuation_context_message
else (
preprocessed.enhanced_message
if use_enhanced_message
else preprocessed.text_for_context
)
)
_preface_kwargs = dict( _preface_kwargs = dict(
message=_ctx_msg, message=_ctx_msg,
session=sess, session=sess,
@@ -730,6 +931,7 @@ async def build_chat_context(
agent_mode=agent_mode, agent_mode=agent_mode,
incognito=incognito, incognito=incognito,
use_skills=skills_enabled, use_skills=skills_enabled,
allow_tool_preprocessing=allow_tool_preprocessing,
) )
if use_rag is not None or is_research_spinoff or casual_low_signal: if use_rag is not None or is_research_spinoff or casual_low_signal:
_preface_kwargs["use_rag"] = use_rag_val _preface_kwargs["use_rag"] = use_rag_val
@@ -748,7 +950,12 @@ async def build_chat_context(
# Normalize model ID. Prefer cached endpoint models so group chat does not # Normalize model ID. Prefer cached endpoint models so group chat does not
# re-hit slow local /models endpoints on every participant turn. # re-hit slow local /models endpoints on every participant turn.
norm = _normalize_model_id_from_cache(sess) or normalize_model_id( 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.endpoint_url,
sess.model, sess.model,
owner=getattr(sess, "owner", None), owner=getattr(sess, "owner", None),
@@ -756,8 +963,14 @@ async def build_chat_context(
if norm: if norm:
sess.model = norm sess.model = norm
# Build messages # Build messages. In Nobody/incognito mode, never read saved session
messages = preface + sess.get_context_messages() # 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 _history_for_request_capability(sess, capability)
)
# Current date/time — injected as a standalone *user*-role context message # Current date/time — injected as a standalone *user*-role context message
# placed immediately before the latest user turn, NOT folded into the # placed immediately before the latest user turn, NOT folded into the
@@ -779,12 +992,32 @@ async def build_chat_context(
except Exception: except Exception:
logger.debug("Failed to add current date/time context", exc_info=True) logger.debug("Failed to add current date/time context", exc_info=True)
# Auto-compact route_messages = list(messages)
# Explicit fallback routing must shape from the same route-neutral prompt
# for every candidate. Running selected-model compaction here would mutate
# 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_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( 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_messages = len(messages)
_before_trim_tokens = estimate_tokens(messages) _before_trim_tokens = estimate_tokens(messages)
if not defer_context_shaping:
messages = trim_for_context(messages, context_length) messages = trim_for_context(messages, context_length)
_after_trim_messages = len(messages) _after_trim_messages = len(messages)
_after_trim_tokens = estimate_tokens(messages) _after_trim_tokens = estimate_tokens(messages)
@@ -809,6 +1042,7 @@ async def build_chat_context(
context_tokens_after_trim=_after_trim_tokens, context_tokens_after_trim=_after_trim_tokens,
auto_opened_docs=auto_opened_docs, auto_opened_docs=auto_opened_docs,
uploaded_files=uploaded_files, uploaded_files=uploaded_files,
route_messages=route_messages,
) )
@@ -1023,7 +1257,12 @@ def save_assistant_response(
tool_events: list = None, tool_events: list = None,
incognito: bool = False, incognito: bool = False,
): ):
"""Add assistant response to session history. In incognito mode, keeps in-memory context but skips DB persistence.""" """Add assistant response to session history.
Incognito responses are intentionally not added to the session object. The
session may later be saved by a normal turn, so "in-memory only" is not
private enough.
"""
md = dict(last_metrics) if last_metrics else {} md = dict(last_metrics) if last_metrics else {}
def _model_value(value) -> str: def _model_value(value) -> str:
if value is None: if value is None:
@@ -1063,19 +1302,18 @@ def save_assistant_response(
_content = _think_info["reply"] _content = _think_info["reply"]
else: else:
_content = full_response _content = full_response
if incognito:
_append_incognito_message(session_id, "assistant", _content, md)
return None
sess.add_message(ChatMessage("assistant", _content, metadata=md)) sess.add_message(ChatMessage("assistant", _content, metadata=md))
if not incognito:
from core.database import update_session_last_accessed from core.database import update_session_last_accessed
update_session_last_accessed(session_id) update_session_last_accessed(session_id)
session_manager.save_sessions() session_manager.save_sessions()
# Return the persisted message's DB id so the stream can wire it onto the # Return the persisted message's DB id so the stream can wire it onto the
# freshly-rendered bubble — lets the user edit/delete a just-streamed reply # freshly-rendered bubble — lets the user edit/delete a just-streamed reply
# without reloading. Incognito returns None: those messages are ephemeral, # without reloading.
# so we don't hand out an edit/delete handle for them.
if incognito:
return None
try: try:
_last = sess.history[-1] _last = sess.history[-1]
_meta = getattr(_last, "metadata", None) _meta = getattr(_last, "metadata", None)
@@ -1155,6 +1393,7 @@ def run_post_response_tasks(
owner: str = None, owner: str = None,
extract_skills: bool = True, extract_skills: bool = True,
allow_background_extraction: 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. """Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.
@@ -1170,6 +1409,12 @@ def run_post_response_tasks(
``_queue_background_extraction`` keeps them from overlapping the *next* ``_queue_background_extraction`` keeps them from overlapping the *next*
turn's request too. 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 = [] _extraction_jobs: list = []
# Memory extraction — only every 4th message pair to avoid excess LLM calls # Memory extraction — only every 4th message pair to avoid excess LLM calls
+1415 -127
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
"""Cleanup route domain package (slice 2g, #4082/#4071).
Contains cleanup_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/cleanup_routes.py re-exports from here.
"""
+60
View File
@@ -0,0 +1,60 @@
# routes/cleanup_routes.py
"""Routes for cleanup operations."""
import logging
from fastapi import APIRouter, HTTPException, Request
from src.cleanup_service import get_cleanup_preview, cleanup_sessions
from src.auth_helpers import get_current_user
logger = logging.getLogger(__name__)
def setup_cleanup_routes(session_manager):
"""
Setup cleanup-related routes.
Args:
session_manager: SessionManager instance
Returns:
APIRouter instance with cleanup routes
"""
router = APIRouter(prefix="/api/cleanup")
@router.get("/preview")
async def cleanup_preview(request: Request):
"""
Preview what would be cleaned up without making any changes.
Returns:
JSON response with lists of sessions that would be archived/deleted and estimated space savings
"""
user = get_current_user(request)
try:
preview = await get_cleanup_preview(owner=user)
return preview
except Exception as e:
logger.error(f"Cleanup preview failed: {e}")
raise HTTPException(500, "Cleanup preview generation failed")
@router.post("")
async def cleanup_endpoint(request: Request):
"""
Perform cleanup operations:
1. Archive inactive sessions (not accessed for 7 days)
2. Delete old sessions (archived, not important, not accessed for 14+ days, with fewer than 10 messages)
Returns:
JSON response with counts of deleted and archived sessions, and space freed
"""
user = get_current_user(request)
try:
archived_count, deleted_count, space_freed_mb = await cleanup_sessions(session_manager, owner=user)
return {
"archived_count": archived_count,
"deleted_count": deleted_count,
"space_freed_mb": round(space_freed_mb, 2)
}
except Exception as e:
logger.error(f"Cleanup failed: {e}")
raise HTTPException(500, "Cleanup operation failed")
return router
+13 -56
View File
@@ -1,60 +1,17 @@
# routes/cleanup_routes.py """Backward-compat shim — canonical location is routes/cleanup/cleanup_routes.py.
"""Routes for cleanup operations."""
import logging
from fastapi import APIRouter, HTTPException, Request
from src.cleanup_service import get_cleanup_preview, cleanup_sessions
from src.auth_helpers import get_current_user
logger = logging.getLogger(__name__) This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.cleanup_routes``, ``from routes.cleanup_routes import X``,
``importlib.import_module("routes.cleanup_routes")``, and the string-targeted
``monkeypatch.setattr("routes.cleanup_routes.get_cleanup_preview", ...)`` /
``"routes.cleanup_routes.get_current_user"`` / ``"routes.cleanup_routes.
cleanup_sessions"`` pattern used by test_cleanup_owner_scope.py all operate
on the *same* object the application actually uses. Keeps existing import
paths working after slice 2g (#4082/#4071).
"""
def setup_cleanup_routes(session_manager): import sys as _sys
"""
Setup cleanup-related routes.
Args: from routes.cleanup import cleanup_routes as _canonical # noqa: F401
session_manager: SessionManager instance
Returns: _sys.modules[__name__] = _canonical
APIRouter instance with cleanup routes
"""
router = APIRouter(prefix="/api/cleanup")
@router.get("/preview")
async def cleanup_preview(request: Request):
"""
Preview what would be cleaned up without making any changes.
Returns:
JSON response with lists of sessions that would be archived/deleted and estimated space savings
"""
user = get_current_user(request)
try:
preview = await get_cleanup_preview(owner=user)
return preview
except Exception as e:
logger.error(f"Cleanup preview failed: {e}")
raise HTTPException(500, "Cleanup preview generation failed")
@router.post("")
async def cleanup_endpoint(request: Request):
"""
Perform cleanup operations:
1. Archive inactive sessions (not accessed for 7 days)
2. Delete old sessions (archived, not important, not accessed for 14+ days, with fewer than 10 messages)
Returns:
JSON response with counts of deleted and archived sessions, and space freed
"""
user = get_current_user(request)
try:
archived_count, deleted_count, space_freed_mb = await cleanup_sessions(session_manager, owner=user)
return {
"archived_count": archived_count,
"deleted_count": deleted_count,
"space_freed_mb": round(space_freed_mb, 2)
}
except Exception as e:
logger.error(f"Cleanup failed: {e}")
raise HTTPException(500, "Cleanup operation failed")
return router
+85 -41
View File
@@ -1,8 +1,9 @@
"""Codex integration routes. """Codex integration routes.
These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They These are small HTTP surfaces intended for the Codex plugin/MCP bridge. They
reuse existing Odysseus helpers and enforce API-token scopes before touching reuse existing Odysseus helpers. Owner-scoped data operations support bearer
user data. principals with the matching token scope; the Cookbook/plugin host-control
plane remains interactive-only.
""" """
import asyncio import asyncio
@@ -12,11 +13,16 @@ from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any 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 fastapi.responses import StreamingResponse
from core.middleware import require_admin 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.tool_implementations import do_manage_notes
from src.constants import COOKBOOK_STATE_FILE from src.constants import COOKBOOK_STATE_FILE
from routes._validators import validate_remote_host, validate_ssh_port 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 """Run an existing route handler with request.state.current_user temporarily
set to ``owner`` so its internal get_current_user/require_user calls see 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). 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 = getattr(request.state, "current_user", None)
orig_api_token = getattr(request.state, "api_token", 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.current_user = owner
request.state.api_token = False request.state.api_token = False
try: try:
@@ -80,46 +117,49 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
pass pass
else: else:
request.state.api_token = orig_api_token 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: def _scope_owner(request: Request, allowed: set[str]) -> str:
"""Return the data owner if the caller is allowed for this Codex action.""" """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 []) scopes = set(getattr(request.state, "api_token_scopes", []) or [])
if not scopes.intersection(allowed): if not scopes.intersection(allowed):
required = " or ".join(sorted(allowed)) required = " or ".join(sorted(allowed))
raise HTTPException(403, f"API token missing required scope: {required}") raise HTTPException(403, f"API token missing required scope: {required}")
owner = getattr(request.state, "api_token_owner", None) return require_api_token_owner(request)
if not owner:
raise HTTPException(403, "API token has no owner")
return owner
return require_user(request) return require_user(request)
def _scope_owner_all(request: Request, required: set[str]) -> str: def _scope_owner_all(request: Request, required: set[str]) -> str:
"""Return owner only when an API token has every required scope.""" """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 []) scopes = set(getattr(request.state, "api_token_scopes", []) or [])
missing = required - scopes missing = required - scopes
if missing: if missing:
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}") raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
owner = getattr(request.state, "api_token_owner", None) return require_api_token_owner(request)
if not owner:
raise HTTPException(403, "API token has no owner")
return owner
return require_user(request) return require_user(request)
def _require_cookbook_scope(request: Request, allowed: set[str]) -> str: def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
"""Authorize a Codex cookbook route. """Authorize a Codex cookbook route.
For API-token callers, enforce the given scope set. Bearer callers are rejected by the host-control boundary regardless of
For cookie-session callers, additionally require admin privileges legacy scope labels. Cookie-session callers additionally require admin
because cookbook surfaces expose host topology, task logs, tmux privileges because cookbook surfaces expose host topology, task logs, tmux
commands, and model-serving controls. commands, and model-serving controls.
""" """
require_non_bearer_request(request)
owner = _scope_owner(request, allowed) 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) require_admin(request)
return owner return owner
@@ -151,7 +191,10 @@ def setup_codex_routes(
calendar_router: APIRouter | None = None, calendar_router: APIRouter | None = None,
document_router: APIRouter | None = None, document_router: APIRouter | None = None,
) -> APIRouter: ) -> 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_list_endpoint = _find_endpoint(email_router, "GET", "/api/email/list")
email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}") email_read_endpoint = _find_endpoint(email_router, "GET", "/api/email/read/{uid}")
email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send") email_send_endpoint = _find_endpoint(email_router, "POST", "/api/email/send")
@@ -167,7 +210,7 @@ def setup_codex_routes(
@router.get("/capabilities") @router.get("/capabilities")
def capabilities(request: Request): def capabilities(request: Request):
token_scopes = set(getattr(request.state, "api_token_scopes", []) or []) 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): def scoped(allowed):
return bool(token_scopes.intersection(allowed)) if has_token else True return bool(token_scopes.intersection(allowed)) if has_token else True
return { 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): def plugin_zip(request: Request):
require_non_bearer_request(request)
require_authenticated_request(request) require_authenticated_request(request)
root = Path(__file__).resolve().parent.parent / "integrations" / "codex" root = Path(__file__).resolve().parent.parent / "integrations" / "codex"
if not root.exists(): if not root.exists():
@@ -513,15 +557,10 @@ def setup_codex_routes(
return await _as_owner(request, owner, documents_create_endpoint, request, req) return await _as_owner(request, owner, documents_create_endpoint, request, req)
# ── Cookbook surface ── # ── Cookbook surface ──
# Lets the agent run the same launch / monitor / kill loop the user # These handlers retain their legacy scope constants for compatibility
# would do by hand in the Cookbook UI: read the current task list + # with callers and tests, but the bridge is an interactive-only
# tmux output, launch a serve task, stop one. Two scopes: # host-control plane. Bearer principals are rejected before any task-list,
# cookbook:read — list tasks + tail output + list servers # tmux-output, launch, stop, or model-serving operation.
# 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.
async def _run_shell(cmd: str, timeout: float = 15.0) -> dict: async def _run_shell(cmd: str, timeout: float = 15.0) -> dict:
"""Run a shell command, return {exit_code, stdout, stderr}.""" """Run a shell command, return {exit_code, stdout, stderr}."""
@@ -565,14 +604,14 @@ def setup_codex_routes(
if k not in ("hf_token", "_secrets")} if k not in ("hf_token", "_secrets")}
return clean return clean
@router.get("/cookbook/tasks") @router.get("/cookbook/tasks", dependencies=[Depends(require_non_bearer_request)])
async def codex_cookbook_tasks(request: Request): async def codex_cookbook_tasks(request: Request):
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES) _require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
state = _read_cookbook_state() state = _read_cookbook_state()
tasks = state.get("tasks") or [] tasks = state.get("tasks") or []
return {"tasks": [_redact_task(t) for t in tasks]} 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): async def codex_cookbook_servers(request: Request):
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES) _require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
state = _read_cookbook_state() state = _read_cookbook_state()
@@ -591,7 +630,7 @@ def setup_codex_routes(
}) })
return {"servers": cleaned} 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): async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
_require_cookbook_scope(request, COOKBOOK_READ_SCOPES) _require_cookbook_scope(request, COOKBOOK_READ_SCOPES)
# Defensive: session_id must be the tmux-style id we issue # Defensive: session_id must be the tmux-style id we issue
@@ -633,7 +672,7 @@ def setup_codex_routes(
"task": _redact_task(task), "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)): async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES) _require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
# Wraps /api/model/serve with the SAME validation the UI uses. # 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") raise HTTPException(503, "model serve endpoint unavailable")
return await serve_endpoint(request, req) 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): async def codex_cookbook_stop(request: Request, session_id: str):
_require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES) _require_cookbook_scope(request, COOKBOOK_LAUNCH_SCOPES)
import re as _re import re as _re
@@ -689,7 +728,7 @@ def setup_codex_routes(
result = await _run_shell(cmd, timeout=10) result = await _run_shell(cmd, timeout=10)
return {"session_id": session_id, "exit_code": result.get("exit_code"), "host": host or "local"} 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): async def codex_cookbook_cached(request: Request, host: str | None = None):
"""List cached models on a configured server (or local if host is omitted). """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 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, 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): async def codex_cookbook_presets(request: Request):
"""List saved serve presets (model + host + port + launch cmd). """List saved serve presets (model + host + port + launch cmd).
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve` 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", "")} 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): async def codex_cookbook_serve_preset(request: Request, name: str):
"""Launch a saved preset by name. Reuses the working cmd + host the """Launch a saved preset by name. Reuses the working cmd + host the
user already saved, avoiding the cmd-allowlist trial-and-error loop.""" 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") raise HTTPException(503, "model serve endpoint unavailable")
return await serve_endpoint(request, req) 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)): 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 """Adopt an existing tmux session (one started via raw ssh+tmux) into
cookbook tracking. Needed when serve_model rejects a cmd and the 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` 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. 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") @router.get("/plugin.zip")
def plugin_zip(request: Request): def plugin_zip(request: Request):
require_non_bearer_request(request)
require_authenticated_request(request) require_authenticated_request(request)
# Only ship the skills/ subtree so extracting at ~/.claude/ doesn't dump # 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. # README.md or other bundle metadata into the user's claude config dir.
+5
View File
@@ -0,0 +1,5 @@
"""Compare route domain package (slice 2i, #4082/#4071).
Contains compare_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/compare_routes.py re-exports from here.
"""
+435
View File
@@ -0,0 +1,435 @@
# routes/compare_routes.py
"""Model A/B comparison routes."""
import json
import uuid
import random
from datetime import datetime
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 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"],
dependencies=[Depends(require_chat_scope)],
)
def _owned_endpoint_by_url(db, base_url, owner):
"""ModelEndpoint whose base_url == `base_url` and is VISIBLE to `owner`
(their own rows + legacy null-owner "shared" rows); None otherwise.
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
owner = private, "the model picker only shows the endpoint to that user") and
holds a decrypted `api_key`. start_comparison copies the matched row's api_key
into the caller-owned [CMP] session's headers, which then drives that session's
/api/chat_stream calls — so an UNSCOPED base_url match would let a user mint a
comparison bound to ANOTHER user's private endpoint and spend that owner's
api_key / reach whatever base_url they configured. Mirrors
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
legacy mode).
"""
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
q = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url)
return owner_filter(q, ModelEndpoint, owner).first()
def _owned_endpoint_by_id(db, endpoint_id, owner):
"""ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their
own rows + legacy null-owner "shared" rows); None otherwise.
Preferred over _owned_endpoint_by_url for credential resolution: two visible
endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two
accounts on the same provider). A base_url-only match returns whichever row
sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session.
An id pins the exact registered endpoint, so /api/compare/start prefers it and
only falls back to URL matching for legacy / admin raw-URL callers. Owner
scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op).
"""
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id)
return owner_filter(q, ModelEndpoint, owner).first()
class RecordVoteRequest(BaseModel):
prompt: str
models: List[str]
winner: str # model name or "tie"
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."""
@router.post("/start")
def start_comparison(
request: Request,
prompt: str = Form(...),
model_a: str = Form(...),
model_b: str = Form(...),
endpoint_a: str = Form(""),
endpoint_b: str = Form(""),
endpoint_a_id: str = Form(""),
endpoint_b_id: str = Form(""),
is_blind: str = Form("true"),
):
"""Create two ephemeral sessions and a comparison record.
Returns the comparison ID and the two session IDs so the client
can fire two independent SSE streams to /api/chat_stream.
"""
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())
# Blind mapping: randomly assign left/right
blind = str(is_blind).lower() == "true"
if blind:
mapping = {"left": "a", "right": "b"}
if random.random() > 0.5:
mapping = {"left": "b", "right": "a"}
else:
mapping = {"left": "a", "right": "b"}
# Map session IDs to left/right based on blind mapping
session_left = sid_a if mapping["left"] == "a" else sid_b
session_right = sid_a if mapping["right"] == "a" else sid_b
# In blind mode, name the helper sessions by their neutral slot
# ("Model A" / "Model B") instead of the real model. Otherwise the
# session name leaks the model in the sidebar and GET /api/sessions,
# de-anonymizing the comparison before the user votes (issue #1285).
slot_name = {session_left: "Model A", session_right: "Model B"}
# SECURITY: resolve and validate BOTH endpoints before creating any
# session. Compare copies a registered endpoint's Authorization header
# into the [CMP] session, so validating one endpoint while creating its
# session, then rejecting the other, would leave a partial compare
# session behind with that header attached. Doing all the owner-scope
# resolution + raw-URL rejection up front means a 403 on either endpoint
# aborts the whole request with nothing created and no header copied.
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
resolved = []
db = SessionLocal()
try:
for sid, model, endpoint, endpoint_id in [
(sid_a, model_a, endpoint_a, endpoint_a_id),
(sid_b, model_b, endpoint_b, endpoint_b_id),
]:
# Prefer an explicit endpoint id: it pins the EXACT registered
# endpoint (and its api_key), even when two endpoints visible to
# the caller share a base_url with different keys — a URL-only
# match would copy whichever row sorts first, i.e. possibly the
# wrong key. Fall back to URL resolution only for legacy / admin
# raw-URL callers that don't send an id.
eid = endpoint_id.strip() if isinstance(endpoint_id, str) else ""
if eid:
ep = _owned_endpoint_by_id(db, eid, user)
if ep is None:
# An id the caller can't see (wrong owner / deleted) must
# NOT silently fall back to a same-URL row with a different
# key — that's exactly the mix-up ids exist to prevent.
raise HTTPException(404, "Model endpoint not found")
# The id already resolved the endpoint; ignore any raw URL the
# caller also sent and dial the stored config instead.
endpoint = ep.base_url
elif not endpoint:
raise HTTPException(
422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required"
)
else:
# Resolve the supplied URL to a ModelEndpoint the caller owns
# (their own rows + legacy null-owner shared rows), scoped so a
# comparison can't borrow another user's private endpoint key.
base = normalize_base(endpoint)
ep = _owned_endpoint_by_url(db, base, user)
# Reject *unregistered* raw URLs for signed-in non-admins; a
# matched registered endpoint supplies an id so the caller can
# still compare endpoints they own. Blanket-rejecting here (the
# earlier `endpoint_id=None` call) locked non-admins out of
# compare entirely, since compare resolves endpoints by URL with
# no endpoint_id. Mirrors the gallery inpaint/harmonize checks.
# Raised here (phase 1), before any session exists.
_reject_raw_endpoint_url_for_non_admin(
request, user, str(ep.id) if ep is not None else None, endpoint
)
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
# base URL (the same value owner scoping + endpoint validation
# already vetted) so the session dials exactly where the stored
# config points. The raw `endpoint` only survives for callers
# allowed to pass one — admins / single-user mode, where
# `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep`
# is None. Mirrors the registered-endpoint path in session_routes.
session_endpoint_url = (
build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint
)
# Headers come only from a matched endpoint's key; None when
# `ep` is None (raw admin URL or no match), so a comparison can
# never inherit another user's key/headers.
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
resolved.append(
(
sid,
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, endpoint_id, provenance in resolved:
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
comparison_session = session_manager.create_session(
session_id=sid,
name=name,
endpoint_url=session_endpoint_url,
model=model,
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:
s.headers = headers
# Store comparison record
db = SessionLocal()
try:
comp = Comparison(
id=comp_id,
prompt=prompt,
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
# meaningful and non-null. resolved is in [a, b] order.
endpoint_a=endpoint_a or resolved[0][2],
endpoint_b=endpoint_b or resolved[1][2],
is_blind=blind,
blind_mapping=json.dumps(mapping),
owner=user,
)
db.add(comp)
db.commit()
finally:
db.close()
# In blind mode, withhold the model identities AND the left/right
# mapping from the response. The client already knows model_a/model_b
# (it sent them), so returning either would defeat blind mode. They are
# revealed by POST /api/compare/{id}/vote once the user has voted (#1285).
return {
"id": comp_id,
"session_left": session_left,
"session_right": session_right,
"model_left": None if blind else (model_a if mapping["left"] == "a" else model_b),
"model_right": None if blind else (model_a if mapping["right"] == "a" else model_b),
"is_blind": blind,
"mapping": None if blind else mapping,
}
@router.post("/{comp_id}/vote")
def vote_comparison(
request: Request,
comp_id: str,
winner: str = Form(...), # "left", "right", or "tie"
):
"""Record the user's vote and reveal model names if blind."""
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
if not comp:
raise HTTPException(404, "Comparison not found")
# SECURITY: strict ownership — null-owner Comparisons were
# accessible to every user.
if user and comp.owner != user:
raise HTTPException(404, "Comparison not found")
if comp.winner:
raise HTTPException(400, "Already voted")
mapping = json.loads(comp.blind_mapping) if comp.blind_mapping else {"left": "a", "right": "b"}
if winner == "tie":
comp.winner = "tie"
elif winner == "left":
comp.winner = mapping["left"]
elif winner == "right":
comp.winner = mapping["right"]
else:
raise HTTPException(400, "winner must be 'left', 'right', or 'tie'")
comp.voted_at = datetime.utcnow()
db.commit()
return {
"winner": comp.winner,
"model_a": comp.model_a,
"model_b": comp.model_b,
"revealed": {
"left": comp.model_a if mapping["left"] == "a" else comp.model_b,
"right": comp.model_a if mapping["right"] == "a" else comp.model_b,
},
}
finally:
db.close()
@router.post("/record")
def record_comparison(request: Request, body: RecordVoteRequest):
"""Lightweight endpoint to record a comparison vote from the frontend."""
require_chat_scope(request)
user = effective_user(request)
comp_id = str(uuid.uuid4())
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(models) > 2:
blind_mapping = json.dumps({"models": models})
else:
blind_mapping = None
db = SessionLocal()
try:
comp = Comparison(
id=comp_id,
prompt=body.prompt[:500],
model_a=model_a,
model_b=model_b,
endpoint_a="",
endpoint_b="",
winner=body.winner,
is_blind=body.is_blind,
blind_mapping=blind_mapping,
voted_at=datetime.utcnow(),
owner=user,
)
db.add(comp)
db.commit()
finally:
db.close()
return {"status": "ok", "id": comp_id}
@router.get("/history")
def list_comparisons(request: Request):
"""List past comparisons."""
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
q = db.query(Comparison)
if user:
q = q.filter(Comparison.owner == user)
comps = q.order_by(Comparison.created_at.desc()).limit(50).all()
return [
{
"id": c.id,
"prompt": c.prompt[:100],
"model_a": c.model_a,
"model_b": c.model_b,
"winner": c.winner,
"is_blind": c.is_blind,
"voted_at": c.voted_at.isoformat() if c.voted_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in comps
]
finally:
db.close()
@router.delete("/{comp_id}")
def delete_comparison(request: Request, comp_id: str):
"""Delete a comparison and its ephemeral sessions."""
require_chat_scope(request)
user = effective_user(request)
db = SessionLocal()
try:
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
if not comp:
raise HTTPException(404, "Comparison not found")
# SECURITY: strict ownership — null-owner Comparisons were
# accessible to every user.
if user and comp.owner != user:
raise HTTPException(404, "Comparison not found")
db.delete(comp)
db.commit()
return {"status": "deleted"}
finally:
db.close()
return router
+14 -361
View File
@@ -1,365 +1,18 @@
# routes/compare_routes.py """Backward-compat shim — canonical location is routes/compare/compare_routes.py.
"""Model A/B comparison routes."""
import json
import uuid
import random
from datetime import datetime
from fastapi import APIRouter, Form, HTTPException, Request
from typing import List
from pydantic import BaseModel
import logging
from core.database import Comparison, SessionLocal This module is replaced in ``sys.modules`` by the canonical module object so
from core.session_manager import SessionManager that ``import routes.compare_routes``, ``from routes.compare_routes import X``,
from src.auth_helpers import get_current_user ``importlib.import_module("routes.compare_routes")``, and the
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin ``import ... as cr`` + ``monkeypatch.setattr(cr, "SessionLocal", ...)`` /
``"_owned_endpoint_by_url"`` / ``"_owned_endpoint_by_id"`` pattern used by
test_endpoint_owner_scope_followup.py all operate on the *same* object the
application actually uses. Keeps existing import paths working after
slice 2i (#4082/#4071). Source-introspection tests read the canonical file
by path.
"""
logger = logging.getLogger(__name__) import sys as _sys
router = APIRouter(prefix="/api/compare", tags=["compare"]) from routes.compare import compare_routes as _canonical # noqa: F401
_sys.modules[__name__] = _canonical
def _owned_endpoint_by_url(db, base_url, owner):
"""ModelEndpoint whose base_url == `base_url` and is VISIBLE to `owner`
(their own rows + legacy null-owner "shared" rows); None otherwise.
Owner-scoped on purpose. ModelEndpoint is per-user (core/database.py: non-null
owner = private, "the model picker only shows the endpoint to that user") and
holds a decrypted `api_key`. start_comparison copies the matched row's api_key
into the caller-owned [CMP] session's headers, which then drives that session's
/api/chat_stream calls — so an UNSCOPED base_url match would let a user mint a
comparison bound to ANOTHER user's private endpoint and spend that owner's
api_key / reach whatever base_url they configured. Mirrors
session_routes._owned_endpoint. A null/empty owner is a no-op (single-user /
legacy mode).
"""
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
q = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url)
return owner_filter(q, ModelEndpoint, owner).first()
def _owned_endpoint_by_id(db, endpoint_id, owner):
"""ModelEndpoint whose id == `endpoint_id` and is VISIBLE to `owner` (their
own rows + legacy null-owner "shared" rows); None otherwise.
Preferred over _owned_endpoint_by_url for credential resolution: two visible
endpoints can share the same base_url but hold DIFFERENT api_keys (e.g. two
accounts on the same provider). A base_url-only match returns whichever row
sorts first, so it can copy the WRONG owner-scoped key into the [CMP] session.
An id pins the exact registered endpoint, so /api/compare/start prefers it and
only falls back to URL matching for legacy / admin raw-URL callers. Owner
scoping is identical to _owned_endpoint_by_url (a null/empty owner is a no-op).
"""
from core.database import ModelEndpoint
from src.auth_helpers import owner_filter
q = db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id)
return owner_filter(q, ModelEndpoint, owner).first()
class RecordVoteRequest(BaseModel):
prompt: str
models: List[str]
winner: str # model name or "tie"
is_blind: bool = True
def setup_compare_routes(session_manager: SessionManager):
"""Setup comparison routes."""
@router.post("/start")
def start_comparison(
request: Request,
prompt: str = Form(...),
model_a: str = Form(...),
model_b: str = Form(...),
endpoint_a: str = Form(""),
endpoint_b: str = Form(""),
endpoint_a_id: str = Form(""),
endpoint_b_id: str = Form(""),
is_blind: str = Form("true"),
):
"""Create two ephemeral sessions and a comparison record.
Returns the comparison ID and the two session IDs so the client
can fire two independent SSE streams to /api/chat_stream.
"""
user = getattr(request.state, 'current_user', None)
comp_id = str(uuid.uuid4())
sid_a = str(uuid.uuid4())
sid_b = str(uuid.uuid4())
# Blind mapping: randomly assign left/right
blind = str(is_blind).lower() == "true"
if blind:
mapping = {"left": "a", "right": "b"}
if random.random() > 0.5:
mapping = {"left": "b", "right": "a"}
else:
mapping = {"left": "a", "right": "b"}
# Map session IDs to left/right based on blind mapping
session_left = sid_a if mapping["left"] == "a" else sid_b
session_right = sid_a if mapping["right"] == "a" else sid_b
# In blind mode, name the helper sessions by their neutral slot
# ("Model A" / "Model B") instead of the real model. Otherwise the
# session name leaks the model in the sidebar and GET /api/sessions,
# de-anonymizing the comparison before the user votes (issue #1285).
slot_name = {session_left: "Model A", session_right: "Model B"}
# SECURITY: resolve and validate BOTH endpoints before creating any
# session. Compare copies a registered endpoint's Authorization header
# into the [CMP] session, so validating one endpoint while creating its
# session, then rejecting the other, would leave a partial compare
# session behind with that header attached. Doing all the owner-scope
# resolution + raw-URL rejection up front means a 403 on either endpoint
# aborts the whole request with nothing created and no header copied.
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
resolved = []
db = SessionLocal()
try:
for sid, model, endpoint, endpoint_id in [
(sid_a, model_a, endpoint_a, endpoint_a_id),
(sid_b, model_b, endpoint_b, endpoint_b_id),
]:
# Prefer an explicit endpoint id: it pins the EXACT registered
# endpoint (and its api_key), even when two endpoints visible to
# the caller share a base_url with different keys — a URL-only
# match would copy whichever row sorts first, i.e. possibly the
# wrong key. Fall back to URL resolution only for legacy / admin
# raw-URL callers that don't send an id.
eid = endpoint_id.strip() if isinstance(endpoint_id, str) else ""
if eid:
ep = _owned_endpoint_by_id(db, eid, user)
if ep is None:
# An id the caller can't see (wrong owner / deleted) must
# NOT silently fall back to a same-URL row with a different
# key — that's exactly the mix-up ids exist to prevent.
raise HTTPException(404, "Model endpoint not found")
# The id already resolved the endpoint; ignore any raw URL the
# caller also sent and dial the stored config instead.
endpoint = ep.base_url
elif not endpoint:
raise HTTPException(
422, "endpoint_a/endpoint_b or endpoint_a_id/endpoint_b_id is required"
)
else:
# Resolve the supplied URL to a ModelEndpoint the caller owns
# (their own rows + legacy null-owner shared rows), scoped so a
# comparison can't borrow another user's private endpoint key.
base = normalize_base(endpoint)
ep = _owned_endpoint_by_url(db, base, user)
# Reject *unregistered* raw URLs for signed-in non-admins; a
# matched registered endpoint supplies an id so the caller can
# still compare endpoints they own. Blanket-rejecting here (the
# earlier `endpoint_id=None` call) locked non-admins out of
# compare entirely, since compare resolves endpoints by URL with
# no endpoint_id. Mirrors the gallery inpaint/harmonize checks.
# Raised here (phase 1), before any session exists.
_reject_raw_endpoint_url_for_non_admin(
request, user, str(ep.id) if ep is not None else None, endpoint
)
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
# caller-supplied string. When the URL matches a registered
# endpoint visible to the caller, use that row's own normalized
# base URL (the same value owner scoping + endpoint validation
# already vetted) so the session dials exactly where the stored
# config points. The raw `endpoint` only survives for callers
# allowed to pass one — admins / single-user mode, where
# `_reject_raw_endpoint_url_for_non_admin` is a no-op and `ep`
# is None. Mirrors the registered-endpoint path in session_routes.
session_endpoint_url = (
build_chat_url(normalize_base(ep.base_url)) if ep is not None else endpoint
)
# Headers come only from a matched endpoint's key; None when
# `ep` is None (raw admin URL or no match), so a comparison can
# never inherit another user's key/headers.
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
resolved.append((sid, model, session_endpoint_url, headers))
finally:
db.close()
# Both endpoints validated — only now create the ephemeral [CMP]
# sessions and copy any resolved headers.
for sid, model, session_endpoint_url, headers in resolved:
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
session_manager.create_session(
session_id=sid,
name=name,
endpoint_url=session_endpoint_url,
model=model,
rag=False,
owner=user,
)
if headers:
s = session_manager.sessions.get(sid)
if s:
s.headers = headers
# Store comparison record
db = SessionLocal()
try:
comp = Comparison(
id=comp_id,
prompt=prompt,
model_a=model_a,
model_b=model_b,
# Record the URL the session actually dials. For URL callers this
# is their raw input; for id-only callers (empty endpoint_a/_b)
# fall back to the resolved endpoint URL so the column stays
# meaningful and non-null. resolved is in [a, b] order.
endpoint_a=endpoint_a or resolved[0][2],
endpoint_b=endpoint_b or resolved[1][2],
is_blind=blind,
blind_mapping=json.dumps(mapping),
owner=user,
)
db.add(comp)
db.commit()
finally:
db.close()
# In blind mode, withhold the model identities AND the left/right
# mapping from the response. The client already knows model_a/model_b
# (it sent them), so returning either would defeat blind mode. They are
# revealed by POST /api/compare/{id}/vote once the user has voted (#1285).
return {
"id": comp_id,
"session_left": session_left,
"session_right": session_right,
"model_left": None if blind else (model_a if mapping["left"] == "a" else model_b),
"model_right": None if blind else (model_a if mapping["right"] == "a" else model_b),
"is_blind": blind,
"mapping": None if blind else mapping,
}
@router.post("/{comp_id}/vote")
def vote_comparison(
request: Request,
comp_id: str,
winner: str = Form(...), # "left", "right", or "tie"
):
"""Record the user's vote and reveal model names if blind."""
user = get_current_user(request)
db = SessionLocal()
try:
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
if not comp:
raise HTTPException(404, "Comparison not found")
# SECURITY: strict ownership — null-owner Comparisons were
# accessible to every user.
if user and comp.owner != user:
raise HTTPException(404, "Comparison not found")
if comp.winner:
raise HTTPException(400, "Already voted")
mapping = json.loads(comp.blind_mapping) if comp.blind_mapping else {"left": "a", "right": "b"}
if winner == "tie":
comp.winner = "tie"
elif winner == "left":
comp.winner = mapping["left"]
elif winner == "right":
comp.winner = mapping["right"]
else:
raise HTTPException(400, "winner must be 'left', 'right', or 'tie'")
comp.voted_at = datetime.utcnow()
db.commit()
return {
"winner": comp.winner,
"model_a": comp.model_a,
"model_b": comp.model_b,
"revealed": {
"left": comp.model_a if mapping["left"] == "a" else comp.model_b,
"right": comp.model_a if mapping["right"] == "a" else comp.model_b,
},
}
finally:
db.close()
@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)
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 ""
# 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})
else:
blind_mapping = None
db = SessionLocal()
try:
comp = Comparison(
id=comp_id,
prompt=body.prompt[:500],
model_a=model_a,
model_b=model_b,
endpoint_a="",
endpoint_b="",
winner=body.winner,
is_blind=body.is_blind,
blind_mapping=blind_mapping,
voted_at=datetime.utcnow(),
owner=user,
)
db.add(comp)
db.commit()
finally:
db.close()
return {"status": "ok", "id": comp_id}
@router.get("/history")
def list_comparisons(request: Request):
"""List past comparisons."""
user = get_current_user(request)
db = SessionLocal()
try:
q = db.query(Comparison)
if user:
q = q.filter(Comparison.owner == user)
comps = q.order_by(Comparison.created_at.desc()).limit(50).all()
return [
{
"id": c.id,
"prompt": c.prompt[:100],
"model_a": c.model_a,
"model_b": c.model_b,
"winner": c.winner,
"is_blind": c.is_blind,
"voted_at": c.voted_at.isoformat() if c.voted_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in comps
]
finally:
db.close()
@router.delete("/{comp_id}")
def delete_comparison(request: Request, comp_id: str):
"""Delete a comparison and its ephemeral sessions."""
user = get_current_user(request)
db = SessionLocal()
try:
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
if not comp:
raise HTTPException(404, "Comparison not found")
# SECURITY: strict ownership — null-owner Comparisons were
# accessible to every user.
if user and comp.owner != user:
raise HTTPException(404, "Comparison not found")
db.delete(comp)
db.commit()
return {"status": "deleted"}
finally:
db.close()
return router
+71 -7
View File
@@ -463,14 +463,22 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" if sz == 0 and os.path.isdir(snap):", " if sz == 0 and os.path.isdir(snap):",
" sz2, nf2, ic2 = snapshot_size()", " sz2, nf2, ic2 = snapshot_size()",
" sz, nf, ic = sz2, nf2, ic or ic2", " sz, nf, ic = sz2, nf2, ic or ic2",
" is_diffusion = False; gguf_files = []", " is_video = bool(re.search(r'(?i)(^|/)Lightricks/LTX-|(^|/)LTX[-_/]|video|text-to-video|image-to-video', rid))",
" is_diffusion = is_video; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', rid)); gguf_files = []",
" if os.path.isdir(snap):", " if os.path.isdir(snap):",
" for sd in os.listdir(snap):", " for sd in os.listdir(snap):",
" sf = os.path.join(snap, sd)", " sf = os.path.join(snap, sd)",
" if not os.path.isdir(sf): continue", " if not os.path.isdir(sf): continue",
" if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True", " if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True",
" if os.path.exists(os.path.join(sf, 'adapter_config.json')) or os.path.exists(os.path.join(sf, 'adapter_model.safetensors')): is_adapter = True",
" for _root, _dirs, _fns in safe_walk(sf):",
" for _fn in _fns:",
" _lfn = _fn.lower()",
" if _lfn.endswith('.safetensors') and re.search(r'(?i)(ltx|video|upscaler)', _lfn): is_video = True; is_diffusion = True",
" if _lfn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in _lfn:",
" is_adapter = True",
" for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)", " for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)",
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})", " models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_video':is_video,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
"def hf_cache_paths():", "def hf_cache_paths():",
" candidates = []", " candidates = []",
" def add(p):", " def add(p):",
@@ -505,11 +513,12 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" fp = os.path.join(p, d)", " fp = os.path.join(p, d)",
" if not os.path.isdir(fp) or os.path.islink(fp) or not safe_path(fp): continue", " if not os.path.isdir(fp) or os.path.islink(fp) or not safe_path(fp): continue",
" if d in seen: continue", " if d in seen: continue",
" is_model = False; gguf_files = []", " is_model = False; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', d)); gguf_files = []",
" for root, dirs, fns in safe_walk(fp):", " for root, dirs, fns in safe_walk(fp):",
" for fn in fns:", " for fn in fns:",
" if fn.lower().endswith('.gguf'): is_model = True", " if fn.lower().endswith('.gguf'): is_model = True",
" elif fn == 'config.json' or fn.endswith('.safetensors') or fn.endswith('.bin'): is_model = True", " elif fn == 'config.json' or fn.endswith('.safetensors') or fn.endswith('.bin'): is_model = True",
" if fn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in fn.lower(): is_adapter = True",
" if is_model: break", " if is_model: break",
" if not is_model: continue", " if not is_model: continue",
" gguf_files = collect_ggufs(fp)", " gguf_files = collect_ggufs(fp)",
@@ -520,7 +529,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" try: nf += 1; sz += os.path.getsize(os.path.join(dp, fn))", " try: nf += 1; sz += os.path.getsize(os.path.join(dp, fn))",
" except Exception: pass", " except Exception: pass",
" is_diff = os.path.exists(os.path.join(fp, 'model_index.json'))", " is_diff = os.path.exists(os.path.join(fp, 'model_index.json'))",
" models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})", " models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
"def parse_size(num, unit):", "def parse_size(num, unit):",
" try: n = float(num)", " try: n = float(num)",
" except Exception: return 0", " except Exception: return 0",
@@ -1195,6 +1204,41 @@ def _safe_env_prefix(ep: str | None) -> str | None:
return f'[ -f "{path}" ] && source "{path}" || true' return f'[ -f "{path}" ] && source "{path}" || true'
def _local_windows_bash_env_prefix(ep: str | None) -> str | None:
"""Convert a frontend PowerShell venv prefix for the local Git Bash runner."""
if not ep:
return ep
prefix = ep.strip()
if not prefix.startswith("&"):
return ep
raw_path = prefix[1:].lstrip()
if not raw_path:
return ep
if raw_path.startswith("'"):
if len(raw_path) < 2 or not raw_path.endswith("'"):
return ep
quoted_path = raw_path[1:-1]
if "'" in quoted_path.replace("''", ""):
return ep
path = quoted_path.replace("''", "'")
else:
path = raw_path.rstrip()
if "'" in path or '"' in path:
return ep
if any(c in path for c in "\r\n;&|`$<>"):
return ep
if not path.replace("\\", "/").casefold().endswith("/scripts/activate.ps1"):
return ep
bash_path = _git_bash_path(path)
if "\\" in bash_path:
return ep
bash_path = bash_path[: -len("Activate.ps1")] + "activate"
return "source " + shlex.quote(bash_path)
def _ssh_ps(host, script_path, port=None): def _ssh_ps(host, script_path, port=None):
"""Build SSH command to run a PowerShell script on a Windows remote.""" """Build SSH command to run a PowerShell script on a Windows remote."""
pf = f"-p {port} " if port and port != "22" else "" pf = f"-p {port} " if port and port != "22" else ""
@@ -1320,6 +1364,26 @@ def _diagnose_serve_output(text: str) -> dict | None:
"MLX LM is not installed on this server.", "MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}], [{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
), ),
(
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
),
(
r"mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['\"]?mflux",
"MLX image serving requires mflux on this Apple Silicon server.",
[{"label": "install mflux in Cookbook Dependencies", "op": "dependency", "package": "mflux"}],
),
(
r"mlx-lama-swift|odysseus-mlx-inpaint|mlx-lama-serve|LaMa / MI-GAN MLX inpainting models require",
"LaMa / MI-GAN MLX inpainting requires an Odysseus-compatible mlx-lama-swift bridge on this Apple Silicon server.",
[{"label": "build mlx-lama-swift bridge and put odysseus-mlx-inpaint or mlx-lama-serve on PATH", "op": "dependency", "package": "mlx_lama_swift"}],
),
(
r"mlx-ddcolor-swift|odysseus-mlx-colorize|mlx-ddcolor-serve|DDColor MLX models require",
"DDColor MLX colorization requires an Odysseus-compatible mlx-ddcolor-swift bridge on this Apple Silicon server.",
[{"label": "build mlx-ddcolor-swift bridge and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH", "op": "dependency", "package": "mlx_ddcolor_swift"}],
),
( (
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear", r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.", "MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
@@ -1358,9 +1422,9 @@ def _diagnose_serve_output(text: str) -> dict | None:
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}], [{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
), ),
( (
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers", r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
"Diffusion serving requires PyTorch and diffusers.", "Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}], [{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
), ),
( (
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review", r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
+227 -30
View File
@@ -50,7 +50,7 @@ from routes.cookbook_helpers import (
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token, _SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
_validate_local_dir, _validate_gpus, _shell_path, _validate_local_dir, _validate_gpus, _shell_path,
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT, _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines, _safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script, _append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
load_stored_hf_token, load_stored_hf_token,
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain, _append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
@@ -73,6 +73,47 @@ _HF_TOKEN_STATUS_SNIPPET = (
) )
def _windows_local_pid_record_line(pid_path: Path, ready_path: Path) -> str:
"""Build the Git Bash prelude that records a Win32-stoppable PID.
Python publishes the detached outer process's Win32 PID first, then touches
``ready_path``. The inner Git Bash runner waits for that publication before
replacing the fallback with its own Win32 PID from /proc/<msys-pid>/winpid.
Missing, malformed, or late mappings leave the valid outer PID untouched.
"""
pp = shlex.quote(pid_path.as_posix())
rp = shlex.quote(ready_path.as_posix())
return (
"i=0; "
f"while [ ! -e {rp} ] && [ \"$i\" -lt 500 ]; do "
"i=$((i+1)); sleep 0.01; done; "
f"if [ -e {rp} ]; then "
"winpid=\"$(cat /proc/$$/winpid 2>/dev/null || true)\"; "
"case \"$winpid\" in ''|*[!0-9]*) ;; "
f"*) printf '%s\\n' \"$winpid\" > {pp} ;; esac; "
"fi; "
f"rm -f {rp}"
)
def _append_mlx_image_server_script(runner_lines: list[str]) -> None:
"""Write the MLX image API helper next to the tmux runner on remote hosts."""
script_path = Path(__file__).resolve().parents[1] / "scripts" / "mlx_image_server.py"
try:
script = script_path.read_text(encoding="utf-8")
except Exception as e:
logger.warning("Failed to read mlx_image_server.py: %s", e)
runner_lines.append('echo "ERROR: Odysseus could not prepare the MLX image server helper."')
runner_lines.append('ODYSSEUS_PREFLIGHT_EXIT=127')
return
runner_lines.append('mkdir -p scripts')
runner_lines.append("cat > scripts/mlx_image_server.py <<'PY'")
runner_lines.extend(script.splitlines())
runner_lines.append("PY")
runner_lines.append('chmod +x scripts/mlx_image_server.py 2>/dev/null || true')
def _venv_root_from_serve_cmd(cmd: str) -> str: def _venv_root_from_serve_cmd(cmd: str) -> str:
"""Best-effort venv root from an absolute venv python in a serve command.""" """Best-effort venv root from an absolute venv python in a serve command."""
try: try:
@@ -492,6 +533,11 @@ def setup_cookbook_routes() -> APIRouter:
"MLX LM is not installed on this server.", "MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}], [{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
), ),
(
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
),
( (
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear", r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.", "MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
@@ -530,9 +576,9 @@ def setup_cookbook_routes() -> APIRouter:
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}], [{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
), ),
( (
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers", r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
"Diffusion serving requires PyTorch and diffusers.", "Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}], [{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
), ),
( (
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review", r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
@@ -956,15 +1002,18 @@ def setup_cookbook_routes() -> APIRouter:
directly (simple commands only). Returns the launched job record.""" directly (simple commands only). Returns the launched job record."""
log_path = TMUX_LOG_DIR / f"{session_id}.log" log_path = TMUX_LOG_DIR / f"{session_id}.log"
pid_path = TMUX_LOG_DIR / f"{session_id}.pid" pid_path = TMUX_LOG_DIR / f"{session_id}.pid"
pid_ready_path: Path | None = None
bash = find_bash() bash = find_bash()
if bash: if bash:
# Run the existing bash wrapper verbatim through Git Bash, redirecting # Run the existing bash wrapper verbatim through Git Bash, redirecting
# all output to the log the poller reads. Paths handed to bash use # all output to the log the poller reads. Paths handed to bash use
# POSIX form + shell-quoting so drive paths / spaces survive. # POSIX form + shell-quoting so drive paths / spaces survive.
inner = TMUX_LOG_DIR / f"{session_id}_run.sh" inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
pp = shlex.quote(pid_path.as_posix()) pid_ready_path = TMUX_LOG_DIR / f"{session_id}.pid.ready"
pid_ready_path.unlink(missing_ok=True)
inner.write_text( inner.write_text(
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n", _windows_local_pid_record_line(pid_path, pid_ready_path) + "\n"
+ "\n".join(bash_lines) + "\n",
encoding="utf-8", encoding="utf-8",
) )
lp = shlex.quote(log_path.as_posix()) lp = shlex.quote(log_path.as_posix())
@@ -998,7 +1047,18 @@ def setup_cookbook_routes() -> APIRouter:
env=env, env=env,
**detached_popen_kwargs(), **detached_popen_kwargs(),
) )
# Publish a valid Win32 ancestor first. The Git Bash runner may then
# replace it with its own Win32 pid, but never before this fallback exists.
pid_path.write_text(str(proc.pid), encoding="utf-8") pid_path.write_text(str(proc.pid), encoding="utf-8")
if pid_ready_path is not None:
try:
pid_ready_path.touch()
except OSError as e:
logger.warning(
"Could not publish Windows local PID handoff for %s: %s",
session_id,
e,
)
return {"pid": proc.pid, "log_path": str(log_path)} return {"pid": proc.pid, "log_path": str(log_path)}
@router.post("/api/model/download") @router.post("/api/model/download")
@@ -1276,7 +1336,7 @@ def setup_cookbook_routes() -> APIRouter:
# Local: run hf download in the background (tmux on POSIX, a detached # Local: run hf download in the background (tmux on POSIX, a detached
# process + logfile on Windows where tmux doesn't exist). # process + logfile on Windows where tmux doesn't exist).
if req.env_prefix: if req.env_prefix:
lines.append(_safe_env_prefix(req.env_prefix)) lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
else: else:
lines.append("deactivate 2>/dev/null; hash -r") lines.append("deactivate 2>/dev/null; hash -r")
# Show whether the HF token reached this run (masked) — tells a gated # Show whether the HF token reached this run (masked) — tells a gated
@@ -1435,6 +1495,8 @@ def setup_cookbook_routes() -> APIRouter:
"status": "downloading" if m["has_incomplete"] else "ready", "status": "downloading" if m["has_incomplete"] else "ready",
"path": m.get("path", ""), "path": m.get("path", ""),
"is_diffusion": m.get("is_diffusion", False), "is_diffusion": m.get("is_diffusion", False),
"is_video": m.get("is_video", False),
"is_adapter": m.get("is_adapter", False),
} }
if m.get("is_local_dir"): if m.get("is_local_dir"):
entry["is_local_dir"] = True entry["is_local_dir"] = True
@@ -1460,6 +1522,7 @@ def setup_cookbook_routes() -> APIRouter:
"""Register a diffusion model as an image endpoint so it appears in the model selector.""" """Register a diffusion model as an image endpoint so it appears in the model selector."""
import re import re
from core.database import SessionLocal, ModelEndpoint from core.database import SessionLocal, ModelEndpoint
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server # Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd) port_match = re.search(r'--port\s+(\d+)', req.cmd)
@@ -1477,6 +1540,7 @@ def setup_cookbook_routes() -> APIRouter:
# Friendly display name from repo_id # Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)" display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal() db = SessionLocal()
try: try:
@@ -1486,7 +1550,16 @@ def setup_cookbook_routes() -> APIRouter:
existing.is_enabled = True existing.is_enabled = True
existing.model_type = "image" existing.model_type = "image"
existing.name = display_name existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit() db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}") logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id return existing.id
@@ -1498,9 +1571,18 @@ def setup_cookbook_routes() -> APIRouter:
api_key=None, api_key=None,
is_enabled=True, is_enabled=True,
model_type="image", model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
) )
db.add(ep) db.add(ep)
db.commit() db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}") logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id return ep_id
except Exception as e: except Exception as e:
@@ -2084,7 +2166,7 @@ def setup_cookbook_routes() -> APIRouter:
if req.gpus: if req.gpus:
runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'") runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'")
if req.env_prefix: if req.env_prefix:
runner_lines.append(_safe_env_prefix(req.env_prefix)) runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
else: else:
runner_lines.append("deactivate 2>/dev/null; hash -r") runner_lines.append("deactivate 2>/dev/null; hash -r")
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd) _append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)
@@ -2356,24 +2438,8 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append('fi') runner_lines.append('fi')
elif "sglang.launch_server" in req.cmd: elif "sglang.launch_server" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
runner_lines.append('if ! command -v sglang &>/dev/null; then')
runner_lines.append(' echo "ERROR: SGLang is not installed."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('elif ! ODYSSEUS_SGLANG_IMPORT_ERROR="$(python3 -c "import sglang" 2>&1)"; then')
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import."')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
elif "mlx_lm.server" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$(python3 -c "import mlx_lm" 2>&1)"; then')
runner_lines.append(' echo "ERROR: MLX LM is not installed."')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'") runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then') runner_lines.append('ODYSSEUS_SGLANG_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append(' ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys') runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])') runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"') runner_lines.append('py = "python3"')
@@ -2384,6 +2450,36 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append('print(py)') runner_lines.append('print(py)')
runner_lines.append('PY') runner_lines.append('PY')
runner_lines.append(')"') runner_lines.append(')"')
runner_lines.append('if ! "$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" &>/dev/null; then')
runner_lines.append(' if ! command -v sglang &>/dev/null; then')
runner_lines.append(' echo "ERROR: SGLang is not installed."')
runner_lines.append(' else')
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import in the launch Python."')
runner_lines.append(' fi')
runner_lines.append(' ODYSSEUS_SGLANG_IMPORT_ERROR="$("$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" 2>&1)"')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
elif "mlx_lm.server" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append('ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"')
runner_lines.append('for i, part in enumerate(parts):')
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
runner_lines.append(' py = part')
runner_lines.append(' break')
runner_lines.append('print(py)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$("$ODYSSEUS_MLX_CMD_PY" -c "import mlx_lm" 2>&1)"; then')
runner_lines.append(' echo "ERROR: MLX LM is not installed in the launch Python: $ODYSSEUS_MLX_CMD_PY"')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
runner_lines.append(' ODYSSEUS_SERVE_CMD="$("$ODYSSEUS_MLX_CMD_PY" - "$ODYSSEUS_SERVE_CMD" <<\'PY\'') runner_lines.append(' ODYSSEUS_SERVE_CMD="$("$ODYSSEUS_MLX_CMD_PY" - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import json, os, shlex, sys') runner_lines.append('import json, os, shlex, sys')
runner_lines.append('from pathlib import Path') runner_lines.append('from pathlib import Path')
@@ -2474,10 +2570,111 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append('PY') runner_lines.append('PY')
runner_lines.append(')"') runner_lines.append(')"')
runner_lines.append('fi') runner_lines.append('fi')
elif "scripts/mlx_image_server.py" in req.cmd or ".mlx_image_server.py" in req.cmd:
_append_mlx_image_server_script(runner_lines)
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append('ODYSSEUS_MLX_IMAGE_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"')
runner_lines.append('for part in parts:')
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
runner_lines.append(' py = part')
runner_lines.append(' break')
runner_lines.append('print(py)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('ODYSSEUS_MLX_IMAGE_BIN_DIR="$(dirname "$ODYSSEUS_MLX_IMAGE_CMD_PY" 2>/dev/null || true)"')
runner_lines.append('if [ -n "$ODYSSEUS_MLX_IMAGE_BIN_DIR" ]; then export PATH="$ODYSSEUS_MLX_IMAGE_BIN_DIR:$PATH"; fi')
runner_lines.append('if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import fastapi, uvicorn, multipart" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: MLX image serving requires FastAPI + uvicorn + python-multipart in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY. Install the MLX image dependencies in Cookbook Dependencies."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
runner_lines.append('ODYSSEUS_MLX_IMAGE_MODEL="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('model = ""')
runner_lines.append('for i, part in enumerate(parts):')
runner_lines.append(' if part == "--model" and i + 1 < len(parts):')
runner_lines.append(' model = parts[i + 1]')
runner_lines.append(' break')
runner_lines.append('print(model)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('if printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi hidream; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import mlx, mlx_vlm, transformers, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: HiDream MLX serving needs the model requirements in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart mlx mlx-vlm \'transformers>=4.57.0,<6.0\' huggingface_hub safetensors numpy pillow tqdm sentencepiece hf_transfer"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi boogu; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import boogu_image_mlx, mlx, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: Boogu MLX serving needs boogu-image-mlx in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "ddcolor"; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: DDColor MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' if ! command -v odysseus-mlx-colorize >/dev/null 2>&1 && ! command -v mlx-ddcolor-serve >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: DDColor MLX serving requires the Odysseus mlx-ddcolor-swift bridge on PATH: odysseus-mlx-colorize or mlx-ddcolor-serve."')
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' ODYSSEUS_DDCOLOR_BIN="$(command -v odysseus-mlx-colorize 2>/dev/null || command -v mlx-ddcolor-serve 2>/dev/null || true)"')
runner_lines.append(' if [ -n "$ODYSSEUS_DDCOLOR_BIN" ]; then')
runner_lines.append(' ODYSSEUS_DDCOLOR_DIR="$(dirname "$ODYSSEUS_DDCOLOR_BIN")"')
runner_lines.append(' if [ ! -f "$ODYSSEUS_DDCOLOR_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_DDCOLOR_DIR/default.metallib" ]; then')
runner_lines.append(' echo "ERROR: DDColor MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
runner_lines.append(' echo "Run the DDColor MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "mi-gan|migan|lama"; then')
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' if ! command -v odysseus-mlx-inpaint >/dev/null 2>&1 && ! command -v mlx-lama-serve >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving requires the Odysseus mlx-lama-swift bridge on PATH: odysseus-mlx-inpaint or mlx-lama-serve."')
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' ODYSSEUS_INPAINT_BIN="$(command -v odysseus-mlx-inpaint 2>/dev/null || command -v mlx-lama-serve 2>/dev/null || true)"')
runner_lines.append(' if [ -n "$ODYSSEUS_INPAINT_BIN" ]; then')
runner_lines.append(' ODYSSEUS_INPAINT_DIR="$(dirname "$ODYSSEUS_INPAINT_BIN")"')
runner_lines.append(' if [ ! -f "$ODYSSEUS_INPAINT_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_INPAINT_DIR/default.metallib" ]; then')
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
runner_lines.append(' echo "Run the LaMa / MI-GAN MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append('elif ! command -v mflux-generate >/dev/null 2>&1 && ! command -v mflux-generate-qwen >/dev/null 2>&1; then')
runner_lines.append(' echo "ERROR: mflux-compatible MLX image serving requires mflux-generate or mflux-generate-qwen in PATH for launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U mflux fastapi uvicorn python-multipart"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
elif "scripts/diffusion_server.py" in req.cmd or ".diffusion_server.py" in req.cmd: elif "scripts/diffusion_server.py" in req.cmd or ".diffusion_server.py" in req.cmd:
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$(python3 -c "import torch, diffusers" 2>&1)"; then') runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + diffusers."') runner_lines.append('ODYSSEUS_DIFFUSION_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
runner_lines.append('import shlex, sys')
runner_lines.append('parts = shlex.split(sys.argv[1])')
runner_lines.append('py = "python3"')
runner_lines.append('for part in parts:')
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
runner_lines.append(' py = part')
runner_lines.append(' break')
runner_lines.append('print(py)')
runner_lines.append('PY')
runner_lines.append(')"')
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$("$ODYSSEUS_DIFFUSION_CMD_PY" -c "import torch, torchvision, diffusers" 2>&1)"; then')
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + Torchvision + diffusers in the launch Python: $ODYSSEUS_DIFFUSION_CMD_PY."')
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_DIFFUSION_IMPORT_ERROR"') runner_lines.append(' printf "%s\\n" "$ODYSSEUS_DIFFUSION_IMPORT_ERROR"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi') runner_lines.append('fi')
@@ -2588,8 +2785,8 @@ def setup_cookbook_routes() -> APIRouter:
# endpoint; any other real model serve (i.e. not a pip-install task) gets # endpoint; any other real model serve (i.e. not a pip-install task) gets
# a local LLM endpoint pointed at its /v1. # a local LLM endpoint pointed at its /v1.
endpoint_id = None endpoint_id = None
is_diffusion = "diffusion_server.py" in req.cmd is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
if is_diffusion: if is_image_endpoint:
endpoint_id = _auto_register_image_endpoint(req, remote) endpoint_id = _auto_register_image_endpoint(req, remote)
elif not is_pip_install: elif not is_pip_install:
endpoint_id = _auto_register_llm_endpoint(req, remote) endpoint_id = _auto_register_llm_endpoint(req, remote)
@@ -2605,7 +2802,7 @@ def setup_cookbook_routes() -> APIRouter:
# if N != 0 within the watch window, delete the endpoint we just # if N != 0 within the watch window, delete the endpoint we just
# created. Skipped for diffusion (different image-endpoint cleanup # created. Skipped for diffusion (different image-endpoint cleanup
# path) and pip-install tasks (no endpoint to drop). # path) and pip-install tasks (no endpoint to drop).
if endpoint_id and not is_diffusion and not is_pip_install: if endpoint_id and not is_image_endpoint and not is_pip_install:
asyncio.create_task(_serve_crash_watchdog( asyncio.create_task(_serve_crash_watchdog(
endpoint_id=endpoint_id, endpoint_id=endpoint_id,
session_id=session_id, session_id=session_id,
+6
View File
@@ -0,0 +1,6 @@
"""Document route domain package (slice 2m, #4082/#4071).
Contains document_routes.py and document_helpers.py, migrated from the flat
routes/ directory. Backward-compat shims at routes/document_routes.py and
routes/document_helpers.py re-export from here.
"""
+243
View File
@@ -0,0 +1,243 @@
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
"""Document routes — CRUD for living documents with version history."""
import logging
import os
import re
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
from pydantic import BaseModel
from core.database import Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import _auth_disabled
from src.upload_handler import UploadHandler
logger = logging.getLogger(__name__)
# ---- Request schemas ----
class DocumentCreate(BaseModel):
session_id: Optional[str] = None
title: str = "Untitled"
language: Optional[str] = None
content: str = ""
class DocumentUpdate(BaseModel):
content: str
summary: Optional[str] = None
force_version: bool = False
class DocumentPatch(BaseModel):
title: Optional[str] = None
language: Optional[str] = None
session_id: Optional[str] = None # link/unlink document to a session
# ---- Helpers ----
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
return {
"id": doc.id,
"session_id": doc.session_id,
"title": doc.title,
"language": doc.language,
"current_content": doc.current_content,
"version_count": doc.version_count,
"is_active": doc.is_active,
"archived": bool(getattr(doc, "archived", False)),
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
# Source-email provenance (set when doc was created from an email
# attachment) — drives the "Send signed reply" menu item.
"source_email_uid": getattr(doc, "source_email_uid", None),
"source_email_folder": getattr(doc, "source_email_folder", None),
"source_email_account_id": getattr(doc, "source_email_account_id", None),
"source_email_message_id": getattr(doc, "source_email_message_id", None),
}
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
return {
"id": v.id,
"document_id": v.document_id,
"version_number": v.version_number,
"content": v.content,
"summary": v.summary,
"source": v.source,
"created_at": v.created_at.isoformat() if v.created_at else None,
}
def _verify_doc_owner(db, doc: Document, user: str):
"""Verify `user` owns this document. Raise 404 if not.
Documents now carry their own `owner` column, so a doc whose session
was deleted (session_id → NULL) can still prove ownership and stay
openable / cloneable. We trust that column first and only fall back to
the session join for any not-yet-backfilled legacy row.
"""
if user is None:
if _auth_disabled():
return # Single-user / no-auth mode: allow access
raise HTTPException(403, "Authentication required")
if doc.owner is not None:
if doc.owner != user:
raise HTTPException(404, "Document not found")
return
# Legacy fallback: derive ownership from the linked session.
if not doc.session_id:
raise HTTPException(404, "Document not found")
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
if not session or session.owner != user:
raise HTTPException(404, "Document not found")
def _owner_session_filter(q, user):
"""Restrict a documents query to those owned by `user`.
Documents now carry their own `owner` column (backfilled at boot from
the linked session, or assigned to the admin user for legacy/orphaned
docs). We filter on that directly rather than on a session join, so a
document whose session was deleted (session_id → NULL) still shows up
for its owner instead of silently vanishing from the Library + search.
The owner backfill runs in init_db before the app serves requests, so
by the time this filter is live there are no NULL-owner rows to leak;
we therefore match the owner strictly for authenticated callers."""
if not user:
if user == "" or _auth_disabled():
return q
return q.filter(False)
return q.filter(Document.owner == user)
def _slug(name: str) -> str:
"""Filesystem-friendly version of a document title.
Whitespace becomes underscores; other unsafe punctuation is dropped.
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
"""
import re as _re
s = (name or "").strip()
# Drop the trailing extension if the title happens to include one
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
s = _re.sub(r'\s+', '_', s)
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
s = _re.sub(r'_+', '_', s).strip('_')
return s or "form"
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
_PDF_RENDER_SCALE = 2.0
def _upload_path_inside(upload_dir: str, path: str) -> bool:
base = os.path.realpath(upload_dir)
p = os.path.realpath(path)
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
def _resolve_user_upload_path(
upload_handler: Any,
upload_id: str,
owner: Optional[str],
auth_manager=None,
) -> Optional[str]:
"""Resolve an upload id to a filesystem path the caller may read."""
if upload_handler is None:
return None
resolved = upload_handler.resolve_upload(
upload_id,
owner=owner,
auth_manager=auth_manager,
)
if not isinstance(resolved, dict) or not resolved:
return None
path = resolved.get("path")
upload_dir = getattr(upload_handler, "upload_dir", None)
if path and upload_dir and not _upload_path_inside(upload_dir, path):
logger.warning("Upload path outside upload directory: %s", path)
return None
return path
def _locate_upload(
upload_dir: str,
file_id: str,
owner: Optional[str] = None,
auth_manager=None,
upload_handler: Any = None,
):
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
if upload_handler is None:
from src.upload_handler import UploadHandler
base_dir = os.path.dirname(os.path.abspath(upload_dir))
upload_handler = UploadHandler(base_dir, upload_dir)
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
def _assert_pdf_marker_upload_owned(
request: Request,
content: str,
user: Optional[str],
upload_handler: Any,
) -> None:
"""Reject document content whose pdf_source marker points at another user's upload."""
if upload_handler is None:
return
from src.pdf_form_doc import find_source_upload_id
upload_id = find_source_upload_id(content or "")
if not upload_id:
return
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
raise HTTPException(
400,
"Document PDF marker references an upload you do not own",
)
def _derive_title(content: str) -> str:
"""Derive a title from document content."""
import re
if not isinstance(content, str):
return "Untitled"
text = content.strip()
if not text:
return "Untitled"
# Markdown header
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
if md:
title = md.group(1).strip()
if len(title) > 50:
title = title[:48] + "…"
return title
# HTML heading
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
if html:
title = html.group(1).strip()
if len(title) > 50:
title = title[:48] + "…"
return title
# First non-empty line (if short enough)
for line in text.split('\n'):
line = line.strip()
if line and 2 <= len(line) <= 60:
title = re.sub(r'[:#*`]+$', '', line).strip()
if title and len(title) > 50:
title = title[:48] + "…"
return title or "Untitled"
return "Untitled"
File diff suppressed because it is too large Load Diff
+10 -239
View File
@@ -1,243 +1,14 @@
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py.""" """Backward-compat shim — canonical location is routes/document/document_helpers.py.
"""Document routes — CRUD for living documents with version history.""" This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.document_helpers``, ``from routes.document_helpers import
X``, and the ``sys.modules.pop("routes.document_helpers")`` + re-import
pattern used by test_security_regressions.py all operate on the *same* object.
Keeps existing import paths working after slice 2m (#4082/#4071).
"""
import logging import sys as _sys
import os
import re
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request from routes.document import document_helpers as _canonical # noqa: F401
from pydantic import BaseModel
from core.database import Document, DocumentVersion _sys.modules[__name__] = _canonical
from core.database import Session as DbSession
from src.auth_helpers import _auth_disabled
from src.upload_handler import UploadHandler
logger = logging.getLogger(__name__)
# ---- Request schemas ----
class DocumentCreate(BaseModel):
session_id: Optional[str] = None
title: str = "Untitled"
language: Optional[str] = None
content: str = ""
class DocumentUpdate(BaseModel):
content: str
summary: Optional[str] = None
force_version: bool = False
class DocumentPatch(BaseModel):
title: Optional[str] = None
language: Optional[str] = None
session_id: Optional[str] = None # link/unlink document to a session
# ---- Helpers ----
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
return {
"id": doc.id,
"session_id": doc.session_id,
"title": doc.title,
"language": doc.language,
"current_content": doc.current_content,
"version_count": doc.version_count,
"is_active": doc.is_active,
"archived": bool(getattr(doc, "archived", False)),
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
# Source-email provenance (set when doc was created from an email
# attachment) — drives the "Send signed reply" menu item.
"source_email_uid": getattr(doc, "source_email_uid", None),
"source_email_folder": getattr(doc, "source_email_folder", None),
"source_email_account_id": getattr(doc, "source_email_account_id", None),
"source_email_message_id": getattr(doc, "source_email_message_id", None),
}
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
return {
"id": v.id,
"document_id": v.document_id,
"version_number": v.version_number,
"content": v.content,
"summary": v.summary,
"source": v.source,
"created_at": v.created_at.isoformat() if v.created_at else None,
}
def _verify_doc_owner(db, doc: Document, user: str):
"""Verify `user` owns this document. Raise 404 if not.
Documents now carry their own `owner` column, so a doc whose session
was deleted (session_id → NULL) can still prove ownership and stay
openable / cloneable. We trust that column first and only fall back to
the session join for any not-yet-backfilled legacy row.
"""
if user is None:
if _auth_disabled():
return # Single-user / no-auth mode: allow access
raise HTTPException(403, "Authentication required")
if doc.owner is not None:
if doc.owner != user:
raise HTTPException(404, "Document not found")
return
# Legacy fallback: derive ownership from the linked session.
if not doc.session_id:
raise HTTPException(404, "Document not found")
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
if not session or session.owner != user:
raise HTTPException(404, "Document not found")
def _owner_session_filter(q, user):
"""Restrict a documents query to those owned by `user`.
Documents now carry their own `owner` column (backfilled at boot from
the linked session, or assigned to the admin user for legacy/orphaned
docs). We filter on that directly rather than on a session join, so a
document whose session was deleted (session_id → NULL) still shows up
for its owner instead of silently vanishing from the Library + search.
The owner backfill runs in init_db before the app serves requests, so
by the time this filter is live there are no NULL-owner rows to leak;
we therefore match the owner strictly for authenticated callers."""
if not user:
if user == "" or _auth_disabled():
return q
return q.filter(False)
return q.filter(Document.owner == user)
def _slug(name: str) -> str:
"""Filesystem-friendly version of a document title.
Whitespace becomes underscores; other unsafe punctuation is dropped.
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
"""
import re as _re
s = (name or "").strip()
# Drop the trailing extension if the title happens to include one
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
s = _re.sub(r'\s+', '_', s)
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
s = _re.sub(r'_+', '_', s).strip('_')
return s or "form"
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
_PDF_RENDER_SCALE = 2.0
def _upload_path_inside(upload_dir: str, path: str) -> bool:
base = os.path.realpath(upload_dir)
p = os.path.realpath(path)
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
def _resolve_user_upload_path(
upload_handler: Any,
upload_id: str,
owner: Optional[str],
auth_manager=None,
) -> Optional[str]:
"""Resolve an upload id to a filesystem path the caller may read."""
if upload_handler is None:
return None
resolved = upload_handler.resolve_upload(
upload_id,
owner=owner,
auth_manager=auth_manager,
)
if not isinstance(resolved, dict) or not resolved:
return None
path = resolved.get("path")
upload_dir = getattr(upload_handler, "upload_dir", None)
if path and upload_dir and not _upload_path_inside(upload_dir, path):
logger.warning("Upload path outside upload directory: %s", path)
return None
return path
def _locate_upload(
upload_dir: str,
file_id: str,
owner: Optional[str] = None,
auth_manager=None,
upload_handler: Any = None,
):
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
if upload_handler is None:
from src.upload_handler import UploadHandler
base_dir = os.path.dirname(os.path.abspath(upload_dir))
upload_handler = UploadHandler(base_dir, upload_dir)
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
def _assert_pdf_marker_upload_owned(
request: Request,
content: str,
user: Optional[str],
upload_handler: Any,
) -> None:
"""Reject document content whose pdf_source marker points at another user's upload."""
if upload_handler is None:
return
from src.pdf_form_doc import find_source_upload_id
upload_id = find_source_upload_id(content or "")
if not upload_id:
return
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
raise HTTPException(
400,
"Document PDF marker references an upload you do not own",
)
def _derive_title(content: str) -> str:
"""Derive a title from document content."""
import re
if not isinstance(content, str):
return "Untitled"
text = content.strip()
if not text:
return "Untitled"
# Markdown header
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
if md:
title = md.group(1).strip()
if len(title) > 50:
title = title[:48] + "…"
return title
# HTML heading
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
if html:
title = html.group(1).strip()
if len(title) > 50:
title = title[:48] + "…"
return title
# First non-empty line (if short enough)
for line in text.split('\n'):
line = line.strip()
if line and 2 <= len(line) <= 60:
title = re.sub(r'[:#*`]+$', '', line).strip()
if title and len(title) > 50:
title = title[:48] + "…"
return title or "Untitled"
return "Untitled"
+13 -1795
View File
File diff suppressed because it is too large Load Diff
+145 -3
View File
@@ -34,7 +34,7 @@ from fastapi import Query, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List 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 from src.secret_storage import decrypt as _decrypt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -246,6 +246,8 @@ import re as _re_reply
# serves replies and summaries (any fenced final-output block). # serves replies and summaries (any fenced final-output block).
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I) _REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I) _REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
_SUMMARY_BULLET_RE = _re_reply.compile(r"^(?:[-*\u2022]\s+|\d+[.)]\s+)")
def _extract_reply(text: str) -> str: def _extract_reply(text: str) -> str:
@@ -272,9 +274,129 @@ def _extract_reply(text: str) -> str:
# Drop any stray/duplicate marker tokens, then strip think markup. # Drop any stray/duplicate marker tokens, then strip think markup.
t = _REPLY_OPEN_RE.sub("", t) t = _REPLY_OPEN_RE.sub("", t)
t = _REPLY_CLOSE_RE.sub("", t) t = _REPLY_CLOSE_RE.sub("", t)
t = _REPLY_ROLE_MARKER_RE.sub("", t)
return _strip_think(t).strip() return _strip_think(t).strip()
def _build_email_summary_messages(sender: str, subject: str, body_for_llm: str) -> list[dict[str, str]]:
return [
{
"role": "system",
"content": (
"You are an email summarizer. Format: 1-3 short bullet points "
"(use '- '). Cover: main point, action items, deadlines. If the "
"email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR "
"CONTENTS - pull invoice totals, deadlines, key clauses, concrete "
"numbers/dates from PDFs/docs into the bullets. Be terse.\n\n"
"OUTPUT FORMAT: Put ONLY the bullet points between these exact "
"markers, each on its own line:\n"
"<<<SUMMARY>>>\n"
"- ...\n"
"<<<END>>>\n"
"Any reasoning must come BEFORE <<<SUMMARY>>> (ideally inside "
"<think>...</think>). Only the text between the markers is kept."
),
},
{
"role": "user",
"content": (
f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}"
"\n\n---\n\nSummarize the email. Output the bullets between "
"<<<SUMMARY>>> and <<<END>>>."
),
},
]
async def _generate_email_summary(
url: str,
model: str,
sender: str,
subject: str,
body_for_llm: str,
*,
headers: dict | None = None,
max_tokens: int = 8192,
timeout: int = 180,
) -> str:
"""Generate an interactive email summary through the shared LLM adapter."""
from src.llm_core import llm_call_async
raw = await llm_call_async(
url=url,
model=model,
messages=_build_email_summary_messages(sender, subject, body_for_llm),
temperature=0.3,
max_tokens=max_tokens,
headers=headers,
timeout=timeout,
workload="foreground",
)
return _normalize_email_summary(raw)
async def _generate_scheduled_email_summary(
url: str,
model: str,
sender: str,
subject: str,
body_for_llm: str,
*,
headers: dict | None = None,
owner: str | None = None,
max_tokens: int = 8192,
timeout: int = 180,
) -> str:
"""Generate a scheduled summary through the background task candidate chain."""
from src.task_endpoint import task_llm_call_async
raw = await task_llm_call_async(
messages=_build_email_summary_messages(sender, subject, body_for_llm),
fallback_url=url,
fallback_model=model,
fallback_headers=headers,
owner=owner,
temperature=0.3,
max_tokens=max_tokens,
timeout=timeout,
)
return _normalize_email_summary(raw)
def _normalize_email_summary(raw) -> str:
"""Extract a stable cache/UI summary from provider output."""
raw_text = raw or ""
if _REPLY_OPEN_RE.search(raw_text):
summary = _extract_reply(raw_text)
if summary:
return summary
cleaned = _strip_think(raw_text).strip()
bullets = [
line.strip()
for line in cleaned.splitlines()
if _SUMMARY_BULLET_RE.match(line.strip())
]
if bullets:
return "\n".join(bullets)
return cleaned.strip()
EMAIL_SUMMARY_ERROR_CODE = "email_summary_unavailable"
EMAIL_SUMMARY_ERROR_MESSAGE = "Failed to summarize"
def _email_summary_failure_log_detail(exc: BaseException) -> str:
"""Return useful provider-failure metadata without echoing exception text."""
detail = f"type={type(exc).__name__}"
status = getattr(exc, "status_code", None)
if status is None:
status = getattr(getattr(exc, "response", None), "status_code", None)
if isinstance(status, int):
detail += f" status={status}"
return detail
def _apply_email_style_mechanics(text: str) -> str: def _apply_email_style_mechanics(text: str) -> str:
"""Enforce deterministic writing-style mechanics that models often miss.""" """Enforce deterministic writing-style mechanics that models often miss."""
if not text: if not text:
@@ -298,6 +420,15 @@ def _require_auth(request: Request) -> str:
unconfigured mode are only honoured if they're coming from unconfigured mode are only honoured if they're coming from
localhost; everyone else gets 401. 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) u = get_current_user(request)
if u: if u:
return u return u
@@ -1035,12 +1166,22 @@ def _coerce_imap_timeout_seconds(raw: str | None) -> int:
_IMAP_TIMEOUT_SECONDS = _coerce_imap_timeout_seconds(os.environ.get("ODYSSEUS_IMAP_TIMEOUT_SECONDS")) _IMAP_TIMEOUT_SECONDS = _coerce_imap_timeout_seconds(os.environ.get("ODYSSEUS_IMAP_TIMEOUT_SECONDS"))
def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int = _IMAP_TIMEOUT_SECONDS): def _open_imap_connection(
host: str,
port: int,
*,
starttls: bool,
timeout: int = _IMAP_TIMEOUT_SECONDS,
ssl_context=None,
):
"""Open an IMAP connection using the configured security mode.""" """Open an IMAP connection using the configured security mode."""
port = int(port or 993) port = int(port or 993)
if starttls: if starttls:
conn = imaplib.IMAP4(host, port, timeout=timeout) conn = imaplib.IMAP4(host, port, timeout=timeout)
try: try:
if ssl_context:
conn.starttls(ssl_context=ssl_context)
else:
conn.starttls() conn.starttls()
except Exception: except Exception:
# Don't leak the open plain socket if the STARTTLS upgrade is # Don't leak the open plain socket if the STARTTLS upgrade is
@@ -1051,7 +1192,8 @@ def _open_imap_connection(host: str, port: int, *, starttls: bool, timeout: int
pass pass
raise raise
elif port == 993: elif port == 993:
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout) kwargs = {"ssl_context": ssl_context} if ssl_context else {}
conn = imaplib.IMAP4_SSL(host, port, timeout=timeout, **kwargs)
else: else:
conn = imaplib.IMAP4(host, port, timeout=timeout) conn = imaplib.IMAP4(host, port, timeout=timeout)
try: try:
+387 -28
View File
@@ -40,6 +40,7 @@ from routes.email_helpers import (
_pre_retrieve_context, _pre_retrieve_context,
_attach_compose_uploads, _cleanup_compose_uploads, _q, _attach_compose_uploads, _cleanup_compose_uploads, _q,
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause, SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -100,6 +101,272 @@ def _owner_for_email_account(account_id: str | None) -> str:
return "" return ""
def _email_date_only(value: str | None):
value = (value or "").strip()
if not value:
return None
try:
return datetime.strptime(value[:10], "%Y-%m-%d").date()
except Exception:
return None
_AUTO_REPLY_KEYS = {
"email_auto_reply",
"email_auto_reply_start",
"email_auto_reply_end",
"email_auto_reply_subject",
"email_auto_reply_message",
"email_auto_reply_cooldown",
"email_auto_reply_scope",
"email_auto_reply_account_id",
"email_auto_reply_exclude_automated",
"email_auto_reply_pause_notifications",
"email_auto_reply_enabled_at",
}
def _effective_settings_for_email_account(settings: dict, account_id: str | None) -> dict:
"""Overlay per-account auto-reply settings onto global settings.
Other automation toggles remain global. This lets each mailbox have its own
away reply while preserving existing installs that only have global keys.
"""
effective = dict(settings or {})
key = str(account_id or "").strip()
by_account = effective.get("email_auto_reply_by_account") or {}
account_cfg = by_account.get(key) if key and isinstance(by_account, dict) else None
if isinstance(account_cfg, dict):
for k in _AUTO_REPLY_KEYS:
if k in account_cfg:
effective[k] = account_cfg[k]
return effective
def _away_reply_active(settings: dict, account_id: str | None) -> bool:
if not settings.get("email_auto_reply", False):
return False
scope = str(settings.get("email_auto_reply_scope") or "all").strip().lower()
if scope == "account":
selected = str(settings.get("email_auto_reply_account_id") or "").strip()
if selected and selected != str(account_id or ""):
return False
today = datetime.utcnow().date()
start = _email_date_only(settings.get("email_auto_reply_start"))
end = _email_date_only(settings.get("email_auto_reply_end"))
if start and today < start:
return False
if end and today > end:
return False
return True
def _message_after_away_enabled(settings: dict, msg) -> bool:
enabled_at = (settings.get("email_auto_reply_enabled_at") or "").strip()
if not enabled_at:
# Existing installs may already have the toggle on before this feature
# existed. Do not back-reply old mail until the user saves/toggles it.
return False
try:
enabled_dt = datetime.fromisoformat(enabled_at.replace("Z", "+00:00"))
except Exception:
return False
try:
msg_dt = email.utils.parsedate_to_datetime(msg.get("Date", ""))
except Exception:
return False
try:
if enabled_dt.tzinfo and not msg_dt.tzinfo:
msg_dt = msg_dt.replace(tzinfo=enabled_dt.tzinfo)
elif msg_dt.tzinfo and not enabled_dt.tzinfo:
enabled_dt = enabled_dt.replace(tzinfo=msg_dt.tzinfo)
except Exception:
pass
return msg_dt >= enabled_dt
def _away_reply_period_key(settings: dict) -> str:
start = (settings.get("email_auto_reply_start") or "").strip()
end = (settings.get("email_auto_reply_end") or "").strip()
return f"{start or '*'}..{end or '*'}"
def _away_reply_cooldown_seconds(settings: dict) -> int | None:
raw = str(settings.get("email_auto_reply_cooldown") or "period").strip().lower()
if raw == "1d":
return 24 * 60 * 60
if raw == "3d":
return 3 * 24 * 60 * 60
if raw == "7d":
return 7 * 24 * 60 * 60
return None
def _ensure_away_reply_table():
import sqlite3 as _sql3
conn = _sql3.connect(SCHEDULED_DB)
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS email_away_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT DEFAULT '',
account_id TEXT DEFAULT '',
message_id TEXT DEFAULT '',
sender_addr TEXT DEFAULT '',
subject TEXT DEFAULT '',
period_key TEXT DEFAULT '',
sent_at TEXT DEFAULT ''
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_msg ON email_away_replies(owner, account_id, message_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_sender ON email_away_replies(owner, account_id, sender_addr, sent_at)")
conn.commit()
finally:
conn.close()
def _sender_is_automated(msg, sender_addr: str) -> bool:
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
if auto_submitted and auto_submitted != "no":
return True
precedence = (msg.get("Precedence") or "").strip().lower()
if precedence in {"bulk", "junk", "list"}:
return True
if msg.get("List-Id") or msg.get("List-Unsubscribe"):
return True
local = (sender_addr or "").split("@", 1)[0].lower()
return local in {
"no-reply", "noreply", "do-not-reply", "donotreply",
"notification", "notifications", "automated", "mailer-daemon",
"postmaster",
}
def _away_reply_already_sent(settings: dict, account_owner: str, account_id: str | None,
message_id: str, sender_addr: str) -> bool:
import sqlite3 as _sql3
_ensure_away_reply_table()
owner = account_owner or ""
aid = account_id or ""
sender = (sender_addr or "").strip().lower()
conn = _sql3.connect(SCHEDULED_DB)
try:
row = conn.execute(
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND message_id=? LIMIT 1",
(owner, aid, message_id),
).fetchone()
if row:
return True
cooldown = _away_reply_cooldown_seconds(settings)
if cooldown is None:
period_key = _away_reply_period_key(settings)
row = conn.execute(
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? AND period_key=? LIMIT 1",
(owner, aid, sender, period_key),
).fetchone()
return bool(row)
since = datetime.utcnow().timestamp() - cooldown
rows = conn.execute(
"SELECT sent_at FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? ORDER BY sent_at DESC LIMIT 5",
(owner, aid, sender),
).fetchall()
for (sent_at,) in rows:
try:
if datetime.fromisoformat(sent_at).timestamp() >= since:
return True
except Exception:
continue
return False
finally:
conn.close()
def _record_away_reply(settings: dict, account_owner: str, account_id: str | None,
message_id: str, sender_addr: str, subject: str):
import sqlite3 as _sql3
_ensure_away_reply_table()
conn = _sql3.connect(SCHEDULED_DB)
try:
conn.execute(
"""
INSERT INTO email_away_replies
(owner, account_id, message_id, sender_addr, subject, period_key, sent_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
account_owner or "",
account_id or "",
message_id,
(sender_addr or "").strip().lower(),
subject or "",
_away_reply_period_key(settings),
datetime.utcnow().isoformat(),
),
)
conn.commit()
finally:
conn.close()
def _send_away_reply(settings: dict, account_owner: str, account_id: str | None,
msg, message_id: str, sender: str, subject: str):
sender_name, sender_addr = email.utils.parseaddr(sender or "")
sender_addr = (sender_addr or "").strip()
if not sender_addr:
return False, "missing sender"
cfg = _get_email_config(account_id, owner=account_owner)
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
if not from_addr:
return False, "missing from address"
if sender_addr.lower() == from_addr.lower():
return False, "self mail"
if settings.get("email_auto_reply_exclude_automated", True) and _sender_is_automated(msg, sender_addr):
return False, "automated sender"
if _away_reply_already_sent(settings, account_owner, account_id, message_id, sender_addr):
return False, "already sent"
body = (settings.get("email_auto_reply_message") or "").strip()
if not body:
body = "Thanks for your email. I'm away and may be slower to reply."
subject_template = (settings.get("email_auto_reply_subject") or "(Away) {subject}").strip()
if subject_template:
original_subject = subject or ""
reply_subject = (
subject_template
.replace("{subject}", original_subject)
.replace("{original_subject}", original_subject)
).strip() or "Re:"
else:
reply_subject = subject or ""
if not reply_subject.lower().lstrip().startswith("re:"):
reply_subject = f"Re: {reply_subject}" if reply_subject else "Re:"
outer = MIMEMultipart("alternative")
display = cfg.get("display_name") or ""
outer["From"] = email.utils.formataddr((display, from_addr)) if display else from_addr
outer["To"] = email.utils.formataddr((sender_name, sender_addr)) if sender_name else sender_addr
outer["Subject"] = reply_subject
outer["Date"] = email.utils.formatdate(localtime=False)
outer["Message-ID"] = email.utils.make_msgid()
outer["Auto-Submitted"] = "auto-replied"
outer["X-Auto-Response-Suppress"] = "All"
if message_id:
outer["In-Reply-To"] = message_id
refs = (msg.get("References") or "").strip()
outer["References"] = f"{refs} {message_id}".strip()
outer.attach(MIMEText(body, "plain", "utf-8"))
_send_smtp_message(cfg, from_addr, [sender_addr], outer.as_string())
_record_away_reply(settings, account_owner, account_id, message_id, sender_addr, subject)
return True, sender_addr
# ── Routes ── # ── Routes ──
async def _emit_progress(progress_cb, message: str): async def _emit_progress(progress_cb, message: str):
@@ -125,9 +392,10 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
settings = _load_settings() settings = _load_settings()
prev = {k: settings.get(k, False) for k in prev = {k: settings.get(k, False) for k in
("email_auto_summarize", "email_auto_reply", "email_auto_tag", ("email_auto_summarize", "email_auto_reply", "email_auto_tag",
"email_auto_spam", "email_auto_calendar")} "email_auto_spam", "email_auto_calendar", "_email_auto_reply_draft_only")}
settings["email_auto_summarize"] = bool(do_summary) settings["email_auto_summarize"] = bool(do_summary)
settings["email_auto_reply"] = bool(do_reply) settings["email_auto_reply"] = bool(do_reply)
settings["_email_auto_reply_draft_only"] = bool(do_reply)
settings["email_auto_tag"] = bool(do_tag) settings["email_auto_tag"] = bool(do_tag)
settings["email_auto_spam"] = bool(do_spam) settings["email_auto_spam"] = bool(do_spam)
settings["email_auto_calendar"] = bool(do_calendar) settings["email_auto_calendar"] = bool(do_calendar)
@@ -142,6 +410,9 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
finally: finally:
s2 = _load_settings() s2 = _load_settings()
for k, v in prev.items(): for k, v in prev.items():
if v is None and k.startswith("_"):
s2.pop(k, None)
else:
s2[k] = v s2[k] = v
_save_settings(s2) _save_settings(s2)
@@ -176,7 +447,7 @@ def _latest_inbox_fallback_uids(conn, reconnect):
return [], reconnect() return [], reconnect()
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str: async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None, away_only: bool = False) -> str:
"""Single pass of the auto-summarize/reply scan. """Single pass of the auto-summarize/reply scan.
When account_id is None, iterates over every enabled account in When account_id is None, iterates over every enabled account in
@@ -208,6 +479,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
account_id=(ids[0] if ids else None), account_id=(ids[0] if ids else None),
max_process=max_process, max_process=max_process,
progress_cb=progress_cb, progress_cb=progress_cb,
away_only=away_only,
) )
outs = [] outs = []
for idx, aid in enumerate(ids, start=1): for idx, aid in enumerate(ids, start=1):
@@ -218,6 +490,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
account_id=aid, account_id=aid,
max_process=max_process, max_process=max_process,
progress_cb=progress_cb, progress_cb=progress_cb,
away_only=away_only,
) )
outs.append(f"[{names.get(aid, aid[:8])}] {result}") outs.append(f"[{names.get(aid, aid[:8])}] {result}")
except Exception as e: except Exception as e:
@@ -229,23 +502,32 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
account_id=account_id, account_id=account_id,
max_process=max_process, max_process=max_process,
progress_cb=progress_cb, progress_cb=progress_cb,
away_only=away_only,
) )
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str: async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None, away_only: bool = False) -> str:
"""Single pass of the auto-summarize/reply scan for ONE account. """Single pass of the auto-summarize/reply scan for ONE account.
Reads current settings flags.""" Reads current settings flags."""
import asyncio import asyncio
import sqlite3 as _sql3 import sqlite3 as _sql3
from src.llm_core import _uses_max_completion_tokens from src.llm_core import _uses_max_completion_tokens
settings = _load_settings() settings = _effective_settings_for_email_account(_load_settings(), account_id)
auto_sum = settings.get("email_auto_summarize", False) auto_sum = settings.get("email_auto_summarize", False)
auto_reply = settings.get("email_auto_reply", False) auto_reply = settings.get("email_auto_reply", False)
auto_reply_draft = bool(auto_reply and settings.get("_email_auto_reply_draft_only", False))
auto_reply_away = bool(auto_reply and not auto_reply_draft and _away_reply_active(settings, account_id))
auto_tag = settings.get("email_auto_tag", False) auto_tag = settings.get("email_auto_tag", False)
auto_spam = settings.get("email_auto_spam", False) auto_spam = settings.get("email_auto_spam", False)
auto_cal = settings.get("email_auto_calendar", False) auto_cal = settings.get("email_auto_calendar", False)
if not auto_sum and not auto_reply and not auto_tag and not auto_spam and not auto_cal: if away_only:
auto_sum = False
auto_reply_draft = False
auto_tag = False
auto_spam = False
auto_cal = False
if not auto_sum and not auto_reply_draft and not auto_reply_away and not auto_tag and not auto_spam and not auto_cal:
return "Nothing to do" return "Nothing to do"
# Owner of the account being processed. All calendar + mailbox reads/writes # Owner of the account being processed. All calendar + mailbox reads/writes
@@ -304,11 +586,11 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_c = _sql3.connect(SCHEDULED_DB) _c = _sql3.connect(SCHEDULED_DB)
_cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner) _cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner)
_sum_existing = {r[0] for r in _c.execute( _sum_existing = set() if away_only else {r[0] for r in _c.execute(
f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}", f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}",
_cache_owner_params, _cache_owner_params,
).fetchall()} ).fetchall()}
_reply_existing = {r[0] for r in _c.execute( _reply_existing = set() if away_only else {r[0] for r in _c.execute(
f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}", f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}",
_cache_owner_params, _cache_owner_params,
).fetchall()} ).fetchall()}
@@ -325,7 +607,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
).fetchall()} ).fetchall()}
else: else:
_tag_existing = set() _tag_existing = set()
_cal_existing = {r[0] for r in _c.execute( _cal_existing = set() if away_only else {r[0] for r in _c.execute(
f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}", f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}",
_cache_owner_params, _cache_owner_params,
).fetchall()} if auto_cal else set() ).fetchall()} if auto_cal else set()
@@ -351,11 +633,20 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if auto_spam and not spam_folder: if auto_spam and not spam_folder:
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move") logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
needs_llm = bool(auto_sum or auto_reply_draft or auto_tag or auto_spam or auto_cal)
if needs_llm:
task_candidates = resolve_task_candidates(owner=account_owner) task_candidates = resolve_task_candidates(owner=account_owner)
if not task_candidates: if not task_candidates:
return "No model configured" return "No model configured"
url, model, headers = task_candidates[0] url, model, headers = task_candidates[0]
else:
url, model, headers = None, "", None
by_account_styles = settings.get("email_writing_styles_by_account") or {}
writing_style = ""
if account_id and isinstance(by_account_styles, dict):
writing_style = str(by_account_styles.get(str(account_id)) or "")
if not writing_style:
writing_style = settings.get("email_writing_style", "") writing_style = settings.get("email_writing_style", "")
processed = 0 processed = 0
already_cached = 0 already_cached = 0
@@ -363,15 +654,19 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
no_msgid = 0 no_msgid = 0
examined = 0 examined = 0
_summaries_created = 0 _summaries_created = 0
_summary_failed = 0
_events_created = 0 _events_created = 0
_replies_drafted = 0 _replies_drafted = 0
_reply_failed = 0 _reply_failed = 0
_away_replies_sent = 0
_away_replies_skipped = 0
_away_replies_failed = 0
_detail_lines = [] _detail_lines = []
_current_folder = "INBOX" _current_folder = "INBOX"
# Calendar extraction is sequential and each row can involve a model # Calendar extraction is sequential and each row can involve a model
# call plus a calendar write. Keep the scheduled calendar-only pass # call plus a calendar write. Keep the scheduled calendar-only pass
# below the 5-minute action budget instead of timing out mid-run. # below the 5-minute action budget instead of timing out mid-run.
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply and not auto_tag and not auto_spam) else 5 _default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply_draft and not auto_reply_away and not auto_tag and not auto_spam) else 5
try: try:
_max_process = max(1, int(max_process)) if max_process is not None else _default_max_process _max_process = max(1, int(max_process)) if max_process is not None else _default_max_process
except Exception: except Exception:
@@ -402,10 +697,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
seed = f"{_folder}|{uid_str}|{msg.get('From','')}|{msg.get('Date','')}|{msg.get('Subject','')}" seed = f"{_folder}|{uid_str}|{msg.get('From','')}|{msg.get('Date','')}|{msg.get('Subject','')}"
message_id = f"<synth-{_hl.sha256(seed.encode()).hexdigest()[:16]}@local>" message_id = f"<synth-{_hl.sha256(seed.encode()).hexdigest()[:16]}@local>"
no_msgid += 1 no_msgid += 1
need_sum = auto_sum and message_id not in _sum_existing
need_reply = auto_reply and message_id not in _reply_existing
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
# Only check urgency on INBOX (received mail), not Sent # Only check urgency on INBOX (received mail), not Sent
# Skip messages that are themselves urgency alerts, or that # Skip messages that are themselves urgency alerts, or that
# we sent to ourselves — otherwise the alert loop re-flags # we sent to ourselves — otherwise the alert loop re-flags
@@ -422,17 +713,45 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
except Exception: except Exception:
_from_addr_only = "" _from_addr_only = ""
_is_self_mail = bool(_self_self_addr) and _from_addr_only.lower() == _self_self_addr _is_self_mail = bool(_self_self_addr) and _from_addr_only.lower() == _self_self_addr
need_sum = auto_sum and message_id not in _sum_existing
need_reply = auto_reply_draft and message_id not in _reply_existing
need_away_reply = bool(
auto_reply_away
and _folder.upper() == "INBOX"
and not _is_self_mail
and (away_only or _message_after_away_enabled(settings, msg))
and not _away_reply_already_sent(settings, account_owner, account_id, message_id, _from_addr_only)
)
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
need_urgent = (auto_urgent and message_id not in _urgent_existing need_urgent = (auto_urgent and message_id not in _urgent_existing
and not _folder.lower().startswith("sent") and not _folder.lower().startswith("sent")
and "sent" not in _folder.lower() and "sent" not in _folder.lower()
and not _is_alert_echo and not _is_alert_echo
and not _is_self_mail) and not _is_self_mail)
if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent: if not need_sum and not need_reply and not need_away_reply and not need_class and not need_cal and not need_urgent:
already_cached += 1 already_cached += 1
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached") await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
continue continue
subject = _decode_header(msg.get("Subject", "")) subject = _decode_header(msg.get("Subject", ""))
sender = _decode_header(msg.get("From", "")) sender = _decode_header(msg.get("From", ""))
if need_away_reply:
try:
sent_away, away_detail = _send_away_reply(
settings, account_owner, account_id, msg, message_id, sender, subject
)
if sent_away:
_away_replies_sent += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"away reply · {_folder}#{_uid_text} · {subject or '(no subject)'} — {away_detail}")
else:
_away_replies_skipped += 1
logger.info(f"Away reply skipped for uid={uid}: {away_detail}")
except Exception as e:
_away_replies_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"away reply failed · {_folder}#{_uid_text} · {subject or '(no subject)'}")
logger.warning(f"Away reply {uid} failed: {e}")
body = _extract_text(msg) body = _extract_text(msg)
# Pull text out of any PDFs / text attachments and append to # Pull text out of any PDFs / text attachments and append to
# the body so summaries / replies can actually reason about # the body so summaries / replies can actually reason about
@@ -454,7 +773,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
elif need_reply: elif need_reply:
if not body: if not body:
body = subject body = subject
elif (not body or len(body) < 100) and not att_text: elif not need_away_reply and (not body or len(body) < 100) and not att_text:
too_short += 1 too_short += 1
continue continue
# Augmented body sent to the LLM: original body + attachment text. # Augmented body sent to the LLM: original body + attachment text.
@@ -468,16 +787,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
if need_sum: if need_sum:
try: try:
summary = await task_llm_call_async( summary = await _generate_scheduled_email_summary(
messages=[ url=url,
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."}, model=model,
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."}, sender=sender,
], subject=subject,
fallback_url=url, fallback_model=model, fallback_headers=headers, body_for_llm=body_for_llm,
headers=req_headers,
owner=account_owner or None, owner=account_owner or None,
temperature=0.3, max_tokens=16384, timeout=240, max_tokens=16384,
timeout=240,
) )
summary = _extract_reply((summary or "").strip())
if summary: if summary:
_c = _sql3.connect(SCHEDULED_DB) _c = _sql3.connect(SCHEDULED_DB)
_c.execute(""" _c.execute("""
@@ -491,10 +811,19 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
_summaries_created += 1 _summaries_created += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid) _uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}") _detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
else:
_summary_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary empty · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
except Exception as e: except Exception as e:
_summary_failed += 1
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid) _uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}") _detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
logger.warning(f"Auto-summary {uid} failed: {e}") logger.warning(
"Auto-summary uid=%s failed %s",
_uid_text,
_email_summary_failure_log_detail(e),
)
if need_reply: if need_reply:
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}") await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
@@ -993,7 +1322,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
# Build a clear status message # Build a clear status message
ops = [] ops = []
if auto_sum: ops.append("summary") if auto_sum: ops.append("summary")
if auto_reply: ops.append("reply") if auto_reply_draft: ops.append("reply")
if auto_reply_away: ops.append("away")
if auto_tag: ops.append("tag") if auto_tag: ops.append("tag")
if auto_spam: ops.append("spam") if auto_spam: ops.append("spam")
ops_label = "/".join(ops) or "none" ops_label = "/".join(ops) or "none"
@@ -1002,10 +1332,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
parts.append(f"processed {processed} new") parts.append(f"processed {processed} new")
if auto_sum: if auto_sum:
parts.append(f"summarized {_summaries_created}") parts.append(f"summarized {_summaries_created}")
if auto_reply: if _summary_failed:
parts.append(f"{_summary_failed} summary failed")
if auto_reply_draft:
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies")) parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
if _reply_failed: if _reply_failed:
parts.append(f"{_reply_failed} reply failed") parts.append(f"{_reply_failed} reply failed")
if auto_reply_away:
parts.append(f"sent {_away_replies_sent} away repl" + ("y" if _away_replies_sent == 1 else "ies"))
if _away_replies_failed:
parts.append(f"{_away_replies_failed} away failed")
if already_cached: if already_cached:
parts.append(f"{already_cached} already cached") parts.append(f"{already_cached} already cached")
if too_short: if too_short:
@@ -1032,12 +1368,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
async def _auto_summarize_poller(): async def _auto_summarize_poller():
"""Background loop kept for backward compatibility — calls _auto_summarize_pass every 60s. """Background loop kept for backward compatibility — calls _auto_summarize_pass periodically.
Newer setups should use scheduled tasks instead (summarize_emails, draft_email_replies).""" Newer setups should use scheduled tasks instead (summarize_emails, draft_email_replies)."""
import asyncio as _asyncio import asyncio as _asyncio
while True: while True:
try: try:
await _asyncio.sleep(1800) settings = _load_settings()
await _asyncio.sleep(60 if settings.get("email_auto_reply", False) else 1800)
await _auto_summarize_pass() await _auto_summarize_pass()
except Exception as e: except Exception as e:
logger.error(f"Auto-summarize poller crash: {e}") logger.error(f"Auto-summarize poller crash: {e}")
@@ -1068,6 +1405,28 @@ def _scheduled_poll_once() -> dict:
for r in rows: for r in rows:
sid = r[0] sid = r[0]
try: try:
# Atomically claim this row before doing any work. Two
# pollers can race here (the in-process asyncio task and an
# externally cron-driven `odysseus-mail poll-scheduled`, or
# an admin running the CLI manually alongside the in-process
# one despite the ODYSSEUS_INPROCESS_POLLERS=0 guidance) -
# both can SELECT the same 'pending' row before either has
# updated its status. The UPDATE...WHERE status='pending' is
# the atomicity boundary: only the poller whose UPDATE
# actually changes a row (rowcount == 1) proceeds to send;
# a loser sees rowcount == 0 and skips it instead of sending
# a duplicate.
claim_conn = sqlite3.connect(SCHEDULED_DB)
claim_cur = claim_conn.execute(
"UPDATE scheduled_emails SET status='sending' WHERE id=? AND status='pending'",
(sid,),
)
claim_conn.commit()
claimed = claim_cur.rowcount == 1
claim_conn.close()
if not claimed:
continue
attachments = json.loads(r[8] or "[]") attachments = json.loads(r[8] or "[]")
row_account_id = r[9] if len(r) > 9 else None row_account_id = r[9] if len(r) > 9 else None
odysseus_kind = r[10] if len(r) > 10 else "scheduled" odysseus_kind = r[10] if len(r) > 10 else "scheduled"
+1082 -153
View File
File diff suppressed because it is too large Load Diff
+466 -40
View File
@@ -1,18 +1,28 @@
"""Gallery routes — browsable library for photos and AI-generated images.""" """Gallery routes — browsable library for photos and AI-generated images."""
import os import os
import base64
import hashlib import hashlib
import io
import logging import logging
import re import re
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional 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 SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession 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 ( from src.upload_limits import (
read_upload_limited, read_upload_limited,
GALLERY_UPLOAD_MAX_BYTES, GALLERY_UPLOAD_MAX_BYTES,
@@ -27,6 +37,188 @@ from routes.gallery.gallery_helpers import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_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")
if "," in image_b64 and image_b64.split(",", 1)[0].startswith("data:"):
image_b64 = image_b64.split(",", 1)[1]
try:
from PIL import Image
raw = base64.b64decode(image_b64)
return Image.open(io.BytesIO(raw)).convert(mode)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(400, "Invalid image") from exc
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
buf = io.BytesIO()
img.save(buf, format=fmt)
return base64.b64encode(buf.getvalue()).decode("ascii")
def _load_sam_backend():
model_id = os.getenv("ODYSSEUS_SAM_MODEL", "facebook/sam-vit-base")
cached = _SAM_STATE.get(model_id)
if cached:
return cached
try:
import torch
from transformers import SamModel, SamProcessor
except Exception as exc:
raise HTTPException(
501,
"SAM mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
) from exc
device = "cpu"
try:
if torch.cuda.is_available():
device = "cuda"
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
device = "mps"
except Exception:
device = "cpu"
try:
processor = SamProcessor.from_pretrained(model_id)
model = SamModel.from_pretrained(model_id)
model.to(device)
model.eval()
except Exception as exc:
raise HTTPException(500, f"Failed to load SAM model {model_id}: {exc}") from exc
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
_SAM_STATE[model_id] = cached
return cached
def _load_grounding_backend():
model_id = os.getenv("ODYSSEUS_GROUNDING_MODEL", "google/owlvit-base-patch32")
cached = _GROUNDING_STATE.get(model_id)
if cached:
return cached
try:
import torch
from transformers import OwlViTForObjectDetection, OwlViTProcessor
except Exception as exc:
raise HTTPException(
501,
"Object mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
) from exc
device = "cpu"
try:
if torch.cuda.is_available():
device = "cuda"
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
device = "mps"
except Exception:
device = "cpu"
try:
processor = OwlViTProcessor.from_pretrained(model_id)
model = OwlViTForObjectDetection.from_pretrained(model_id)
model.to(device)
model.eval()
except Exception as exc:
raise HTTPException(500, f"Failed to load object mask model {model_id}: {exc}") from exc
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
_GROUNDING_STATE[model_id] = cached
return cached
def _model_input_to_device(value, device: str, torch):
if not hasattr(value, "to"):
return value
if (
device == "mps"
and hasattr(torch, "float64")
and getattr(value, "dtype", None) == torch.float64
):
return value.to(device=device, dtype=torch.float32)
return value.to(device)
def _model_inputs_to_device(inputs, device: str, torch) -> Dict[str, Any]:
return {
key: _model_input_to_device(value, device, torch)
for key, value in inputs.items()
}
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
query = (text or "").strip()
if not query:
raise HTTPException(400, "Missing object text")
backend = _load_grounding_backend()
torch = backend["torch"]
processor = backend["processor"]
model = backend["model"]
device = backend["device"]
labels = [query]
if not query.lower().startswith(("a ", "an ", "the ")):
labels.append(f"a photo of {query}")
try:
inputs = processor(text=[labels], images=image, return_tensors="pt")
model_inputs = _model_inputs_to_device(inputs, device, torch)
with torch.no_grad():
outputs = model(**model_inputs)
target_sizes = torch.tensor([[image.height, image.width]])
if hasattr(processor, "post_process_object_detection"):
results = processor.post_process_object_detection(
outputs=outputs,
target_sizes=target_sizes,
threshold=float(threshold),
)
elif hasattr(processor, "post_process_grounded_object_detection"):
results = processor.post_process_grounded_object_detection(
outputs=outputs,
target_sizes=target_sizes,
threshold=float(threshold),
text_labels=[labels],
)
else:
raise HTTPException(500, "Installed Transformers does not expose OWL-ViT object detection post-processing")
boxes = results[0].get("boxes")
scores = results[0].get("scores")
labels_idx = results[0].get("labels")
text_labels = results[0].get("text_labels") or results[0].get("labels_text")
if boxes is None or scores is None or len(boxes) == 0:
raise HTTPException(404, f"No visible object matched '{query}'")
idx = int(torch.argmax(scores).item())
box = [float(v) for v in boxes[idx].detach().cpu().tolist()]
label_idx = int(labels_idx[idx].detach().cpu().item()) if labels_idx is not None and len(labels_idx) else 0
label = labels[min(label_idx, len(labels) - 1)]
if text_labels and len(text_labels) > idx:
label = str(text_labels[idx])
return {
"box": box,
"score": float(scores[idx].detach().cpu().item()),
"label": label,
"model": backend["model_id"],
}
except HTTPException:
raise
except Exception as exc:
logger.exception("ground_text_to_box failed")
raise HTTPException(500, f"Object mask failed: {exc}") from exc
def _current_user_is_admin(request: Request, user: str | None) -> bool: def _current_user_is_admin(request: Request, user: str | None) -> bool:
if not user: if not user:
@@ -169,7 +361,10 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
def setup_gallery_routes() -> APIRouter: def setup_gallery_routes() -> APIRouter:
router = APIRouter(tags=["gallery"]) router = APIRouter(
tags=["gallery"],
dependencies=[Depends(require_chat_scope)],
)
# ---- POST /api/gallery/upload ---- # ---- POST /api/gallery/upload ----
@router.post("/api/gallery/upload") @router.post("/api/gallery/upload")
@@ -183,7 +378,7 @@ def setup_gallery_routes() -> APIRouter:
if not file or not hasattr(file, 'filename'): if not file or not hasattr(file, 'filename'):
raise HTTPException(400, "No file provided") raise HTTPException(400, "No file provided")
user = get_current_user(request) user = _gallery_owner(request)
album_id = form.get("album_id") or None album_id = form.get("album_id") or None
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery upload") content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery upload")
@@ -257,7 +452,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/{image_id}/replace") @router.post("/api/gallery/{image_id}/replace")
async def gallery_replace(request: Request, image_id: str): async def gallery_replace(request: Request, image_id: str):
"""Replace an existing gallery image file with a new one.""" """Replace an existing gallery image file with a new one."""
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -302,7 +497,7 @@ def setup_gallery_routes() -> APIRouter:
"""Rename a gallery photo. Stores the new name in the `prompt` """Rename a gallery photo. Stores the new name in the `prompt`
column (which serves as the user-facing label for uploaded column (which serves as the user-facing label for uploaded
photos that have no AI prompt).""" photos that have no AI prompt)."""
user = get_current_user(request) user = _gallery_owner(request)
data = await request.json() data = await request.json()
new_name = (data.get("name") or "").strip() new_name = (data.get("name") or "").strip()
if not new_name: if not new_name:
@@ -339,7 +534,7 @@ def setup_gallery_routes() -> APIRouter:
if angle not in (90, -90, 180, 270): if angle not in (90, -90, 180, 270):
raise HTTPException(400, "Angle must be 90, -90, 180, or 270") raise HTTPException(400, "Angle must be 90, -90, 180, or 270")
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -380,7 +575,10 @@ def setup_gallery_routes() -> APIRouter:
db.close() db.close()
# ---- POST /api/gallery/ai-upscale ---- # ---- 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): async def gallery_ai_upscale(request: Request):
"""AI upscale using img2img with the diffusion server.""" """AI upscale using img2img with the diffusion server."""
import base64, httpx import base64, httpx
@@ -424,7 +622,10 @@ def setup_gallery_routes() -> APIRouter:
return {"error": "Upscale request failed"} return {"error": "Upscale request failed"}
# ---- POST /api/gallery/style-transfer ---- # ---- 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): async def gallery_style_transfer(request: Request):
"""Style transfer using img2img with the diffusion server.""" """Style transfer using img2img with the diffusion server."""
import base64, httpx import base64, httpx
@@ -474,7 +675,7 @@ def setup_gallery_routes() -> APIRouter:
@router.get("/api/gallery/tags") @router.get("/api/gallery/tags")
async def gallery_tags(request: Request) -> Dict[str, Any]: async def gallery_tags(request: Request) -> Dict[str, Any]:
"""Return distinct tags across all active gallery images.""" """Return distinct tags across all active gallery images."""
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
q = db.query(GalleryImage.tags).filter( q = db.query(GalleryImage.tags).filter(
@@ -506,7 +707,7 @@ def setup_gallery_routes() -> APIRouter:
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
limit: int = Query(24, ge=1, le=100), limit: int = Query(24, ge=1, le=100),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
# Distinct tags for filter UI # Distinct tags for filter UI
@@ -634,7 +835,7 @@ def setup_gallery_routes() -> APIRouter:
@router.get("/api/gallery/albums") @router.get("/api/gallery/albums")
async def list_albums(request: Request): async def list_albums(request: Request):
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
q = db.query(GalleryAlbum) q = db.query(GalleryAlbum)
@@ -673,7 +874,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums") @router.post("/api/gallery/albums")
async def create_album(request: Request): async def create_album(request: Request):
import uuid import uuid
user = get_current_user(request) user = _gallery_owner(request)
data = await request.json() data = await request.json()
name = (data.get("name") or "").strip() name = (data.get("name") or "").strip()
if not name: if not name:
@@ -693,7 +894,7 @@ def setup_gallery_routes() -> APIRouter:
@router.get("/api/gallery/stats") @router.get("/api/gallery/stats")
async def gallery_stats(request: Request): async def gallery_stats(request: Request):
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
from sqlalchemy import func from sqlalchemy import func
@@ -717,13 +918,16 @@ def setup_gallery_routes() -> APIRouter:
finally: finally:
db.close() 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( async def ai_tag_batch(
request: Request, request: Request,
album_id: Optional[str] = Query(None), album_id: Optional[str] = Query(None),
limit: int = Query(200), limit: int = Query(200),
): ):
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
q = db.query(GalleryImage).filter( q = db.query(GalleryImage).filter(
@@ -742,7 +946,7 @@ def setup_gallery_routes() -> APIRouter:
# ---- GET /api/gallery/{image_id} ---- # ---- GET /api/gallery/{image_id} ----
@router.get("/api/gallery/{image_id}") @router.get("/api/gallery/{image_id}")
async def get_gallery_image(request: Request, image_id: str) -> Dict[str, Any]: async def get_gallery_image(request: Request, image_id: str) -> Dict[str, Any]:
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
row = ( row = (
@@ -763,7 +967,7 @@ def setup_gallery_routes() -> APIRouter:
# ---- PATCH /api/gallery/{image_id} ---- # ---- PATCH /api/gallery/{image_id} ----
@router.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]: 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() db = SessionLocal()
try: try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -815,7 +1019,7 @@ def setup_gallery_routes() -> APIRouter:
# of a flood of individual downloads). # of a flood of individual downloads).
@router.post("/api/gallery/download-zip") @router.post("/api/gallery/download-zip")
async def gallery_download_zip(request: Request): async def gallery_download_zip(request: Request):
user = get_current_user(request) user = _gallery_owner(request)
if not user: if not user:
raise HTTPException(401, "Not authenticated") raise HTTPException(401, "Not authenticated")
try: try:
@@ -870,7 +1074,7 @@ def setup_gallery_routes() -> APIRouter:
# AI-suggested values you never added. # AI-suggested values you never added.
@router.post("/api/gallery/clear-user-tags") @router.post("/api/gallery/clear-user-tags")
async def clear_gallery_user_tags(request: Request) -> Dict[str, Any]: async def clear_gallery_user_tags(request: Request) -> Dict[str, Any]:
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
q = db.query(GalleryImage).filter(GalleryImage.is_active == True) q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
@@ -895,7 +1099,7 @@ def setup_gallery_routes() -> APIRouter:
# "woman" have leaked into the gallery and you want them gone. # "woman" have leaked into the gallery and you want them gone.
@router.post("/api/gallery/clear-ai-tags") @router.post("/api/gallery/clear-ai-tags")
async def clear_gallery_ai_tags(request: Request, image_id: Optional[str] = Query(None)) -> Dict[str, Any]: 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() db = SessionLocal()
try: try:
q = db.query(GalleryImage).filter(GalleryImage.is_active == True) q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
@@ -922,7 +1126,7 @@ def setup_gallery_routes() -> APIRouter:
# Returns how many rows were touched + how many tags removed. # Returns how many rows were touched + how many tags removed.
@router.post("/api/gallery/dedupe-tags") @router.post("/api/gallery/dedupe-tags")
async def dedupe_gallery_tags(request: Request) -> Dict[str, Any]: async def dedupe_gallery_tags(request: Request) -> Dict[str, Any]:
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
q = db.query(GalleryImage).filter(GalleryImage.is_active == True) q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
@@ -958,7 +1162,7 @@ def setup_gallery_routes() -> APIRouter:
# ---- DELETE /api/gallery/{image_id} ---- # ---- DELETE /api/gallery/{image_id} ----
@router.delete("/api/gallery/{image_id}") @router.delete("/api/gallery/{image_id}")
async def delete_gallery_image(request: Request, image_id: str) -> Dict[str, str]: async def delete_gallery_image(request: Request, image_id: str) -> Dict[str, str]:
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first() img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
@@ -1077,7 +1281,10 @@ def setup_gallery_routes() -> APIRouter:
db.close() db.close()
# ---- POST /api/image/inpaint — proxy to diffusion server OR OpenAI ---- # ---- 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): async def inpaint_proxy(request: Request):
"""Forward inpaint request. If the selected endpoint is OpenAI, re-shape """Forward inpaint request. If the selected endpoint is OpenAI, re-shape
the request for /v1/images/edits (multipart, inverted mask). Otherwise the request for /v1/images/edits (multipart, inverted mask). Otherwise
@@ -1240,20 +1447,89 @@ def setup_gallery_routes() -> APIRouter:
except httpx.TimeoutException: except httpx.TimeoutException:
raise HTTPException(504, "OpenAI inpaint timed out (120s)") raise HTTPException(504, "OpenAI inpaint timed out (120s)")
# Self-hosted diffusion server path # Self-hosted diffusion server path. Newer Odysseus image
# wrappers expose the OpenAI-compatible /v1/images/edits
# multipart route even when they are local/self-hosted. Older
# diffusion_server.py exposes /v1/images/inpaint as JSON. Try the
# OpenAI-compatible local route first, then fall back.
try: try:
# Forward chosen_model so the diffusion server can route if it ever # Forward chosen_model so the diffusion server can route if it ever
# supports multiple models per process. Harmless if ignored. # supports multiple models per process. Harmless if ignored.
if chosen_model: if chosen_model:
body["model"] = chosen_model body["model"] = chosen_model
async with httpx.AsyncClient(timeout=120) as client: async with httpx.AsyncClient(timeout=240) as client:
try:
import base64, io
from PIL import Image
img_bytes = base64.b64decode(body["image"])
mask_bytes = base64.b64decode(body["mask"])
# Normalize both inputs to PNG bytes. Local MLX and
# Diffusers wrappers expect white mask pixels to mean
# "edit this region", which matches the editor's mask.
source_png = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
mask_png = Image.open(io.BytesIO(mask_bytes)).convert("L")
src_buf = io.BytesIO()
source_png.save(src_buf, format="PNG")
mask_buf = io.BytesIO()
mask_png.save(mask_buf, format="PNG")
files = {
"image": ("source.png", src_buf.getvalue(), "image/png"),
"mask": ("mask.png", mask_buf.getvalue(), "image/png"),
}
data = {
"model": chosen_model or body.get("model") or "",
"prompt": body.get("prompt", ""),
"size": f"{int(body.get('width') or source_png.width)}x{int(body.get('height') or source_png.height)}",
"n": "1",
}
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), data=data, files=files)
if r.status_code == 200:
result = r.json()
if isinstance(result, dict) and result.get("data"):
item = result["data"][0]
if item.get("b64_json"):
return {"image": item["b64_json"]}
if item.get("url"):
raw_b64 = await _fetch_result_image_b64(item["url"])
if raw_b64:
return {"image": raw_b64}
if isinstance(result, dict) and result.get("image"):
return {"image": result["image"]}
raise HTTPException(502, "Image edit endpoint returned no image")
if r.status_code not in (404, 405):
logger.warning("inpaint_proxy self-hosted edits: status %s", r.status_code)
detail = "Image edit request failed"
try:
err = r.json()
detail = err.get("detail") or err.get("error") or detail
except Exception:
pass
# A plain SD/SDXL checkpoint often exposes
# generation only at /images/edits.
# That does not mean the endpoint cannot inpaint:
# Odysseus diffusion_server.py has a dedicated
# /images/inpaint route that can derive/fallback to
# inpaint, img2img crop+composite, or txt2img
# crop+composite. Fall through to that route instead
# of surfacing "does not support image edits".
if r.status_code == 400 and "does not support image edits" in str(detail).lower():
logger.info("inpaint_proxy self-hosted edits unsupported; falling back to /images/inpaint")
else:
raise HTTPException(r.status_code, detail)
except HTTPException:
raise
except Exception:
logger.exception("inpaint_proxy: failed to prepare self-hosted edit request")
raise HTTPException(400, "Failed to prepare inpaint request")
r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body) r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body)
if r.status_code != 200: if r.status_code != 200:
logger.error("inpaint_proxy diffusion: status %s", r.status_code) logger.error("inpaint_proxy diffusion: status %s", r.status_code)
raise HTTPException(r.status_code, "Inpaint request failed") raise HTTPException(r.status_code, "Inpaint request failed")
return r.json() return r.json()
except httpx.TimeoutException: except httpx.TimeoutException:
raise HTTPException(504, "Inpaint request timed out (120s)") raise HTTPException(504, "Inpaint request timed out (240s)")
except HTTPException: except HTTPException:
raise raise
except Exception: except Exception:
@@ -1266,7 +1542,10 @@ def setup_gallery_routes() -> APIRouter:
# scratch using the prompt", ignoring the source. Real img2img sends # scratch using the prompt", ignoring the source. Real img2img sends
# the image alongside a `strength` (denoising strength) and the model # the image alongside a `strength` (denoising strength) and the model
# mixes that fraction of new noise into the existing pixels. # 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): async def harmonize_image(request: Request):
"""Harmonize = img2img. The model preserves (1 - strength) of the """Harmonize = img2img. The model preserves (1 - strength) of the
original and regenerates `strength` fraction. With strength ~0.4 original and regenerates `strength` fraction. With strength ~0.4
@@ -1466,7 +1745,10 @@ def setup_gallery_routes() -> APIRouter:
"/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.") "/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.")
# ---- POST /api/image/sharpen ---- # ---- 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): async def sharpen_image(request: Request):
"""Apply unsharp-mask sharpening to an image.""" """Apply unsharp-mask sharpening to an image."""
require_privilege(request, "can_generate_images") require_privilege(request, "can_generate_images")
@@ -1491,7 +1773,10 @@ def setup_gallery_routes() -> APIRouter:
# AI denoise via Real-ESRGAN with the realesr-general-x4v3 weights at # AI denoise via Real-ESRGAN with the realesr-general-x4v3 weights at
# outscale=1 + denoise_strength. Falls back to a "package missing" # outscale=1 + denoise_strength. Falls back to a "package missing"
# error so the client can prompt the user to install via Cookbook. # 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): async def denoise_image(request: Request):
require_privilege(request, "can_generate_images") require_privilege(request, "can_generate_images")
body = await request.json() body = await request.json()
@@ -1542,7 +1827,10 @@ def setup_gallery_routes() -> APIRouter:
# ---- POST /api/image/upscale-local ---- # ---- POST /api/image/upscale-local ----
# Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion # Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion
# server required. Used by the editor's AI Upscale button. # 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): async def upscale_image_local(request: Request):
require_privilege(request, "can_generate_images") require_privilege(request, "can_generate_images")
body = await request.json() body = await request.json()
@@ -1588,7 +1876,139 @@ def setup_gallery_routes() -> APIRouter:
return {"error": "AI upscale failed"} return {"error": "AI upscale failed"}
# ---- POST /api/image/remove-bg ---- # ---- POST /api/image/remove-bg ----
@router.post("/api/image/remove-bg") @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.
This endpoint intentionally does not inspect edit prompts. It only
turns explicit visual selection hints into a binary mask that the
editor can reuse for wand/layer-mask/inpaint workflows.
"""
require_privilege(request, "can_generate_images")
body = await request.json()
image = _b64_to_pil_image(body.get("image") or "", mode="RGB")
points = body.get("points") or []
box = body.get("box")
text = (body.get("text") or body.get("query") or "").strip()
grounded = None
if not points and not box and text:
grounded = _ground_text_to_box(image, text)
box = grounded["box"]
if not points and not box:
raise HTTPException(400, "Provide at least one point, box, or object text")
backend = _load_sam_backend()
torch = backend["torch"]
processor = backend["processor"]
model = backend["model"]
device = backend["device"]
kwargs: Dict[str, Any] = {"return_tensors": "pt"}
input_points = []
if points:
input_labels = []
for p in points:
try:
input_points.append([float(p["x"]), float(p["y"])])
input_labels.append(int(p.get("label", 1)))
except Exception as exc:
raise HTTPException(400, "Invalid point format") from exc
kwargs["input_points"] = [input_points]
kwargs["input_labels"] = [input_labels]
if box:
if not isinstance(box, list) or len(box) != 4:
raise HTTPException(400, "Box must be [x1, y1, x2, y2]")
try:
kwargs["input_boxes"] = [[[float(v) for v in box]]]
except Exception as exc:
raise HTTPException(400, "Invalid box format") from exc
try:
inputs = processor(image, **kwargs)
model_inputs = _model_inputs_to_device(inputs, device, torch)
with torch.no_grad():
outputs = model(**model_inputs)
masks = processor.image_processor.post_process_masks(
outputs.pred_masks.detach().cpu(),
inputs["original_sizes"].detach().cpu(),
inputs["reshaped_input_sizes"].detach().cpu(),
)
mask_tensor = masks[0]
while getattr(mask_tensor, "ndim", 0) > 3:
mask_tensor = mask_tensor[0]
if getattr(mask_tensor, "ndim", 0) == 3:
scores = outputs.iou_scores.detach().cpu()[0]
while getattr(scores, "ndim", 0) > 1:
scores = scores[0]
# SAM commonly returns multiple candidates for a click. The
# highest-IoU candidate can be the entire image, which is
# useless as an editor selection. Prefer a candidate that
# contains the clicked point while keeping area reasonable.
point_xy = None
if input_points:
try:
point_xy = (
int(round(float(input_points[0][0]))),
int(round(float(input_points[0][1]))),
)
except Exception:
point_xy = None
best_idx = 0
best_rank = None
total_px = max(1, int(mask_tensor.shape[-1]) * int(mask_tensor.shape[-2]))
for i in range(int(mask_tensor.shape[0])):
candidate = mask_tensor[i]
area_ratio = float(candidate.sum().item()) / float(total_px)
if area_ratio >= 0.985:
continue
contains_click = True
if point_xy:
px = max(0, min(int(candidate.shape[-1]) - 1, point_xy[0]))
py = max(0, min(int(candidate.shape[-2]) - 1, point_xy[1]))
contains_click = bool(candidate[py, px].item())
if not contains_click:
continue
score = float(scores[min(i, len(scores) - 1)].item()) if len(scores) else 0.0
# Strongly penalize broad masks; a click-selection should
# usually be local unless the user gives a box.
rank = score - (area_ratio * 0.35)
if best_rank is None or rank > best_rank:
best_rank = rank
best_idx = i
if best_rank is None and len(scores):
best_idx = int(torch.argmax(scores).item())
mask_tensor = mask_tensor[min(best_idx, mask_tensor.shape[0] - 1)]
mask_array = (mask_tensor.numpy() > 0).astype("uint8") * 255
from PIL import Image
mask_img = Image.fromarray(mask_array, mode="L")
if mask_img.size != image.size:
mask_img = mask_img.resize(image.size, Image.NEAREST)
bbox = mask_img.getbbox()
result = {
"mask": _pil_image_to_b64(mask_img),
"bbox": list(bbox) if bbox else None,
"model": backend["model_id"],
"device": device,
}
if grounded:
result["grounding"] = grounded
return result
except HTTPException:
raise
except Exception as exc:
logger.exception("smart_mask failed")
raise HTTPException(500, f"SAM mask failed: {exc}") from exc
@router.post(
"/api/image/remove-bg",
dependencies=[Depends(require_non_bearer_request)],
)
async def remove_background(request: Request): async def remove_background(request: Request):
"""Remove background from an image. If the client passes a `hint_mask` """Remove background from an image. If the client passes a `hint_mask`
(white-where-the-user-wants-the-subject PNG, same dims as the (white-where-the-user-wants-the-subject PNG, same dims as the
@@ -1681,7 +2101,10 @@ def setup_gallery_routes() -> APIRouter:
return {"image": base64.b64encode(buf.getvalue()).decode()} return {"image": base64.b64encode(buf.getvalue()).decode()}
# ---- POST /api/image/enhance-face ---- # ---- 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): async def enhance_face(request: Request):
"""Face/portrait enhancement. Uses GFPGAN if available, falls back to PIL.""" """Face/portrait enhancement. Uses GFPGAN if available, falls back to PIL."""
require_privilege(request, "can_generate_images") require_privilege(request, "can_generate_images")
@@ -1767,7 +2190,7 @@ def setup_gallery_routes() -> APIRouter:
@router.put("/api/gallery/albums/{album_id}") @router.put("/api/gallery/albums/{album_id}")
async def update_album(request: Request, album_id: str): async def update_album(request: Request, album_id: str):
user = get_current_user(request) user = _gallery_owner(request)
data = await request.json() data = await request.json()
db = SessionLocal() db = SessionLocal()
try: try:
@@ -1788,7 +2211,7 @@ def setup_gallery_routes() -> APIRouter:
@router.delete("/api/gallery/albums/{album_id}") @router.delete("/api/gallery/albums/{album_id}")
async def delete_album(request: Request, album_id: str): async def delete_album(request: Request, album_id: str):
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
album = _get_or_404_album(db, album_id, user) album = _get_or_404_album(db, album_id, user)
@@ -1804,7 +2227,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/add") @router.post("/api/gallery/albums/{album_id}/add")
async def add_to_album(request: Request, album_id: str): async def add_to_album(request: Request, album_id: str):
user = get_current_user(request) user = _gallery_owner(request)
data = await request.json() data = await request.json()
ids = data.get("image_ids", []) ids = data.get("image_ids", [])
db = SessionLocal() db = SessionLocal()
@@ -1822,7 +2245,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/albums/{album_id}/remove") @router.post("/api/gallery/albums/{album_id}/remove")
async def remove_from_album(request: Request, album_id: str): async def remove_from_album(request: Request, album_id: str):
user = get_current_user(request) user = _gallery_owner(request)
data = await request.json() data = await request.json()
ids = data.get("image_ids", []) ids = data.get("image_ids", [])
db = SessionLocal() db = SessionLocal()
@@ -1843,7 +2266,7 @@ def setup_gallery_routes() -> APIRouter:
@router.post("/api/gallery/{image_id}/favorite") @router.post("/api/gallery/{image_id}/favorite")
async def toggle_favorite(request: Request, image_id: str): async def toggle_favorite(request: Request, image_id: str):
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
img = _get_or_404_image(db, image_id, user) img = _get_or_404_image(db, image_id, user)
@@ -1855,13 +2278,16 @@ def setup_gallery_routes() -> APIRouter:
# ---- AI auto-tag ---- # ---- 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): async def ai_tag_image(request: Request, image_id: str):
"""Send image to vision model for auto-tagging.""" """Send image to vision model for auto-tagging."""
import base64, httpx import base64, httpx
from pathlib import Path from pathlib import Path
user = get_current_user(request) user = _gallery_owner(request)
db = SessionLocal() db = SessionLocal()
try: try:
img = _get_or_404_image(db, image_id, user) img = _get_or_404_image(db, image_id, user)
+247 -57
View File
@@ -6,17 +6,31 @@ import logging
import re import re
from typing import Dict, Any, Optional 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.models import ChatMessage
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
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.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 ( from routes.session_routes import (
_message_role, _message_role,
_message_text, _message_text,
_reject_compact_during_active_run, _reject_compact_during_active_run,
_verify_session_owner, _verify_session_owner,
) )
from routes.chat_helpers import _validate_bearer_session_model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +38,24 @@ _HISTORY_INLINE_MEDIA_THRESHOLD = 200_000
_DATA_IMAGE_RE = re.compile(r"data:image/[^;,\"]+;base64,[A-Za-z0-9+/=\s]+") _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: def _history_display_content(content: Any) -> Any:
"""Return a lightweight browser-display copy of stored message content. """Return a lightweight browser-display copy of stored message content.
@@ -98,17 +130,42 @@ def _merge_continue_rows_to_delete(db_messages, db1, db2):
return to_delete return to_delete
def setup_history_routes(session_manager) -> APIRouter: def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
router = APIRouter(tags=["history"]) router = APIRouter(tags=["history"], dependencies=[Depends(require_chat_scope)])
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]: def _reserve_message_uploads(
entry = {"role": m.role, "content": _history_display_content(m.content)} request: Request,
meta = {} content: Any,
if m.meta_data: metadata: Any = None,
) -> None:
try: try:
meta = json.loads(m.meta_data) or {} missing_id = reserve_message_upload_references(
except (json.JSONDecodeError, ValueError): upload_handler,
meta = {} effective_user(request),
content,
metadata,
)
except (TypeError, ValueError) as exc:
raise HTTPException(400, "Invalid message attachment metadata") from exc
if missing_id:
raise HTTPException(
409,
f"Referenced upload is no longer available: {missing_id}",
)
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 = _display_metadata(m.meta_data, sanitize=sanitize)
if m.timestamp and "timestamp" not in meta: if m.timestamp and "timestamp" not in meta:
meta["timestamp"] = m.timestamp.isoformat() + "Z" meta["timestamp"] = m.timestamp.isoformat() + "Z"
if meta: if meta:
@@ -122,6 +179,8 @@ def setup_history_routes(session_manager) -> APIRouter:
limit: Optional[int] = None, limit: Optional[int] = None,
offset: Optional[int] = None, offset: Optional[int] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
require_chat_scope(request)
sanitize_history = is_bearer_principal(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
if limit is not None: if limit is not None:
page_limit = max(1, min(int(limit), 100)) page_limit = max(1, min(int(limit), 100))
@@ -138,6 +197,8 @@ def setup_history_routes(session_manager) -> APIRouter:
) )
page_offset = int(offset) if offset is not None else max(total - page_limit, 0) page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
page_offset = max(0, min(page_offset, total)) page_offset = max(0, min(page_offset, total))
# Keep display pagination page-scoped. ``get_session`` is the
# full model-context hydration seam and must not be entered here.
rows = ( rows = (
db.query(DbChatMessage) db.query(DbChatMessage)
.filter(DbChatMessage.session_id == session_id) .filter(DbChatMessage.session_id == session_id)
@@ -147,7 +208,11 @@ def setup_history_routes(session_manager) -> APIRouter:
.all() .all()
) )
history_dict = [ 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") if not (entry.get("metadata") or {}).get("hidden")
] ]
return { return {
@@ -173,24 +238,35 @@ def setup_history_routes(session_manager) -> APIRouter:
for msg in session.history: for msg in session.history:
if isinstance(msg, ChatMessage): if isinstance(msg, ChatMessage):
# Skip hidden messages (e.g. compaction summaries for AI context) # 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 continue
entry = {"role": msg.role, "content": _history_display_content(msg.content)} entry = {"role": msg.role, "content": _history_display_content(msg.content)}
if msg.metadata: if msg_meta:
entry["metadata"] = msg.metadata entry["metadata"] = msg_meta
history_dict.append(entry) history_dict.append(entry)
elif isinstance(msg, dict): 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 continue
entry = { entry = {
"role": msg.get("role", ""), "role": msg.get("role", ""),
"content": _history_display_content(msg.get("content", "")), "content": _history_display_content(msg.get("content", "")),
} }
if msg.get("metadata"): if msg_meta:
entry["metadata"] = msg["metadata"] entry["metadata"] = msg_meta
history_dict.append(entry) history_dict.append(entry)
# Fallback: load from DB if in-memory is empty # Fallback: load from DB if in-memory renders empty. Display only —
# get_session above is the hydration seam, so nothing here writes back
# into session.history — rebuilding it from raw rows would overwrite
# parsed multimodal content and the _db_id edit/delete keys it just set.
if not history_dict: if not history_dict:
db = SessionLocal() db = SessionLocal()
try: try:
@@ -200,20 +276,14 @@ def setup_history_routes(session_manager) -> APIRouter:
.order_by(DbChatMessage.timestamp) .order_by(DbChatMessage.timestamp)
.all() .all()
) )
db_history = []
for m in db_messages:
db_history.append(_db_history_entry(m))
if db_history:
# Rebuild in-memory history from the full set so hidden
# messages (e.g. compaction summaries) are kept for AI context.
session.history = [
ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata"))
for m in db_history
]
# Response excludes hidden messages, matching the in-memory path. # Response excludes hidden messages, matching the in-memory path.
history_dict = [ history_dict = [
m for m in db_history entry
if not (m.get("metadata") or {}).get("hidden") 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: except Exception as e:
logger.error(f"DB fallback failed for {session_id}: {e}") logger.error(f"DB fallback failed for {session_id}: {e}")
@@ -229,6 +299,7 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/truncate") @router.post("/api/session/{session_id}/truncate")
async def truncate_session(request: Request, session_id: str): async def truncate_session(request: Request, session_id: str):
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
body = await request.json() body = await request.json()
@@ -244,14 +315,17 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/message") @router.post("/api/session/{session_id}/message")
async def add_message(request: Request, session_id: str): async def add_message(request: Request, session_id: str):
"""Add a message to a session (for slash command persistence).""" """Add a message to a session (for slash command persistence)."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
body = await request.json() body = await request.json()
role = body.get("role", "assistant") role = normalize_client_message_role(body.get("role", "assistant"))
content = body.get("content", "") content = body.get("content", "")
if not content: if not content:
raise HTTPException(400, "content is required") raise HTTPException(400, "content is required")
msg = ChatMessage(role=role, content=content, 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) session_manager.add_message(session_id, msg)
return {"status": "ok"} return {"status": "ok"}
except KeyError: except KeyError:
@@ -260,6 +334,7 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/delete-messages") @router.post("/api/session/{session_id}/delete-messages")
async def delete_messages(request: Request, session_id: str): async def delete_messages(request: Request, session_id: str):
"""Delete specific messages by DB ID (or legacy index).""" """Delete specific messages by DB ID (or legacy index)."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
body = await request.json() body = await request.json()
@@ -323,6 +398,7 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/edit-message") @router.post("/api/session/{session_id}/edit-message")
async def edit_message(request: Request, session_id: str): async def edit_message(request: Request, session_id: str):
"""Edit the content of a message by its database ID.""" """Edit the content of a message by its database ID."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
body = await request.json() body = await request.json()
@@ -331,6 +407,8 @@ def setup_history_routes(session_manager) -> APIRouter:
if not msg_id or content is None: if not msg_id or content is None:
raise HTTPException(400, "msg_id and content are required") raise HTTPException(400, "msg_id and content are required")
_reserve_message_uploads(request, content)
session = session_manager.get_session(session_id) session = session_manager.get_session(session_id)
db = SessionLocal() db = SessionLocal()
try: try:
@@ -343,9 +421,8 @@ def setup_history_routes(session_manager) -> APIRouter:
db_msg.content = content db_msg.content = content
meta = {} meta = {}
if db_msg.meta_data: meta = _metadata_dict(db_msg.meta_data)
try: meta = json.loads(db_msg.meta_data) meta = dict(meta)
except (json.JSONDecodeError, ValueError): pass
meta['edited'] = True meta['edited'] = True
db_msg.meta_data = json.dumps(meta) db_msg.meta_data = json.dumps(meta)
@@ -376,6 +453,7 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/mark-stopped") @router.post("/api/session/{session_id}/mark-stopped")
async def mark_stopped(request: Request, session_id: str): async def mark_stopped(request: Request, session_id: str):
"""Mark the last assistant message as stopped by user.""" """Mark the last assistant message as stopped by user."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
session = session_manager.get_session(session_id) session = session_manager.get_session(session_id)
@@ -384,13 +462,13 @@ def setup_history_routes(session_manager) -> APIRouter:
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \ if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
(isinstance(msg, dict) and msg.get('role') == 'assistant'): (isinstance(msg, dict) and msg.get('role') == 'assistant'):
if isinstance(msg, ChatMessage): if isinstance(msg, ChatMessage):
if not msg.metadata: if not isinstance(msg.metadata, dict):
msg.metadata = {} msg.metadata = {}
msg.metadata['stopped'] = True msg.metadata['stopped'] = True
if not msg.metadata.get('model'): if not msg.metadata.get('model'):
msg.metadata['model'] = session.model msg.metadata['model'] = session.model
else: else:
if 'metadata' not in msg: if not isinstance(msg.get('metadata'), dict):
msg['metadata'] = {} msg['metadata'] = {}
msg['metadata']['stopped'] = True msg['metadata']['stopped'] = True
if not msg['metadata'].get('model'): if not msg['metadata'].get('model'):
@@ -408,11 +486,8 @@ def setup_history_routes(session_manager) -> APIRouter:
) )
if db_messages: if db_messages:
meta = {} meta = {}
if db_messages.meta_data: meta = _metadata_dict(db_messages.meta_data)
try: meta = dict(meta)
meta = _json.loads(db_messages.meta_data)
except (json.JSONDecodeError, ValueError):
pass
meta['stopped'] = True meta['stopped'] = True
if not meta.get('model'): if not meta.get('model'):
meta['model'] = session.model meta['model'] = session.model
@@ -431,10 +506,11 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/update-last-meta") @router.post("/api/session/{session_id}/update-last-meta")
async def update_last_meta(request: Request, session_id: str): async def update_last_meta(request: Request, session_id: str):
"""Merge metadata into the last assistant message (e.g. save variants).""" """Merge metadata into the last assistant message (e.g. save variants)."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
body = await request.json() 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) session = session_manager.get_session(session_id)
# Update in-memory # Update in-memory
@@ -442,11 +518,11 @@ def setup_history_routes(session_manager) -> APIRouter:
if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \ if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \
(isinstance(msg, dict) and msg.get('role') == 'assistant'): (isinstance(msg, dict) and msg.get('role') == 'assistant'):
if isinstance(msg, ChatMessage): if isinstance(msg, ChatMessage):
if not msg.metadata: if not isinstance(msg.metadata, dict):
msg.metadata = {} msg.metadata = {}
msg.metadata.update(meta_update) msg.metadata.update(meta_update)
else: else:
if 'metadata' not in msg: if not isinstance(msg.get('metadata'), dict):
msg['metadata'] = {} msg['metadata'] = {}
msg['metadata'].update(meta_update) msg['metadata'].update(meta_update)
break break
@@ -462,10 +538,7 @@ def setup_history_routes(session_manager) -> APIRouter:
.first() .first()
) )
if db_msg: if db_msg:
meta = {} meta = dict(_metadata_dict(db_msg.meta_data))
if db_msg.meta_data:
try: meta = _json.loads(db_msg.meta_data)
except (json.JSONDecodeError, ValueError): pass
meta.update(meta_update) meta.update(meta_update)
db_msg.meta_data = _json.dumps(meta) db_msg.meta_data = _json.dumps(meta)
db.commit() db.commit()
@@ -482,6 +555,7 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/merge-last-assistant") @router.post("/api/session/{session_id}/merge-last-assistant")
async def merge_last_assistant(request: Request, session_id: str): async def merge_last_assistant(request: Request, session_id: str):
"""Merge the last two assistant messages into one (for continue).""" """Merge the last two assistant messages into one (for continue)."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
body = await request.json() body = await request.json()
@@ -506,8 +580,12 @@ def setup_history_routes(session_manager) -> APIRouter:
merged_content = content1 + separator + content2 merged_content = content1 + separator + content2
# Merge metadata # Merge metadata
meta1 = (msg1.metadata if isinstance(msg1, ChatMessage) else msg1.get('metadata')) or {} meta1 = dict(_metadata_dict(
meta2 = (msg2.metadata if isinstance(msg2, ChatMessage) else msg2.get('metadata')) or {} 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 = {**meta1, **meta2}
merged_meta.pop('stopped', None) # no longer stopped after continue merged_meta.pop('stopped', None) # no longer stopped after continue
@@ -571,16 +649,32 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.post("/api/session/{session_id}/fork") @router.post("/api/session/{session_id}/fork")
async def fork_session(request: Request, session_id: str): async def fork_session(request: Request, session_id: str):
"""Create a new session with messages copied up to keep_count.""" """Create a new session with messages copied up to keep_count."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
body = await request.json() body = await request.json()
keep_count = body.get("keep_count", 0) keep_count = body.get("keep_count", 0)
# Get the source session # Get the source session. keep_count indexes into source.history,
source = session_manager.sessions.get(session_id) # so this must go through get_session — reading the cache directly
# forks an empty transcript out of a metadata-only session after a
# restart (display pagination no longer hydrates it).
try:
source = session_manager.get_session(session_id)
except KeyError:
raise HTTPException(404, "Session not found")
if not source: if not source:
raise HTTPException(404, "Session not found") 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 # Create new session
new_id = str(uuid.uuid4()) new_id = str(uuid.uuid4())
fork_name = f"\u2ADD {source.name}" fork_name = f"\u2ADD {source.name}"
@@ -592,6 +686,14 @@ def setup_history_routes(session_manager) -> APIRouter:
rag=False, rag=False,
owner=getattr(source, 'owner', None), 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 # Copy messages up to keep_count
msgs_to_copy = source.history[:keep_count] msgs_to_copy = source.history[:keep_count]
@@ -602,7 +704,10 @@ def setup_history_routes(session_manager) -> APIRouter:
# in-memory messages, corrupting their _db_id and breaking # in-memory messages, corrupting their _db_id and breaking
# edit/delete-by-id on the original conversation. # edit/delete-by-id on the original conversation.
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None 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)) new_session.add_message(ChatMessage(msg.role, msg.content, meta))
if not is_bearer_principal(request):
try: try:
from src.event_bus import fire_event from src.event_bus import fire_event
fire_event("session_created", getattr(source, 'owner', None)) fire_event("session_created", getattr(source, 'owner', None))
@@ -623,6 +728,7 @@ def setup_history_routes(session_manager) -> APIRouter:
@router.get("/api/conversations/topics") @router.get("/api/conversations/topics")
async def get_conversation_topics(request: Request) -> Dict[str, Any]: async def get_conversation_topics(request: Request) -> Dict[str, Any]:
require_chat_scope(request)
from src.auth_helpers import require_user from src.auth_helpers import require_user
user = require_user(request) user = require_user(request)
try: try:
@@ -630,9 +736,69 @@ def setup_history_routes(session_manager) -> APIRouter:
except Exception as e: except Exception as e:
raise HTTPException(500, f"Topic analysis failed: {e}") raise HTTPException(500, f"Topic analysis failed: {e}")
@router.get("/api/session/{session_id}/context")
async def get_session_context_usage(request: Request, session_id: str) -> Dict[str, Any]:
"""Return an estimated whole-chat context usage for the session's model.
Streaming footers report the prompt size for the last request. This
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)
except KeyError:
raise HTTPException(404, "Session not found")
try:
from src.model_context import estimate_tokens, get_context_length
messages = session.get_context_messages()
used = int(estimate_tokens(messages))
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 _metadata_dict(getattr(m, "metadata", None)).get("hidden")
)
compacted_messages = sum(
1 for m in session.history
if _metadata_dict(getattr(m, "metadata", None)).get("compacted")
)
can_compact = used > 0
return {
"session_id": session_id,
"model": session.model,
"endpoint_url": session.endpoint_url,
"used_tokens": used,
"context_length": ctx_len,
"context_percent": pct,
"messages": visible_messages,
"context_messages": len(messages),
"compacted_messages": compacted_messages,
"can_compact": can_compact,
"should_compact": pct >= 70,
"auto_compact_threshold": 85,
}
except Exception as e:
logger.error(f"Context usage error {session_id}: {e}")
raise HTTPException(500, str(e))
@router.post("/api/session/{session_id}/compact") @router.post("/api/session/{session_id}/compact")
async def compact_session(request: Request, session_id: str): async def compact_session(request: Request, session_id: str):
"""Manually trigger context compaction for a session.""" """Manually trigger context compaction for a session."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
from src.auth_helpers import effective_user from src.auth_helpers import effective_user
owner = effective_user(request) owner = effective_user(request)
@@ -645,12 +811,21 @@ def setup_history_routes(session_manager) -> APIRouter:
try: try:
from src.model_context import estimate_tokens, get_context_length from src.model_context import estimate_tokens, get_context_length
from src.llm_core import llm_call_async from src.llm_core import llm_call_async
from src.endpoint_resolver import resolve_endpoint
if len(session.history) < 6: if len(session.history) < 6:
return {"status": "ok", "message": "Not enough messages to compact"} 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() messages_before = session.get_context_messages()
used_before = estimate_tokens(messages_before) used_before = estimate_tokens(messages_before)
pct_before = round((used_before / ctx_len) * 100, 1) if ctx_len else 0 pct_before = round((used_before / ctx_len) * 100, 1) if ctx_len else 0
@@ -668,15 +843,26 @@ def setup_history_routes(session_manager) -> APIRouter:
for m in older for m in older
) )
# Use utility model if available # 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) util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
compact_url = util_url or session.endpoint_url compact_url = util_url or session.endpoint_url
compact_model = util_model or session.model compact_model = util_model or session.model
compact_headers = util_headers if util_url else session.headers 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 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 "")) 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)) 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( summary = await llm_call_async(
compact_url, compact_model, compact_url, compact_model,
[ [
@@ -685,7 +871,9 @@ def setup_history_routes(session_manager) -> APIRouter:
], ],
temperature=0.2, max_tokens=1024, temperature=0.2, max_tokens=1024,
headers=compact_headers, timeout=30, headers=compact_headers, timeout=30,
**compact_kwargs,
) )
summary = normalize_compaction_summary(summary)
# Replace session history: summary as system message + recent messages # Replace session history: summary as system message + recent messages
# System message holds the full summary for AI context # System message holds the full summary for AI context
@@ -761,6 +949,8 @@ def setup_history_routes(session_manager) -> APIRouter:
"after": pct_after, "after": pct_after,
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Manual compact error {session_id}: {e}") logger.error(f"Manual compact error {session_id}: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
+36 -7
View File
@@ -5,10 +5,11 @@ import shlex
import subprocess import subprocess
from copy import deepcopy 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 core.platform_compat import run_ssh_command
from routes._validators import validate_remote_host, validate_ssh_port 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 # 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(): 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") @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. """Detect and return current system hardware info. Pass host=user@server for remote.
fresh=true bypasses the per-host cache (the Rescan button).""" 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 from services.hwfit.hardware import detect_system
host, ssh_port = _validate_detection_target(host, ssh_port) host, ssh_port = _validate_detection_target(host, ssh_port)
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh) return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
@router.get("/models") @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. """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 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 active group). gpu_group: index into system.gpu_groups (the homogeneous
pools) to target — empty/auto = the largest pool. vLLM can only pools) to target — empty/auto = the largest pool. vLLM can only
tensor-parallel across identical GPUs, so we never mix pools. tensor-parallel across identical GPUs, so we never mix pools.
fresh=true bypasses the hardware-detection cache.""" 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.hardware import detect_system
from services.hwfit.fit import rank_models from services.hwfit.fit import rank_models
from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs
@@ -316,7 +325,7 @@ def setup_hwfit_routes():
return payload return payload
@router.get("/profiles") @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` """Compute llama.cpp serve profiles (Quality/Balanced/Speed) for `model`
against the detected hardware on `host` (or local). Returns concrete 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. 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 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. 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.hardware import detect_system
from services.hwfit.models import get_models from services.hwfit.models import get_models
from services.hwfit.profiles import compute_serve_profiles from services.hwfit.profiles import compute_serve_profiles
@@ -410,8 +421,10 @@ def setup_hwfit_routes():
} }
@router.get("/image-models") @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.""" """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.hardware import detect_system
from services.hwfit.image_models import rank_image_models from services.hwfit.image_models import rank_image_models
host, ssh_port = _validate_detection_target(host, ssh_port) host, ssh_port = _validate_detection_target(host, ssh_port)
@@ -429,11 +442,27 @@ def setup_hwfit_routes():
system["available_ram_gb"] = 0 system["available_ram_gb"] = 0
system["total_ram_gb"] = 0 system["total_ram_gb"] = 0
system = _apply_manual_hardware(system, manual_mode, manual_gpu_count, manual_vram_gb, manual_ram_gb, manual_backend) system = _apply_manual_hardware(system, manual_mode, manual_gpu_count, manual_vram_gb, manual_ram_gb, manual_backend)
# Image models use a single GPU — always use per-GPU VRAM try:
requested_gpu_count = int(gpu_count) if gpu_count != "" else None
except ValueError:
requested_gpu_count = None
if requested_gpu_count == 0:
# Respect the UI's RAM toggle. Before this route always rewrote the
# system to best-single-GPU VRAM, so image rows never changed when
# switching RAM/GPU.
system["has_gpu"] = False
system["gpu_vram_gb"] = 0
system["gpu_count"] = 0
system["gpu_only"] = False
else:
# Image diffusion backends generally use one device per pipeline,
# so rank GPU mode against the best single GPU rather than total
# multi-GPU VRAM.
gpu_vrams = [float(g.get("vram_gb") or 0) for g in (system.get("gpus") or []) if isinstance(g, dict)] gpu_vrams = [float(g.get("vram_gb") or 0) for g in (system.get("gpus") or []) if isinstance(g, dict)]
single_vram = max(gpu_vrams) if gpu_vrams else ((system.get("gpu_vram_gb") or 0) / max(system.get("gpu_count") or 1, 1)) single_vram = max(gpu_vrams) if gpu_vrams else ((system.get("gpu_vram_gb") or 0) / max(system.get("gpu_count") or 1, 1))
system["gpu_vram_gb"] = single_vram system["gpu_vram_gb"] = single_vram
system["gpu_count"] = 1 if single_vram > 0 else 0 system["gpu_count"] = 1 if single_vram > 0 else 0
system["gpu_only"] = True if single_vram > 0 else False
results = rank_image_models(system, search=search or None, sort=sort) results = rank_image_models(system, search=search or None, sort=sort)
return {"system": system, "models": results} return {"system": system, "models": results}
+5
View File
@@ -0,0 +1,5 @@
"""MCP route domain package (slice 2o, #4082/#4071).
Contains mcp_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/mcp_routes.py re-exports from here.
"""
+703
View File
@@ -0,0 +1,703 @@
# routes/mcp_routes.py
"""MCP (Model Context Protocol) server management routes."""
import json
import os
import uuid
import urllib.parse
import html
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
import logging
import httpx
from core.database import McpServer, SessionLocal
from core.middleware import require_admin
from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def _mcp_oauth_base_dir() -> Path:
"""Directory that may contain OAuth files managed by Odysseus."""
return Path(MCP_OAUTH_DIR).resolve(strict=False)
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
raw = str(raw_path or "").strip()
if not raw:
return ""
base = _mcp_oauth_base_dir()
path = Path(os.path.expanduser(raw))
if not path.is_absolute():
path = base / path
resolved = path.resolve(strict=False)
try:
resolved.relative_to(base)
except ValueError as exc:
raise HTTPException(
400,
f"Invalid OAuth {field_name}: path must stay under {base}",
) from exc
return str(resolved)
def _sanitize_mcp_oauth_config(oauth_cfg):
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
if not oauth_cfg:
return oauth_cfg
if not isinstance(oauth_cfg, dict):
return {}
sanitized = dict(oauth_cfg)
for field_name in ("keys_file", "token_file"):
if sanitized.get(field_name):
sanitized[field_name] = _resolve_mcp_oauth_path(
sanitized[field_name],
field_name,
)
return sanitized
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
"""Check token existence without letting legacy bad paths break listing."""
if not isinstance(oauth_cfg, dict):
return False
try:
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
except HTTPException:
if strict:
raise
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
return True
return bool(token_file and not os.path.exists(token_file))
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
if not oauth_cfg or not isinstance(env, dict):
return
keys_file = oauth_cfg.get("keys_file")
token_file = oauth_cfg.get("token_file")
if keys_file:
env["GMAIL_OAUTH_PATH"] = keys_file
if token_file:
env["GMAIL_CREDENTIALS_PATH"] = token_file
def _load_disabled_map():
"""Load per-server disabled tool sets from DB."""
db = SessionLocal()
try:
disabled_map = {}
for srv in db.query(McpServer).all():
if srv.disabled_tools:
try:
names = json.loads(srv.disabled_tools)
if names:
disabled_map[srv.id] = set(names)
except (json.JSONDecodeError, TypeError):
pass
return disabled_map
finally:
db.close()
def _mcp_oauth_redirect_uri() -> str:
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
from src.mcp_oauth import REDIRECT_URI
return REDIRECT_URI
def setup_mcp_routes(mcp_manager: McpManager):
"""Setup MCP routes with the provided manager."""
@router.get("/servers")
def list_servers(request: Request):
"""List all configured MCP servers with connection status."""
require_admin(request)
db = SessionLocal()
try:
servers = db.query(McpServer).all()
result = []
for srv in servers:
status = mcp_manager.get_server_status(srv.id)
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
needs_oauth = False
if oauth_cfg:
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
total_tools = status.get("tool_count", 0)
result.append({
"id": srv.id,
"name": srv.name,
"transport": srv.transport,
"command": srv.command,
"args": json.loads(srv.args) if srv.args else [],
"env": json.loads(srv.env) if srv.env else {},
"url": srv.url,
"is_enabled": srv.is_enabled,
"status": status.get("status", "disconnected"),
"tool_count": total_tools,
"disabled_tool_count": len(disabled_list),
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"has_oauth": oauth_cfg is not None,
"needs_oauth": needs_oauth,
})
return result
finally:
db.close()
@router.post("/servers")
async def add_server(
request: Request,
name: str = Form(...),
transport: str = Form("stdio"),
command: str = Form(None),
args: str = Form("[]"),
env: str = Form("{}"),
url: str = Form(None),
oauth_file: str = Form(None),
oauth_config: str = Form(None),
):
"""Add a new MCP server config and attempt connection. Admin-only:
registering a stdio server is equivalent to executing arbitrary
binaries on the host."""
require_admin(request)
server_id = str(uuid.uuid4())[:8]
# Validate
if transport == "stdio" and not command:
raise HTTPException(400, "command is required for stdio transport")
if transport == "sse" and not url:
raise HTTPException(400, "url is required for SSE transport")
if transport == "http" and not url:
raise HTTPException(400, "url is required for HTTP transport")
# Parse JSON fields
try:
parsed_args = json.loads(args) if args else []
except json.JSONDecodeError:
parsed_args = []
try:
parsed_env = json.loads(env) if env else {}
except json.JSONDecodeError:
parsed_env = {}
if not isinstance(parsed_env, dict):
parsed_env = {}
# Parse OAuth config
parsed_oauth_config = None
if oauth_config:
try:
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
except json.JSONDecodeError:
pass
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
# Write OAuth credentials file if provided (for Google MCP servers)
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
if oauth_file:
try:
oauth_data = json.loads(oauth_file)
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
oauth_filename = oauth_data.get("filename", "")
client_id = oauth_data.get("client_id", "")
client_secret = oauth_data.get("client_secret", "")
if oauth_dir and oauth_filename and client_id and client_secret:
filepath = _resolve_mcp_oauth_path(
Path(oauth_dir) / str(oauth_filename),
"filename",
)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
creds = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": ["http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
}
}
with open(filepath, "w", encoding="utf-8") as f:
json.dump(creds, f, indent=2)
logger.info(f"Wrote OAuth credentials to {filepath}")
parsed_env.pop("GOOGLE_CLIENT_ID", None)
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Failed to write OAuth file: {e}")
# Save to DB
db = SessionLocal()
try:
srv = McpServer(
id=server_id,
name=name,
transport=transport,
command=command,
args=json.dumps(parsed_args),
env=json.dumps(parsed_env),
url=url,
is_enabled=True,
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
)
db.add(srv)
db.commit()
finally:
db.close()
# Check if OAuth token already exists — skip connection attempt if not
needs_oauth = False
if parsed_oauth_config:
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
connected = False
if not needs_oauth:
connected = await mcp_manager.connect_server(
server_id=server_id,
name=name,
transport=transport,
command=command,
args=parsed_args,
env=parsed_env,
url=url,
)
status = mcp_manager.get_server_status(server_id)
needs_auth = status.get("status") == "needs_auth"
return {
"id": server_id,
"name": name,
"connected": connected,
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
"needs_oauth": needs_oauth,
"needs_auth": needs_auth,
"auth_url": status.get("auth_url"),
}
@router.post("/servers/{server_id}/reconnect")
async def reconnect_server(server_id: str, request: Request):
"""Reconnect to an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
status = mcp_manager.get_server_status(server_id)
return {
"connected": connected,
"status": status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"needs_auth": status.get("status") == "needs_auth",
}
finally:
db.close()
@router.patch("/servers/{server_id}")
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
"""Enable or disable an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
enabled = str(is_enabled).lower() == "true"
srv.is_enabled = enabled
db.commit()
if enabled:
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
else:
await mcp_manager.disconnect_server(server_id)
return {"id": server_id, "is_enabled": enabled}
finally:
db.close()
@router.delete("/servers/{server_id}")
async def delete_server(server_id: str, request: Request):
"""Remove an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
db.delete(srv)
db.commit()
return {"status": "deleted"}
finally:
db.close()
@router.get("/tools")
def list_tools(request: Request):
"""List all discovered MCP tools across all connected servers."""
require_admin(request)
disabled_map = _load_disabled_map()
return mcp_manager.get_all_tools(disabled_map)
@router.get("/servers/{server_id}/tools")
def list_server_tools(server_id: str, request: Request):
"""List all tools for a specific MCP server with enabled/disabled state."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
disabled_set = set(disabled_list)
finally:
db.close()
all_tools = mcp_manager.get_all_tools()
server_tools = [t for t in all_tools if t["server_id"] == server_id]
for t in server_tools:
t["is_disabled"] = t["name"] in disabled_set
return server_tools
@router.patch("/servers/{server_id}/tools")
async def update_disabled_tools(server_id: str, request: Request):
"""Bulk update disabled tools list for a server.
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
"""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
body = await request.json()
disabled = body.get("disabled", [])
if not isinstance(disabled, list):
raise HTTPException(400, "disabled must be a list of tool names")
srv.disabled_tools = json.dumps(disabled) if disabled else None
db.commit()
return {"id": server_id, "disabled_count": len(disabled)}
finally:
db.close()
# ── OAuth flow for Google MCP servers ──────────────────────────
@router.get("/oauth/authorize/{server_id}")
def oauth_authorize(server_id: str, request: Request):
"""Show OAuth authorization page with Google sign-in link."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
if not srv.oauth_config:
raise HTTPException(400, "Server has no OAuth config")
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
if not keys_file or not os.path.exists(keys_file):
raise HTTPException(400, "OAuth keys file not found")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
if not keys:
raise HTTPException(400, "Invalid OAuth keys file format")
client_id = keys["client_id"]
scopes = oauth_cfg.get("scopes", [])
# For Desktop App creds, default to localhost — the user will
# paste the resulting URL back if they're on a different device.
redirect_uri = _mcp_oauth_redirect_uri()
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(scopes),
"access_type": "offline",
"prompt": "consent",
"state": server_id,
}
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
# Determine if user is accessing from the same machine
host = request.headers.get("host", "")
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
if is_local:
# Same machine — just redirect, callback will work directly
return RedirectResponse(auth_url)
else:
# Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, redirect_uri))
finally:
db.close()
@router.get("/oauth/callback")
async def oauth_callback(code: str, state: str, request: Request):
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
pending-state registry; Google flows fall through to the legacy path."""
require_admin(request)
from src.mcp_oauth import resolve_pending
if resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
# Legacy Google path: state is the server_id
return await _exchange_and_connect(state, code, request)
@router.post("/oauth/exchange/{server_id}")
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
"""Manual code exchange — user pastes the callback URL from their browser."""
require_admin(request)
try:
parsed = urllib.parse.urlparse(callback_url)
params = urllib.parse.parse_qs(parsed.query)
code = params.get("code", [None])[0]
if not code:
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
except Exception:
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
# resolve it directly (the background connect finishes the handshake).
state = params.get("state", [None])[0]
from src.mcp_oauth import resolve_pending
if state and resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
return await _exchange_and_connect(server_id, code, request)
async def _exchange_and_connect(server_id: str, code: str, request: Request):
"""Exchange auth code for tokens and connect the MCP server."""
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
if not srv.oauth_config:
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
token_file = oauth_cfg.get("token_file", "")
if not keys_file or not token_file:
raise HTTPException(400, "OAuth keys/token file not configured")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
client_id = keys["client_id"]
client_secret = keys["client_secret"]
redirect_uri = _mcp_oauth_redirect_uri()
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
err = resp.text
logger.error(f"OAuth token exchange failed: {err}")
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
tokens = resp.json()
logger.info(f"OAuth tokens received for server {server_id}")
# Save tokens to the file the MCP package expects
os.makedirs(os.path.dirname(token_file), exist_ok=True)
with open(token_file, "w", encoding="utf-8") as f:
json.dump(tokens, f, indent=2)
logger.info(f"Saved OAuth tokens to {token_file}")
# Attempt to connect the MCP server now
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
if connected:
status = mcp_manager.get_server_status(server_id)
tool_count = status.get("tool_count", 0)
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
f"{srv.name} connected with {tool_count} tools. You can close this window.",
success=True,
))
else:
status = mcp_manager.get_server_status(server_id)
return HTMLResponse(_oauth_result_page(
"Authorized but Connection Failed",
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
))
except HTTPException as e:
logger.warning(f"OAuth callback rejected: {e.detail}")
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
except Exception as e:
logger.exception(f"OAuth callback error: {e}")
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
finally:
db.close()
return router
def _oauth_authorize_page(
auth_url: str,
server_id: str,
redirect_uri: str,
) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access."""
# Escape values interpolated into the page: `server_id` comes from the OAuth
# state and is not trusted.
auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>Authorize — Odysseus</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 480px; text-align: center; }}
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
.step b {{ color: #e06c75; }}
a.auth-link {{
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
font-weight: 600; font-size: 0.9rem;
}}
a.auth-link:hover {{ background: #c55; }}
input[type=text] {{
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
}}
input:focus {{ outline: none; border-color: #e06c75; }}
button {{
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
}}
button:hover {{ background: #c55; }}
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
</style></head>
<body><div class="card">
<h2>Authorize Google Account</h2>
<div class="step">
<b>1.</b> Click the button below to sign in with Google<br>
<b>2.</b> After approving, your browser will show an error page — that's normal<br>
<b>3.</b> Copy the full URL from your browser's address bar<br>
<b>4.</b> Paste it below and click Connect
</div>
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
<div class="divider"></div>
<!-- Relative action: the browser resolves it against the origin this page was
served from, so the form follows the user through any proxy without the
app having to know the scheme or the host. An absolute http:// action is
blocked as mixed content on exactly the HTTPS deployments that need
paste-back, and request.url.scheme cannot be trusted to spot them —
uvicorn only honours X-Forwarded-Proto from a peer in
--forwarded-allow-ips, which defaults to 127.0.0.1 and excludes a proxy
arriving over the Docker bridge. -->
<form method="POST" action="/api/mcp/oauth/exchange/{server_id}">
<p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button>
</form>
</div></body></html>"""
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
"""Generate a simple HTML page for the OAuth result."""
safe_title = html.escape(title)
safe_message = html.escape(message)
color = "#00661a" if success else "#e06c75"
icon = "&#10003;" if success else "&#10007;"
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>{safe_title}</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 420px; text-align: center; }}
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
</style></head>
<body><div class="card">
<div class="icon">{icon}</div>
<h2>{safe_title}</h2>
<p>{safe_message}</p>
</div></body></html>"""
+14 -693
View File
@@ -1,697 +1,18 @@
# routes/mcp_routes.py """Backward-compat shim — canonical location is routes/mcp/mcp_routes.py.
"""MCP (Model Context Protocol) server management routes."""
import json
import os
import uuid
import urllib.parse
import html
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
import logging
import httpx
from core.database import McpServer, SessionLocal This module is replaced in ``sys.modules`` by the canonical module object so
from core.middleware import require_admin that ``import routes.mcp_routes``, ``from routes.mcp_routes import X``,
from src.constants import DATA_DIR, MCP_OAUTH_DIR ``importlib.import_module("routes.mcp_routes")``, the
from src.mcp_manager import McpManager ``sys.modules.pop("routes.mcp_routes")`` + re-import pattern in
test_security_regressions.py, and the ``monkeypatch.setattr(mcp_routes,
"MCP_OAUTH_DIR", ...)`` pattern all operate on the *same* object. This also
makes ``mcp_routes.__file__`` resolve to the canonical file (which the
source-introspection at line 839 reads). Keeps existing import paths working
after slice 2o (#4082/#4071).
"""
logger = logging.getLogger(__name__) import sys as _sys
router = APIRouter(prefix="/api/mcp", tags=["mcp"]) from routes.mcp import mcp_routes as _canonical # noqa: F401
_sys.modules[__name__] = _canonical
def _mcp_oauth_base_dir() -> Path:
"""Directory that may contain OAuth files managed by Odysseus."""
return Path(MCP_OAUTH_DIR).resolve(strict=False)
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
raw = str(raw_path or "").strip()
if not raw:
return ""
base = _mcp_oauth_base_dir()
path = Path(os.path.expanduser(raw))
if not path.is_absolute():
path = base / path
resolved = path.resolve(strict=False)
try:
resolved.relative_to(base)
except ValueError as exc:
raise HTTPException(
400,
f"Invalid OAuth {field_name}: path must stay under {base}",
) from exc
return str(resolved)
def _sanitize_mcp_oauth_config(oauth_cfg):
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
if not oauth_cfg:
return oauth_cfg
if not isinstance(oauth_cfg, dict):
return {}
sanitized = dict(oauth_cfg)
for field_name in ("keys_file", "token_file"):
if sanitized.get(field_name):
sanitized[field_name] = _resolve_mcp_oauth_path(
sanitized[field_name],
field_name,
)
return sanitized
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
"""Check token existence without letting legacy bad paths break listing."""
if not isinstance(oauth_cfg, dict):
return False
try:
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
except HTTPException:
if strict:
raise
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
return True
return bool(token_file and not os.path.exists(token_file))
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
if not oauth_cfg or not isinstance(env, dict):
return
keys_file = oauth_cfg.get("keys_file")
token_file = oauth_cfg.get("token_file")
if keys_file:
env["GMAIL_OAUTH_PATH"] = keys_file
if token_file:
env["GMAIL_CREDENTIALS_PATH"] = token_file
def _load_disabled_map():
"""Load per-server disabled tool sets from DB."""
db = SessionLocal()
try:
disabled_map = {}
for srv in db.query(McpServer).all():
if srv.disabled_tools:
try:
names = json.loads(srv.disabled_tools)
if names:
disabled_map[srv.id] = set(names)
except (json.JSONDecodeError, TypeError):
pass
return disabled_map
finally:
db.close()
def _mcp_oauth_redirect_uri() -> str:
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
from src.mcp_oauth import REDIRECT_URI
return REDIRECT_URI
def setup_mcp_routes(mcp_manager: McpManager):
"""Setup MCP routes with the provided manager."""
@router.get("/servers")
def list_servers(request: Request):
"""List all configured MCP servers with connection status."""
require_admin(request)
db = SessionLocal()
try:
servers = db.query(McpServer).all()
result = []
for srv in servers:
status = mcp_manager.get_server_status(srv.id)
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
needs_oauth = False
if oauth_cfg:
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
total_tools = status.get("tool_count", 0)
result.append({
"id": srv.id,
"name": srv.name,
"transport": srv.transport,
"command": srv.command,
"args": json.loads(srv.args) if srv.args else [],
"env": json.loads(srv.env) if srv.env else {},
"url": srv.url,
"is_enabled": srv.is_enabled,
"status": status.get("status", "disconnected"),
"tool_count": total_tools,
"disabled_tool_count": len(disabled_list),
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"has_oauth": oauth_cfg is not None,
"needs_oauth": needs_oauth,
})
return result
finally:
db.close()
@router.post("/servers")
async def add_server(
request: Request,
name: str = Form(...),
transport: str = Form("stdio"),
command: str = Form(None),
args: str = Form("[]"),
env: str = Form("{}"),
url: str = Form(None),
oauth_file: str = Form(None),
oauth_config: str = Form(None),
):
"""Add a new MCP server config and attempt connection. Admin-only:
registering a stdio server is equivalent to executing arbitrary
binaries on the host."""
require_admin(request)
server_id = str(uuid.uuid4())[:8]
# Validate
if transport == "stdio" and not command:
raise HTTPException(400, "command is required for stdio transport")
if transport == "sse" and not url:
raise HTTPException(400, "url is required for SSE transport")
if transport == "http" and not url:
raise HTTPException(400, "url is required for HTTP transport")
# Parse JSON fields
try:
parsed_args = json.loads(args) if args else []
except json.JSONDecodeError:
parsed_args = []
try:
parsed_env = json.loads(env) if env else {}
except json.JSONDecodeError:
parsed_env = {}
if not isinstance(parsed_env, dict):
parsed_env = {}
# Parse OAuth config
parsed_oauth_config = None
if oauth_config:
try:
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
except json.JSONDecodeError:
pass
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
# Write OAuth credentials file if provided (for Google MCP servers)
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
if oauth_file:
try:
oauth_data = json.loads(oauth_file)
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
oauth_filename = oauth_data.get("filename", "")
client_id = oauth_data.get("client_id", "")
client_secret = oauth_data.get("client_secret", "")
if oauth_dir and oauth_filename and client_id and client_secret:
filepath = _resolve_mcp_oauth_path(
Path(oauth_dir) / str(oauth_filename),
"filename",
)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
creds = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": ["http://localhost"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
}
}
with open(filepath, "w", encoding="utf-8") as f:
json.dump(creds, f, indent=2)
logger.info(f"Wrote OAuth credentials to {filepath}")
parsed_env.pop("GOOGLE_CLIENT_ID", None)
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Failed to write OAuth file: {e}")
# Save to DB
db = SessionLocal()
try:
srv = McpServer(
id=server_id,
name=name,
transport=transport,
command=command,
args=json.dumps(parsed_args),
env=json.dumps(parsed_env),
url=url,
is_enabled=True,
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
)
db.add(srv)
db.commit()
finally:
db.close()
# Check if OAuth token already exists — skip connection attempt if not
needs_oauth = False
if parsed_oauth_config:
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
connected = False
if not needs_oauth:
connected = await mcp_manager.connect_server(
server_id=server_id,
name=name,
transport=transport,
command=command,
args=parsed_args,
env=parsed_env,
url=url,
)
status = mcp_manager.get_server_status(server_id)
needs_auth = status.get("status") == "needs_auth"
return {
"id": server_id,
"name": name,
"connected": connected,
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
"needs_oauth": needs_oauth,
"needs_auth": needs_auth,
"auth_url": status.get("auth_url"),
}
@router.post("/servers/{server_id}/reconnect")
async def reconnect_server(server_id: str, request: Request):
"""Reconnect to an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
status = mcp_manager.get_server_status(server_id)
return {
"connected": connected,
"status": status.get("status", "disconnected"),
"tool_count": status.get("tool_count", 0),
"error": status.get("error"),
"auth_url": status.get("auth_url"),
"needs_auth": status.get("status") == "needs_auth",
}
finally:
db.close()
@router.patch("/servers/{server_id}")
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
"""Enable or disable an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
enabled = str(is_enabled).lower() == "true"
srv.is_enabled = enabled
db.commit()
if enabled:
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
else:
await mcp_manager.disconnect_server(server_id)
return {"id": server_id, "is_enabled": enabled}
finally:
db.close()
@router.delete("/servers/{server_id}")
async def delete_server(server_id: str, request: Request):
"""Remove an MCP server."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
await mcp_manager.disconnect_server(server_id)
db.delete(srv)
db.commit()
return {"status": "deleted"}
finally:
db.close()
@router.get("/tools")
def list_tools(request: Request):
"""List all discovered MCP tools across all connected servers."""
require_admin(request)
disabled_map = _load_disabled_map()
return mcp_manager.get_all_tools(disabled_map)
@router.get("/servers/{server_id}/tools")
def list_server_tools(server_id: str, request: Request):
"""List all tools for a specific MCP server with enabled/disabled state."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
disabled_set = set(disabled_list)
finally:
db.close()
all_tools = mcp_manager.get_all_tools()
server_tools = [t for t in all_tools if t["server_id"] == server_id]
for t in server_tools:
t["is_disabled"] = t["name"] in disabled_set
return server_tools
@router.patch("/servers/{server_id}/tools")
async def update_disabled_tools(server_id: str, request: Request):
"""Bulk update disabled tools list for a server.
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
"""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
body = await request.json()
disabled = body.get("disabled", [])
if not isinstance(disabled, list):
raise HTTPException(400, "disabled must be a list of tool names")
srv.disabled_tools = json.dumps(disabled) if disabled else None
db.commit()
return {"id": server_id, "disabled_count": len(disabled)}
finally:
db.close()
# ── OAuth flow for Google MCP servers ──────────────────────────
@router.get("/oauth/authorize/{server_id}")
def oauth_authorize(server_id: str, request: Request):
"""Show OAuth authorization page with Google sign-in link."""
require_admin(request)
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
raise HTTPException(404, "Server not found")
if not srv.oauth_config:
raise HTTPException(400, "Server has no OAuth config")
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
if not keys_file or not os.path.exists(keys_file):
raise HTTPException(400, "OAuth keys file not found")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
if not keys:
raise HTTPException(400, "Invalid OAuth keys file format")
client_id = keys["client_id"]
scopes = oauth_cfg.get("scopes", [])
# For Desktop App creds, default to localhost — the user will
# paste the resulting URL back if they're on a different device.
redirect_uri = _mcp_oauth_redirect_uri()
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(scopes),
"access_type": "offline",
"prompt": "consent",
"state": server_id,
}
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
# Determine if user is accessing from the same machine
host = request.headers.get("host", "")
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
if is_local:
# Same machine — just redirect, callback will work directly
return RedirectResponse(auth_url)
else:
# Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
finally:
db.close()
@router.get("/oauth/callback")
async def oauth_callback(code: str, state: str, request: Request):
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
pending-state registry; Google flows fall through to the legacy path."""
require_admin(request)
from src.mcp_oauth import resolve_pending
if resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
# Legacy Google path: state is the server_id
return await _exchange_and_connect(state, code, request)
@router.post("/oauth/exchange/{server_id}")
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
"""Manual code exchange — user pastes the callback URL from their browser."""
require_admin(request)
try:
parsed = urllib.parse.urlparse(callback_url)
params = urllib.parse.parse_qs(parsed.query)
code = params.get("code", [None])[0]
if not code:
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
except Exception:
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
# resolve it directly (the background connect finishes the handshake).
state = params.get("state", [None])[0]
from src.mcp_oauth import resolve_pending
if state and resolve_pending(state, code):
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
"The MCP server is connecting. You can close this window and return to Odysseus.",
success=True,
))
return await _exchange_and_connect(server_id, code, request)
async def _exchange_and_connect(server_id: str, code: str, request: Request):
"""Exchange auth code for tokens and connect the MCP server."""
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
if not srv:
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
if not srv.oauth_config:
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
keys_file = oauth_cfg.get("keys_file", "")
token_file = oauth_cfg.get("token_file", "")
if not keys_file or not token_file:
raise HTTPException(400, "OAuth keys/token file not configured")
with open(keys_file, encoding="utf-8") as f:
keys_data = json.load(f)
keys = keys_data.get("installed") or keys_data.get("web")
client_id = keys["client_id"]
client_secret = keys["client_secret"]
redirect_uri = _mcp_oauth_redirect_uri()
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if resp.status_code != 200:
err = resp.text
logger.error(f"OAuth token exchange failed: {err}")
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
tokens = resp.json()
logger.info(f"OAuth tokens received for server {server_id}")
# Save tokens to the file the MCP package expects
os.makedirs(os.path.dirname(token_file), exist_ok=True)
with open(token_file, "w", encoding="utf-8") as f:
json.dump(tokens, f, indent=2)
logger.info(f"Saved OAuth tokens to {token_file}")
# Attempt to connect the MCP server now
args = json.loads(srv.args) if srv.args else []
env = json.loads(srv.env) if srv.env else {}
connected = await mcp_manager.connect_server(
server_id=server_id,
name=srv.name,
transport=srv.transport,
command=srv.command,
args=args,
env=env,
url=srv.url,
)
if connected:
status = mcp_manager.get_server_status(server_id)
tool_count = status.get("tool_count", 0)
return HTMLResponse(_oauth_result_page(
"Authorization Successful",
f"{srv.name} connected with {tool_count} tools. You can close this window.",
success=True,
))
else:
status = mcp_manager.get_server_status(server_id)
return HTMLResponse(_oauth_result_page(
"Authorized but Connection Failed",
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
))
except HTTPException as e:
logger.warning(f"OAuth callback rejected: {e.detail}")
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
except Exception as e:
logger.exception(f"OAuth callback error: {e}")
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
finally:
db.close()
return router
def _oauth_authorize_page(
auth_url: str,
server_id: str,
host: str,
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access."""
# Escape values interpolated into the page: `host` comes from the request
# Host header and `server_id` from the OAuth state — neither is trusted.
auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True)
host = html.escape(host, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>Authorize — Odysseus</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 480px; text-align: center; }}
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
.step b {{ color: #e06c75; }}
a.auth-link {{
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
font-weight: 600; font-size: 0.9rem;
}}
a.auth-link:hover {{ background: #c55; }}
input[type=text] {{
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
}}
input:focus {{ outline: none; border-color: #e06c75; }}
button {{
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
}}
button:hover {{ background: #c55; }}
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
</style></head>
<body><div class="card">
<h2>Authorize Google Account</h2>
<div class="step">
<b>1.</b> Click the button below to sign in with Google<br>
<b>2.</b> After approving, your browser will show an error page — that's normal<br>
<b>3.</b> Copy the full URL from your browser's address bar<br>
<b>4.</b> Paste it below and click Connect
</div>
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
<div class="divider"></div>
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
<p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button>
</form>
</div></body></html>"""
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
"""Generate a simple HTML page for the OAuth result."""
safe_title = html.escape(title)
safe_message = html.escape(message)
color = "#00661a" if success else "#e06c75"
icon = "&#10003;" if success else "&#10007;"
return f"""<!DOCTYPE html>
<html><head>
<meta charset="UTF-8"><title>{safe_title}</title>
<style>
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
padding: 2rem; max-width: 420px; text-align: center; }}
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
</style></head>
<body><div class="card">
<div class="icon">{icon}</div>
<h2>{safe_title}</h2>
<p>{safe_message}</p>
</div></body></html>"""
+33 -7
View File
@@ -1,5 +1,5 @@
# routes/memory_routes.py # 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 from typing import Dict, Any, Optional, List
import json import json
import os import os
@@ -21,7 +21,7 @@ def _strip_list_prefix(text: str) -> str:
return text return text
return _LIST_PREFIX_RE.sub("", text, count=1).strip() return _LIST_PREFIX_RE.sub("", text, count=1).strip()
from services.memory import MemoryManager from services.memory import MemoryManager, MemoryStoreUnreadable
from core.session_manager import SessionManager from core.session_manager import SessionManager
from src.request_models import MemoryAddRequest from src.request_models import MemoryAddRequest
from core.database import SessionLocal from core.database import SessionLocal
@@ -35,11 +35,37 @@ from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
"""Load the whole store for a read-modify-write cycle.
A transient read failure must not look like an empty store: the caller
would append to ``[]`` and save that back, atomically destroying every
existing memory (issue #5673). Surface it as a 503 and change nothing.
"""
try:
return memory_manager.load_all_for_update()
except MemoryStoreUnreadable as e:
logger.error("Refusing to rewrite the memory store: %s", e)
raise HTTPException(
503, "Memory store is temporarily unreadable — no changes were made."
)
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None): def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
"""Set up memory-related routes.""" """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]: 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) return get_current_user(request)
def _assert_session_owner(session_obj, user): def _assert_session_owner(session_obj, user):
@@ -116,7 +142,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user) new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
if memory_data.session_id: if memory_data.session_id:
new_entry["session_id"] = memory_data.session_id new_entry["session_id"] = memory_data.session_id
all_mem = memory_manager.load_all() all_mem = _load_for_update(memory_manager)
all_mem.append(new_entry) all_mem.append(new_entry)
memory_manager.save(all_mem) memory_manager.save(all_mem)
# Sync vector index # Sync vector index
@@ -487,7 +513,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)): def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
"""Pin or unpin a memory. Pinned memories are always included in context.""" """Pin or unpin a memory. Pinned memories are always included in context."""
user = _owner(request) user = _owner(request)
all_mem = memory_manager.load_all() all_mem = _load_for_update(memory_manager)
for i, memory in enumerate(all_mem): for i, memory in enumerate(all_mem):
if memory["id"] == memory_id: if memory["id"] == memory_id:
_verify_memory_owner(memory, user) _verify_memory_owner(memory, user)
@@ -512,7 +538,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)): def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
"""Update an existing memory item with new text and optional category.""" """Update an existing memory item with new text and optional category."""
user = _owner(request) user = _owner(request)
all_mem = memory_manager.load_all() all_mem = _load_for_update(memory_manager)
for i, memory in enumerate(all_mem): for i, memory in enumerate(all_mem):
if memory["id"] == memory_id: if memory["id"] == memory_id:
_verify_memory_owner(memory, user) _verify_memory_owner(memory, user)
@@ -534,7 +560,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
def delete_memory(request: Request, memory_id: str): def delete_memory(request: Request, memory_id: str):
"""Delete a memory item by its ID.""" """Delete a memory item by its ID."""
user = _owner(request) user = _owner(request)
all_mem = memory_manager.load_all() all_mem = _load_for_update(memory_manager)
# Find and verify ownership before deleting # Find and verify ownership before deleting
target = next((m for m in all_mem if m["id"] == memory_id), None) target = next((m for m in all_mem if m["id"] == memory_id), None)
+330 -65
View File
@@ -29,7 +29,12 @@ from src.endpoint_resolver import (
build_models_url, build_models_url,
build_headers, 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__) logger = logging.getLogger(__name__)
@@ -46,10 +51,12 @@ _ENDPOINT_SETTING_FIELDS = {
} }
_ENDPOINT_FALLBACK_FIELDS = { _ENDPOINT_FALLBACK_FIELDS = {
"default_model_fallbacks": "Default Model Fallbacks", "foreground_model_fallbacks": "Foreground Model Fallbacks",
"utility_model_fallbacks": "Utility Model Fallbacks", "utility_model_fallbacks": "Utility Model Fallbacks",
"vision_model_fallbacks": "Vision Model Fallbacks", "vision_model_fallbacks": "Vision Model Fallbacks",
} }
# `default_model_fallbacks` is intentionally absent. The legacy data remains
# stored as-is even when an endpoint is removed, but no longer affects routing.
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list: def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
@@ -179,7 +186,12 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
if not isinstance(all_prefs, dict): if not isinstance(all_prefs, dict):
return 0 return 0
users = all_prefs.get("_users") users = all_prefs.get("_users")
pref_sets = users.values() if isinstance(users, dict) else [all_prefs] # A mixed store can contain auth-disabled foreground policy at the root
# alongside named-owner preferences. Both are active namespaces; legacy
# `default_model_fallbacks` remains untouched by the field allowlist.
pref_sets = [all_prefs]
if isinstance(users, dict):
pref_sets.extend(users.values())
cleared_users = 0 cleared_users = 0
for prefs in pref_sets: for prefs in pref_sets:
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id): if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
@@ -472,7 +484,11 @@ def _endpoint_kind(ep: Any) -> str:
def _endpoint_refresh_mode(ep: Any, endpoint_kind: str | None = None) -> str: def _endpoint_refresh_mode(ep: Any, endpoint_kind: str | None = None) -> str:
return _normalize_refresh_mode(getattr(ep, "model_refresh_mode", None), endpoint_kind or _endpoint_kind(ep)) return _normalize_endpoint_refresh_mode(
getattr(ep, "model_refresh_mode", None),
endpoint_kind or _endpoint_kind(ep),
getattr(ep, "base_url", ""),
)
def _endpoint_refresh_interval(ep: Any, category: str) -> float: def _endpoint_refresh_interval(ep: Any, category: str) -> float:
@@ -851,6 +867,99 @@ def _ollama_model_names(data: Any) -> List[str]:
return out return out
def _is_google_api_base(base_url: str) -> bool:
try:
return (urlparse(base_url).hostname or "").lower() == "generativelanguage.googleapis.com"
except Exception:
return False
def _normalize_endpoint_refresh_mode(value: Any, endpoint_kind: str = "auto", base_url: str = "") -> str:
if not str(value or "").strip() and _is_google_api_base(base_url):
return "manual"
return _normalize_refresh_mode(value, endpoint_kind)
def _google_native_root(base_url: str) -> str:
"""Return the Gemini native API root for a Google endpoint.
Chat calls may be configured against Google's OpenAI-compatible
`/openai` path, but model catalog reads should use the native Models API
so we get Google's current Model resource shape.
"""
try:
parsed = urlparse(base_url)
except Exception:
return "https://generativelanguage.googleapis.com/v1beta"
path = (parsed.path or "").rstrip("/")
if path.endswith("/openai"):
path = path[: -len("/openai")].rstrip("/")
if not path:
path = "/v1beta"
return urlunparse(parsed._replace(path=path, query="", fragment="")).rstrip("/")
def _google_native_models_url(base_url: str) -> str:
return _google_native_root(base_url) + "/models"
def _google_model_id_from_item(item: Any) -> str:
if not isinstance(item, dict):
return ""
value = item.get("baseModelId") or item.get("name") or item.get("model") or ""
return str(value or "").strip().removeprefix("models/")
def _google_model_supports_chat(item: Any) -> bool:
"""Return whether a native Google Model resource supports chat generation."""
if not isinstance(item, dict):
return False
methods = item.get("supportedGenerationMethods")
if not isinstance(methods, list):
return False
chat_methods = {"generateContent", "generateMessage", "generateText", "generateAnswer"}
return any(method in chat_methods for method in methods)
def _probe_google_models(base_url: str, api_key: str = None, timeout: int = 5, page_size: int = 1000) -> List[str]:
"""Read Google's native paginated Models API.
This intentionally returns only provider-reported model IDs. Capability
mapping is handled by the model capability reader and must not infer from
names here.
"""
url = _google_native_models_url(base_url)
try:
page_size = min(max(int(page_size or 1000), 1), 1000)
except Exception:
page_size = 1000
headers = {"Accept": "application/json"}
if api_key:
headers["x-goog-api-key"] = api_key
params: Dict[str, Any] = {"pageSize": page_size}
models: List[str] = []
seen = set()
page_token = ""
for _ in range(20):
request_params = dict(params)
if page_token:
request_params["pageToken"] = page_token
r = httpx.get(url, headers=headers, params=request_params, timeout=timeout, verify=llm_verify())
r.raise_for_status()
data = r.json()
for item in data.get("models") or []:
if not _google_model_supports_chat(item):
continue
model_id = _google_model_id_from_item(item)
if model_id and model_id not in seen:
seen.add(model_id)
models.append(model_id)
page_token = str(data.get("nextPageToken") or "").strip()
if not page_token:
break
return models
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]: def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
"""Probe a base URL's /models endpoint and return list of model IDs. """Probe a base URL's /models endpoint and return list of model IDs.
For Anthropic, queries their /v1/models API, falling back to hardcoded list.""" For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
@@ -863,6 +972,17 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
if api_key: if api_key:
return fetch_available_models(api_key, timeout=timeout) return fetch_available_models(api_key, timeout=timeout)
return [] return []
if _is_google_api_base(base):
try:
models = _probe_google_models(base, api_key, timeout=timeout)
if models:
return models
except httpx.HTTPStatusError as e:
status = e.response.status_code if e.response is not None else "unknown"
logger.warning(f"Google native models probe failed: HTTP {status}")
except Exception as e:
logger.warning(f"Google native models probe failed: {e}")
return []
if provider == "anthropic": if provider == "anthropic":
# Try Anthropic's /v1/models endpoint first # Try Anthropic's /v1/models endpoint first
url = _safe_build_models_url(base) url = _safe_build_models_url(base)
@@ -1202,6 +1322,118 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
return [m for m in merged if m not in hidden] return [m for m in merged if m not in hidden]
def _picker_requires_pinning(base_url: str, kind: str) -> bool:
return _classify_endpoint(base_url, kind) == "api"
def _has_explicit_pinned_models(ep) -> bool:
"""Whether pinned_models was deliberately written for this endpoint.
API endpoints use pinned_models as an allow-list. An explicit empty JSON
list means "show no models"; it must not fall back to the old hidden-list
migration behavior.
"""
raw = getattr(ep, "pinned_models", None)
return raw is not None and str(raw).strip() != ""
def _legacy_visible_api_models(ep) -> List[str]:
"""Return API models selected under the old hidden-list picker.
Before API endpoints switched to an explicit allow-list, selected models
were represented as cached_models minus hidden_models. Existing OpenRouter
rows can therefore have many checked models and an empty pinned_models
field. Treat that old state as the initial pinned list so settings and chat
agree after upgrade.
"""
return _visible_models(
_cached_model_ids(ep),
getattr(ep, "hidden_models", None),
None,
)
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
"""Return model IDs that should appear in the picker for an endpoint.
API providers expose remote inventory from /v1/models. Default to that
visible inventory until an explicit pinned-model allow-list is saved.
Local/self-hosted endpoints keep the older hide-list behavior.
"""
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if _picker_requires_pinning(base_url, kind):
if not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep)
return pinned, pinned
return _visible_models(
_cached_model_ids(ep),
getattr(ep, "hidden_models", None),
pinned,
), 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: def _api_key_fingerprint(api_key: Optional[str]) -> str:
"""Stable, non-secret label for distinguishing same-URL credentials.""" """Stable, non-secret label for distinguishing same-URL credentials."""
key = (api_key or "").strip() key = (api_key or "").strip()
@@ -1371,7 +1603,12 @@ def setup_model_routes(model_discovery):
_refresh_inflight["v"] = False _refresh_inflight["v"] = False
threading.Thread(target=_do, daemon=True).start() 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. """Return model list from cached data (instant). Background refresh keeps caches fresh.
SECURITY: filters endpoints by `owner` — without this the picker SECURITY: filters endpoints by `owner` — without this the picker
@@ -1386,7 +1623,7 @@ def setup_model_routes(model_discovery):
db = SessionLocal() db = SessionLocal()
try: try:
if _disable_stale_cookbook_local_endpoints(db): if not read_only and _disable_stale_cookbook_local_endpoints(db):
_invalidate_models_cache() _invalidate_models_cache()
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner and not is_admin: if owner and not is_admin:
@@ -1400,24 +1637,18 @@ def setup_model_routes(model_discovery):
for ep in endpoints: for ep in endpoints:
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
provider = _safe_detect_provider(base) provider = _safe_detect_provider(base)
# Merge cached + pinned models, then filter out hidden ones
ep_model_type = getattr(ep, "model_type", None) or "llm" ep_model_type = getattr(ep, "model_type", None) or "llm"
model_ids = _visible_models(
_cached_model_ids(ep),
ep.hidden_models,
getattr(ep, "pinned_models", None),
)
# Build correct URL based on provider # Build correct URL based on provider
chat_url = build_chat_url(base) chat_url = build_chat_url(base)
kind = _effective_endpoint_kind(ep, base) kind = _effective_endpoint_kind(ep, base)
category = _classify_endpoint(base, kind) category = _classify_endpoint(base, kind)
model_ids, pinned = _picker_models_for_endpoint(ep, base, kind)
if model_ids: if model_ids:
curated_key = _match_provider_curated(base, None) curated_key = _match_provider_curated(base, None)
curated, extra = _curate_models(model_ids, curated_key) curated, extra = _curate_models(model_ids, curated_key)
# Pinned models are admin-selected — they always belong in the # Pinned models are admin-selected — they always belong in the
# primary curated list, not buried in extras. # primary curated list, not buried in extras.
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
for m in pinned: for m in pinned:
if m not in curated: if m not in curated:
curated.append(m) curated.append(m)
@@ -1463,13 +1694,7 @@ def setup_model_routes(model_discovery):
# Require auth; "" is the unconfigured single-user mode, treated as # Require auth; "" is the unconfigured single-user mode, treated as
# "see everything" by _fetch_models. # "see everything" by _fetch_models.
try: try:
if getattr(request.state, "api_token", False): owner = require_chat_scope(request) or ""
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 ""
# Reject anonymous in configured deployments — no leaking the model # Reject anonymous in configured deployments — no leaking the model
# list to unauthenticated callers. # list to unauthenticated callers.
@@ -1481,6 +1706,17 @@ def setup_model_routes(model_discovery):
except Exception as e: except Exception as e:
logger.error("Auth gate error in GET /api/models, failing closed: %s", e) logger.error("Auth gate error in GET /api/models, failing closed: %s", e)
raise HTTPException(status_code=500, detail="Internal error") 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 # Admins see every endpoint (they manage the global pool); regular
# users get the owner-scoped view. # users get the owner-scoped view.
_is_admin = False _is_admin = False
@@ -1779,18 +2015,24 @@ def setup_model_routes(model_discovery):
_invalidate_models_cache() _invalidate_models_cache()
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all() rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
results = [] results = []
upgraded_legacy_pins = False
for r in rows: for r in rows:
all_models = _cached_model_ids(r) all_models = _cached_model_ids(r)
hidden = _hidden_model_ids(r) hidden = _hidden_model_ids(r)
pinned = _normalize_model_ids(getattr(r, "pinned_models", None)) pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
visible = _visible_models(all_models, r.hidden_models, pinned)
# Keep the list route cache-only. It feeds Settings → # Keep the list route cache-only. It feeds Settings →
# Added Models and must render immediately; explicit # Added Models and must render immediately; explicit
# Refresh/Probe endpoints do the network work. # Refresh/Probe endpoints do the network work.
status = "online" if (all_models or pinned) else ("empty" if r.is_enabled else "offline")
ping = None ping = None
base = _normalize_base(r.base_url) base = _normalize_base(r.base_url)
kind = _effective_endpoint_kind(r, base) kind = _effective_endpoint_kind(r, base)
visible, pinned = _picker_models_for_endpoint(r, base, kind)
if _picker_requires_pinning(base, kind) and pinned and not _has_explicit_pinned_models(r):
r.pinned_models = json.dumps(pinned)
upgraded_legacy_pins = True
model_inventory_count = len(_merge_model_ids(all_models, pinned))
picker_requires_pinning = _picker_requires_pinning(base, kind)
status = "online" if (all_models or visible or pinned) else ("empty" if r.is_enabled else "offline")
results.append({ results.append({
"id": r.id, "id": r.id,
"name": r.name, "name": r.name,
@@ -1799,6 +2041,8 @@ def setup_model_routes(model_discovery):
"api_key_fingerprint": _api_key_fingerprint(r.api_key), "api_key_fingerprint": _api_key_fingerprint(r.api_key),
"is_enabled": r.is_enabled, "is_enabled": r.is_enabled,
"models": visible, "models": visible,
"model_count": model_inventory_count,
"picker_requires_pinning": picker_requires_pinning,
"pinned_models": pinned, "pinned_models": pinned,
"hidden_count": len(hidden), "hidden_count": len(hidden),
"online": status != "offline", "online": status != "offline",
@@ -1812,6 +2056,9 @@ def setup_model_routes(model_discovery):
"model_refresh_interval": getattr(r, "model_refresh_interval", None), "model_refresh_interval": getattr(r, "model_refresh_interval", None),
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None), "model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
}) })
if upgraded_legacy_pins:
db.commit()
_invalidate_models_cache()
return results return results
finally: finally:
db.close() db.close()
@@ -1854,7 +2101,7 @@ def setup_model_routes(model_discovery):
name = base_url.replace("http://", "").replace("https://", "").split("/")[0] name = base_url.replace("http://", "").replace("https://", "").split("/")[0]
requested_kind = _normalize_endpoint_kind(endpoint_kind) requested_kind = _normalize_endpoint_kind(endpoint_kind)
refresh_mode = _normalize_refresh_mode(model_refresh_mode, requested_kind) refresh_mode = _normalize_endpoint_refresh_mode(model_refresh_mode, requested_kind, base_url)
refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400) refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400)
refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60) refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
require_model_list = _truthy(require_models) require_model_list = _truthy(require_models)
@@ -1915,6 +2162,10 @@ def setup_model_routes(model_discovery):
if refresh_timeout is not None: if refresh_timeout is not None:
existing.model_refresh_timeout = refresh_timeout existing.model_refresh_timeout = refresh_timeout
changed = True changed = True
incoming_model_type = (model_type or "").strip() or "llm"
if incoming_model_type and (getattr(existing, "model_type", None) or "llm") != incoming_model_type:
existing.model_type = incoming_model_type
changed = True
if api_key.strip() and not existing.api_key: if api_key.strip() and not existing.api_key:
existing.api_key = api_key.strip() existing.api_key = api_key.strip()
changed = True changed = True
@@ -2147,9 +2398,10 @@ def setup_model_routes(model_discovery):
raise HTTPException(404, "Endpoint not found") raise HTTPException(404, "Endpoint not found")
hidden = _hidden_model_ids(ep) hidden = _hidden_model_ids(ep)
all_models = _cached_model_ids(ep) all_models = _cached_model_ids(ep)
if refresh:
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base) kind = _effective_endpoint_kind(ep, base)
picker_requires_pinning = _picker_requires_pinning(base, kind)
if refresh:
category = _classify_endpoint(base, kind) category = _classify_endpoint(base, kind)
timeout = _manual_refresh_timeout(ep, category, refresh_timeout) timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
try: try:
@@ -2167,7 +2419,7 @@ def setup_model_routes(model_discovery):
else: else:
response.headers["X-Model-Refresh-Status"] = "failed" response.headers["X-Model-Refresh-Status"] = "failed"
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models." response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None)) _, pinned = _picker_models_for_endpoint(ep, base, kind)
pinned_set = set(pinned) pinned_set = set(pinned)
return [ return [
{ {
@@ -2175,6 +2427,7 @@ def setup_model_routes(model_discovery):
"display": m.split("/")[-1], "display": m.split("/")[-1],
"is_hidden": m in hidden, "is_hidden": m in hidden,
"is_pinned": m in pinned_set, "is_pinned": m in pinned_set,
"picker_requires_pinning": picker_requires_pinning,
} }
for m in _merge_model_ids(all_models, pinned) for m in _merge_model_ids(all_models, pinned)
] ]
@@ -2203,10 +2456,27 @@ def setup_model_routes(model_discovery):
hidden = body.get("hidden") hidden = body.get("hidden")
if not isinstance(hidden, list): if not isinstance(hidden, list):
raise HTTPException(400, "hidden must be a list of model IDs") raise HTTPException(400, "hidden must be a list of model IDs")
base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base)
if _picker_requires_pinning(base, kind):
# Compatibility for older/admin UI paths that still submit
# the previous hide-list shape. API pickers are allow-lists:
# convert "unchecked models" into an explicit pinned list so
# Settings summary, /api/models, and chat agree.
selected = _visible_models(_cached_model_ids(ep), hidden, None)
ep.pinned_models = json.dumps(selected)
ep.hidden_models = None
else:
ep.hidden_models = json.dumps(hidden) if hidden else None ep.hidden_models = json.dumps(hidden) if hidden else None
# Accept either "pinned" or "pinned_models" for the manual IDs list. # Accept either "pinned" or "pinned_models" for the manual IDs list.
if "pinned_models" in body or "pinned" in body: if "pinned_models" in body or "pinned" in body:
pinned = _normalize_model_ids(body.get("pinned_models", body.get("pinned"))) pinned = _normalize_model_ids(body.get("pinned_models", body.get("pinned")))
base = _normalize_base(ep.base_url)
kind = _effective_endpoint_kind(ep, base)
if _picker_requires_pinning(base, kind):
ep.pinned_models = json.dumps(pinned)
ep.hidden_models = None
else:
ep.pinned_models = json.dumps(pinned) if pinned else None ep.pinned_models = json.dumps(pinned) if pinned else None
db.commit() db.commit()
_invalidate_models_cache() _invalidate_models_cache()
@@ -2226,11 +2496,11 @@ def setup_model_routes(model_discovery):
# no per-user default yet, we resolve via the owner-scoped endpoint # no per-user default yet, we resolve via the owner-scoped endpoint
# lookup below (last-resort: first enabled endpoint THIS user owns). # lookup below (last-resort: first enabled endpoint THIS user owns).
# Unauthenticated single-user mode keeps the old behavior. # Unauthenticated single-user mode keeps the old behavior.
from src.auth_helpers import get_current_user as _gcu # Resolve through the same owner/scope gate as the model picker. In an
try: # auth-disabled process there is no middleware to stamp token state, so
_user = _gcu(request) or "" # raw bearer detection must still prevent a token from resolving
except Exception: # global/admin defaults.
_user = "" _user = require_chat_scope(request) or ""
# Admins resolve via the global defaults (they own them, and the # Admins resolve via the global defaults (they own them, and the
# scoped resolution was making the picker disappear for them). # scoped resolution was making the picker disappear for them).
# Regular users get per-user prefs with NO global fallback for the # Regular users get per-user prefs with NO global fallback for the
@@ -2240,7 +2510,12 @@ def setup_model_routes(model_discovery):
_is_admin = False _is_admin = False
try: try:
auth_mgr = getattr(request.app.state, "auth_manager", None) 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)) _is_admin = bool(auth_mgr.is_admin(_user))
except Exception: except Exception:
_is_admin = False _is_admin = False
@@ -2249,7 +2524,6 @@ def setup_model_routes(model_discovery):
_user_prefs = _load_for_user(_user) or {} _user_prefs = _load_for_user(_user) or {}
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip() ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
model = (_user_prefs.get("default_model") or "").strip() model = (_user_prefs.get("default_model") or "").strip()
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
# If user has no personal default, fall back to global default # If user has no personal default, fall back to global default
# But only based on the "share_defaults_with_users" flag # But only based on the "share_defaults_with_users" flag
# (only if share_defaults_with_users is enabled) # (only if share_defaults_with_users is enabled)
@@ -2258,12 +2532,9 @@ def setup_model_routes(model_discovery):
ep_id = settings.get("default_endpoint_id", "") ep_id = settings.get("default_endpoint_id", "")
if not model: if not model:
model = settings.get("default_model", "") model = settings.get("default_model", "")
if not _fallbacks:
_fallbacks = settings.get("default_model_fallbacks") or []
else: else:
ep_id = settings.get("default_endpoint_id", "") ep_id = settings.get("default_endpoint_id", "")
model = settings.get("default_model", "") model = settings.get("default_model", "")
_fallbacks = settings.get("default_model_fallbacks") or []
db = SessionLocal() db = SessionLocal()
try: try:
ep = None ep = None
@@ -2278,33 +2549,6 @@ def setup_model_routes(model_discovery):
if _user and not _is_admin: if _user and not _is_admin:
ep_q = owner_filter(ep_q, ModelEndpoint, _user) ep_q = owner_filter(ep_q, ModelEndpoint, _user)
ep = ep_q.first() ep = ep_q.first()
# Configured fallback chain — when the chosen default endpoint is
# gone/disabled, honor the user's configured `default_model_fallbacks`
# in order BEFORE arbitrarily grabbing the first enabled endpoint.
# (Previously this jumped straight to "first enabled", which is why
# deleting/changing the main endpoint silently reassigned the default
# chat to some unrelated endpoint instead of the fallback.)
if not ep:
for entry in _fallbacks:
if not isinstance(entry, dict):
continue
fid = (entry.get("endpoint_id") or "").strip()
if not fid:
continue
cand_q = db.query(ModelEndpoint).filter(
ModelEndpoint.id == fid, ModelEndpoint.is_enabled == True
)
if _user and not _is_admin:
cand_q = owner_filter(cand_q, ModelEndpoint, _user)
cand = cand_q.first()
if cand:
ep = cand
# Use the fallback entry's model. Reset even when empty
# so we don't carry the prior endpoint's stale model onto
# this fallback — the cached-models lookup below then
# fills it from the fallback endpoint.
model = (entry.get("model") or "").strip()
break
# Last resort: first enabled endpoint owned by THIS user. Do not # Last resort: first enabled endpoint owned by THIS user. Do not
# include null-owner/shared endpoints here: a brand-new user with # include null-owner/shared endpoints here: a brand-new user with
# no explicit default should not auto-open a pending chat using an # no explicit default should not auto-open a pending chat using an
@@ -2319,7 +2563,13 @@ def setup_model_routes(model_discovery):
return {"endpoint_id": "", "endpoint_url": "", "model": ""} return {"endpoint_id": "", "endpoint_url": "", "model": ""}
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
chat_url = build_chat_url(base) 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: try:
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None)) visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
if visible: if visible:
@@ -2360,11 +2610,21 @@ def setup_model_routes(model_discovery):
ep.model_type = body["model_type"].strip() or ep.model_type ep.model_type = body["model_type"].strip() or ep.model_type
if "pinned_models" in body: if "pinned_models" in body:
_pinned = _normalize_model_ids(body["pinned_models"]) _pinned = _normalize_model_ids(body["pinned_models"])
_base_for_pins = _normalize_base(ep.base_url)
_kind_for_pins = _effective_endpoint_kind(ep, _base_for_pins)
if _picker_requires_pinning(_base_for_pins, _kind_for_pins):
ep.pinned_models = json.dumps(_pinned)
ep.hidden_models = None
else:
ep.pinned_models = json.dumps(_pinned) if _pinned else None ep.pinned_models = json.dumps(_pinned) if _pinned else None
if "endpoint_kind" in body: if "endpoint_kind" in body:
ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind")) ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind"))
if "model_refresh_mode" in body: if "model_refresh_mode" in body:
ep.model_refresh_mode = _normalize_refresh_mode(body.get("model_refresh_mode"), _endpoint_kind(ep)) ep.model_refresh_mode = _normalize_endpoint_refresh_mode(
body.get("model_refresh_mode"),
_endpoint_kind(ep),
ep.base_url,
)
if "model_refresh_interval" in body: if "model_refresh_interval" in body:
interval = _parse_positive_int(body.get("model_refresh_interval"), minimum=30, maximum=86400) interval = _parse_positive_int(body.get("model_refresh_interval"), minimum=30, maximum=86400)
ep.model_refresh_interval = interval ep.model_refresh_interval = interval
@@ -2520,8 +2780,13 @@ def setup_model_routes(model_discovery):
# ── Tool management ── # ── Tool management ──
@router.get("/tools") @router.get("/tools")
def list_tools(): def list_tools(request: Request):
"""List all available tools with their enabled/disabled status.""" """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 from src.agent_tools import TOOL_TAGS
settings = _load_settings() settings = _load_settings()
disabled = set(settings.get("disabled_tools", [])) disabled = set(settings.get("disabled_tools", []))
+5
View File
@@ -0,0 +1,5 @@
"""Note route domain package (slice 2f, #4082/#4071).
Contains note_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/note_routes.py re-exports from here.
"""
+937
View File
@@ -0,0 +1,937 @@
# routes/note_routes.py
"""Google Keep-style notes / checklists API."""
import json
import uuid
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from core.database import SessionLocal, Note
from core.middleware import INTERNAL_TOOL_USER
from src.auth_helpers import require_user
from src.constants import DATA_DIR
from src.upload_handler import reserve_upload_references
from sqlalchemy.orm.attributes import flag_modified
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class NoteCreate(BaseModel):
title: str = ""
content: Optional[str] = None
items: Optional[list] = None
note_type: str = "note"
color: Optional[str] = None
label: Optional[str] = None
pinned: bool = False
due_date: Optional[str] = None
source: str = "user"
session_id: Optional[str] = None
image_url: Optional[str] = None
repeat: Optional[str] = "none"
sort_order: Optional[int] = None
class NoteUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
items: Optional[list] = None
note_type: Optional[str] = None
color: Optional[str] = None
label: Optional[str] = None
pinned: Optional[bool] = None
archived: Optional[bool] = None
due_date: Optional[str] = None
image_url: Optional[str] = None
repeat: Optional[str] = None
sort_order: Optional[int] = None
agent_session_id: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _note_to_dict(note: Note) -> Dict[str, Any]:
items = None
if note.items:
try:
items = json.loads(note.items)
except (json.JSONDecodeError, TypeError):
items = None
ai_cls = None
raw_ai = getattr(note, "ai_classification", None)
if raw_ai:
try:
ai_cls = json.loads(raw_ai)
except (json.JSONDecodeError, TypeError):
ai_cls = None
return {
"id": note.id,
"owner": note.owner,
"title": note.title,
"content": note.content,
"items": items,
"note_type": note.note_type,
"color": note.color,
"label": note.label,
"pinned": note.pinned,
"archived": note.archived,
"due_date": note.due_date,
"source": note.source,
"session_id": note.session_id,
"sort_order": note.sort_order or 0,
"image_url": note.image_url,
"repeat": note.repeat or "none",
"ai_classification": ai_cls,
"ai_content_hash": getattr(note, "ai_content_hash", None),
"agent_session_id": getattr(note, "agent_session_id", None),
"created_at": note.created_at.isoformat() if note.created_at else None,
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
}
def _reminder_text_from_note(note: Note) -> tuple[str, str]:
"""Return the reminder title/body from a stored note row."""
title = (note.title or "Note reminder").strip() or "Note reminder"
if note.items:
try:
items = json.loads(note.items)
except (json.JSONDecodeError, TypeError):
items = None
if isinstance(items, list):
pending: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
if item.get("done") or item.get("checked"):
continue
text = str(item.get("text") or "").strip()
if text:
pending.append(text)
if pending:
shown = "\n".join(f"- {text}" for text in pending[:8])
extra = f"\n...and {len(pending) - 8} more" if len(pending) > 8 else ""
return title, f"Pending ({len(pending)}):\n{shown}{extra}"
return title, f"{len(items)} item{'s' if len(items) != 1 else ''}"
return title, (note.content or "").strip()[:400]
# ---------------------------------------------------------------------------
# Reminder dispatch — module-level so background tasks (built-in actions)
# can call it directly without an HTTP roundtrip + auth cookie. The route
# version below is a thin wrapper that pulls `owner` from the request.
# ---------------------------------------------------------------------------
# Scheduler reference — set by setup_note_routes() so dispatch_reminder can
# push a parallel in-app notification (frontend polls the scheduler's queue
# and fires real browser Notification(...) popups). Optional; works without it.
_scheduler_ref = None
async def dispatch_reminder(
title: str,
note_body: str,
note_id: str,
owner: str = "",
queue_browser: bool = True,
settings_override: dict | None = None,
) -> dict:
"""Fire a reminder via the configured channel (browser/email/ntfy/webhook).
Args:
title: short headline shown to the user
note_body: longer body text
note_id: stable id (used as tag/dedupe in browser notifications)
owner: the user this reminder belongs to — scopes SMTP config to
their account so we don't cross-leak credentials
Returns: {synthesis, email_sent, ntfy_sent}. Browser channel is wired via
the in-memory notification queue picked up by the frontend poller, so
nothing is "sent" synchronously for it — the channel just routes there.
"""
from src.settings import load_settings
settings = {**load_settings(), **(settings_override or {})}
channel = settings.get("reminder_channel", "browser")
llm_on = bool(settings.get("reminder_llm_synthesis", False))
title = (title or "").strip()
note_body = (note_body or "").strip()
cache_key = str(note_id) if note_id else ""
cache = {}
cache_path = None
if cache_key:
try:
import json as _json
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
from pathlib import Path as _P
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
cache_path = _P(DATA_DIR) / f"note_pings_{_slug}.json"
if cache_path.exists():
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
last = cache.get(cache_key)
if last:
last_channel = None
if isinstance(last, dict):
last_channel = last.get("channel")
last = last.get("at")
last_dt = _dt.fromisoformat(str(last))
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=_tz.utc)
# Legacy cache values were plain timestamps and could be
# written by the frontend even when the email/ntfy send failed.
# Treat those as browser-only dedupe so email reminders can be
# retried by the backend scanner after a failed frontend path.
should_skip = last_dt >= _dt.now(_tz.utc) - _td(minutes=25)
if should_skip and channel in ("email", "ntfy", "webhook"):
should_skip = last_channel == channel
if should_skip:
return {
"synthesis": None,
"email_sent": False,
"ntfy_sent": False,
"webhook_sent": False,
"browser_sent": True,
"skipped": True,
}
except Exception as _e:
logger.debug(f"dispatch_reminder: cache read failed: {_e}")
synthesis = None
_SYNTH_FAILED_TAG = "[utility model unavailable — no summary generated]"
if llm_on:
try:
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
from src.reminder_personas import synthesis_system_prompt
url, model, headers = resolve_endpoint("utility", owner=owner or None)
if not url:
url, model, headers = resolve_endpoint("default", owner=owner or None)
if url and model:
persona_id = (settings.get("reminder_llm_persona") or "").strip()
sys_prompt = synthesis_system_prompt(persona_id)
raw = await llm_call_async(
url=url, model=model,
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"Title: {title}\n\n{note_body}".strip()},
],
temperature=0.7, max_tokens=200, headers=headers, timeout=30,
)
from src.text_helpers import strip_think as _strip_think
# prose=True strips untagged "The user wants me to…" chain-of-thought.
# prompt_echo=True strips Qwen-style "Thinking Process:" / leaked
# prompt prefixes. Both are safe here because this is a
# one-sentence LLM-only output, not user-pasted content.
synthesis = _strip_think(raw or "", prose=True, prompt_echo=True)
# Reminder synthesis is supposed to be ONE sentence. Strip-think's
# paragraph-based heuristic misses cases where the model puts
# reasoning + answer on consecutive lines inside one paragraph
# (e.g. "I should write... [\n] You have one task waiting...").
# Walk lines, drop reasoning/prompt-echo lines, then keep the
# last surviving line — that's the actual warm sentence.
if synthesis:
import re as _re
# Tightened: target ACTUAL self-talk (model narrating what
# it'll do) rather than any first-person sentence. The old
# pattern killed legit warm sentences like "I'll see you
# tomorrow" or "I should be done by then". New rules:
# • "I (need|should|have|'ll|will) (write|draft|reply|…)"
# only matches when followed by a TASK verb taking an
# OBJECT (so first-person + intransitive verb passes).
# • Self-instructional patterns the model emits verbatim:
# "I should write something that reminds them…",
# "I need to draft…", "Let me think…".
# • Explicit instructions echoed back from the prompt:
# "Keep it under 25 words", "No greetings".
_reasoning = _re.compile(
r"^\s*(?:"
# "I should write/draft/compose…" with a task-object follow
r"i (?:need|should|have|'ll|will|am going|am)\s+to\s+"
r"(?:write|draft|compose|craft|generate|produce|create|"
r"summarize|answer|provide|note|address|remind|output)"
r"\s+(?:a |an |the |something|this|that|here|them|him|her|"
r"you|user|reply|response|sentence|message|line|warm)|"
# The model literally narrating about the user
r"the user (?:wants|is asking|asks|needs|wrote|said|requested) (?:me )?(?:to|for|that|about|something)|"
# "Let me [think/write/draft/…] (about/for/the …)"
r"let me (?:think|write|draft|consider|note|see|check)\b\s+(?:about|for|the|this|that|if|whether)|"
# "Looking at the/this/that …"
r"looking at (?:the|this|that)\b|"
# "Based on the/this/what …"
r"based on (?:the|this|what|context|that)\b|"
# Prompt-echo of length / style instructions
r"keep it under \d+ words\b|"
r"(?:no greetings|no preamble|no hashtags|just output the)\b"
r").*",
_re.IGNORECASE,
)
# Echo of the prompt's "Pending:" / "<N> pending" tail.
_echo = _re.compile(
r"^\s*(?:pending\s*[:.]|(?:\d+|one|two|three|four|five)\s+pending\b)",
_re.IGNORECASE,
)
lines = [ln for ln in synthesis.splitlines() if ln.strip()]
cleaned = [ln for ln in lines if not _reasoning.match(ln) and not _echo.match(ln)]
if cleaned:
# The model's actual answer is normally the LAST surviving
# line — reasoning leads, answer trails.
synthesis = cleaned[-1].strip()
else:
synthesis = _SYNTH_FAILED_TAG
except Exception as e:
logger.warning(f"Reminder LLM synthesis failed: {e}")
synthesis = _SYNTH_FAILED_TAG
if synthesis:
_s = synthesis.strip(); _low = _s.lower()
if (not _s or _low.startswith("error:") or _low.startswith("[error")
or "operation failed" in _low
or ("upstream" in _low and "failed" in _low)) and synthesis != _SYNTH_FAILED_TAG:
logger.warning(f"Reminder synthesis looked like an error, replacing: {_s[:120]!r}")
synthesis = _SYNTH_FAILED_TAG
email_sent = False
email_error = ""
if channel == "email":
try:
from routes.email_routes import _get_email_config, _smtp_ready
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime as _dt
# `reminder_email_account_id` lets the user pick WHICH email
# account to send reminders from (when they have several
# configured in Integrations). Falls back to the default
# account when no explicit choice is saved.
_acc_id = (settings.get("reminder_email_account_id") or "").strip() or None
cfg = _get_email_config(account_id=_acc_id, owner=owner or "")
if not _smtp_ready(cfg):
try:
from core.database import SessionLocal as _SL, EmailAccount as _EA
from sqlalchemy import and_, or_
db = _SL()
try:
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
if owner:
unowned = or_(_EA.owner == None, _EA.owner == "") # noqa: E711
same_mailbox = or_(_EA.imap_user == owner, _EA.from_address == owner)
q = q.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox)))
for row in q.order_by(_EA.is_default.desc(), _EA.created_at.asc()).all():
trial = _get_email_config(account_id=row.id, owner=owner or "")
if _smtp_ready(trial):
cfg = trial
break
finally:
db.close()
except Exception as _fallback_error:
logger.debug(f"Reminder SMTP fallback lookup failed: {_fallback_error}")
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
recipient = (settings.get("reminder_email_to") or "").strip() or from_addr
# Loud diagnostic so we can see WHY a reminder didn't send (the
# previous "silently no-op when cfg has no smtp_host" was invisible).
logger.info(
"dispatch_reminder[email] note_id=%s owner=%r "
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
note_id, owner,
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
bool(from_addr), bool(recipient),
)
missing = []
if not cfg.get("smtp_host"):
missing.append("SMTP host")
if not cfg.get("smtp_user"):
missing.append("SMTP user")
if not (cfg.get("smtp_password") or cfg.get("oauth_provider")):
missing.append("SMTP credentials")
if not from_addr:
missing.append("from address")
if not recipient:
missing.append("recipient")
if missing:
email_error = "Missing " + ", ".join(missing)
logger.warning(
"Reminder email not sent for note_id=%s account=%r: %s",
note_id, cfg.get("account_name"), email_error,
)
else:
msg = MIMEMultipart("alternative")
msg["From"] = from_addr
msg["To"] = recipient
_t = title or 'Note'
_t = _t[len('Reminder:'):].strip() if _t.lower().startswith('reminder:') else _t
msg["Subject"] = f"Reminder (Odysseus): {_t}"
msg["Date"] = _dt.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
msg["X-Odysseus-Origin"] = "odysseus-ui"
msg["X-Odysseus-Kind"] = "reminder"
msg["X-Odysseus-Ref"] = str(note_id)
# Body shape: synthesis (warm sentence) → blank line → bold
# title header → note details. The title was previously only
# in the subject line, so the email read like a faceless
# to-do list with no anchor to which note triggered it.
_body_chunks = []
if synthesis:
_body_chunks.append(synthesis)
if _t:
_body_chunks.append(_t)
if note_body:
_body_chunks.append(note_body)
plain = "\n\n".join(_body_chunks) if _body_chunks else title
msg.attach(MIMEText(plain, "plain", "utf-8"))
def _smtp_send():
from routes.email_helpers import _send_smtp_message
_send_smtp_message(cfg, from_addr, [recipient], msg.as_string())
import asyncio as _aio
await _aio.to_thread(_smtp_send)
email_sent = True
except Exception as e:
email_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder email send failed: {e}")
webhook_sent = False
webhook_error = ""
if channel == "webhook":
try:
import httpx
import json as _wjson
from src.integrations import load_integrations
# Built-in payload defaults for known presets so users don't have
# to configure a template just to use a standard service.
_PRESET_TEMPLATE_DEFAULTS = {
"discord_webhook": '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}',
}
intg_id = settings.get("reminder_webhook_integration_id", "").strip()
template = settings.get("reminder_webhook_payload_template", "").strip()
if not intg_id:
webhook_error = "No webhook integration selected"
else:
intg = next(
(i for i in load_integrations()
if i.get("id") == intg_id and i.get("base_url")),
None,
)
if not intg:
webhook_error = f"Integration {intg_id!r} not found or missing base URL"
else:
# Fall back to a built-in default for known presets so
# users don't have to configure a template for standard
# services like Discord.
if not template:
template = _PRESET_TEMPLATE_DEFAULTS.get(intg.get("preset", ""), "")
if not template:
webhook_error = "No payload template configured"
else:
# Render template: JSON-escape the values so the result
# is always valid JSON regardless of special characters.
# dumps() returns `"value"` — strip outer quotes.
msg = (synthesis or note_body or title or "Reminder")[:4000]
_t = _wjson.dumps(title or "Reminder")[1:-1]
_m = _wjson.dumps(msg)[1:-1]
rendered = template.replace("{{title}}", _t).replace("{{message}}", _m)
hdrs = {"Content-Type": "application/json"}
api_key = intg.get("api_key", "")
auth_type = (intg.get("auth_type") or "none").lower()
if api_key:
if auth_type == "bearer":
hdrs["Authorization"] = f"Bearer {api_key}"
elif auth_type == "header":
hdrs[intg.get("auth_header") or "Authorization"] = api_key
url = intg["base_url"].rstrip("/")
# SSRF guard — matches the pattern used by webhook_routes,
# CalDAV, search, and embeddings. Blocks link-local / metadata
# addresses (169.254.x.x) by default; set
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true to also block
# RFC-1918 ranges for locked-down deployments.
import os as _os
from src.url_safety import check_outbound_url as _chk
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
_ok, _reason = _chk(url, block_private=_block)
if not _ok:
webhook_error = f"Webhook URL rejected: {_reason}"
else:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, content=rendered.encode(), headers=hdrs)
webhook_sent = resp.is_success
if not webhook_sent:
webhook_error = f"Webhook returned HTTP {resp.status_code}"
except Exception as e:
webhook_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder webhook send failed: {e}")
ntfy_sent = False
ntfy_error = ""
if channel == "ntfy":
try:
from src.integrations import load_integrations
import httpx
intg = next(
(i for i in load_integrations()
if i.get("preset") == "ntfy" and i.get("enabled", True) and i.get("base_url")),
None,
)
if intg:
base = intg["base_url"].rstrip("/")
topic = settings.get("reminder_ntfy_topic") or "reminders"
ntfy_body = synthesis or note_body or title
# ntfy Title is an ASCII HTTP header; sanitize Unicode and cap its length.
_clean_title = (title or "Reminder").encode("ascii", "replace").decode("ascii")[:200]
hdrs = {"Title": _clean_title, "Priority": "high", "Tags": "bell"}
api_key = intg.get("api_key", "")
if api_key:
hdrs["Authorization"] = f"Bearer {api_key}"
# SSRF guard — same check (and env knob) as the webhook branch
# above: link-local / metadata addresses are always rejected;
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true also blocks RFC-1918
# so a ntfy base_url can't be pointed at internal services.
import os as _os
from src.url_safety import check_outbound_url as _chk
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
_ok, _reason = _chk(f"{base}/{topic}", block_private=_block)
if not _ok:
ntfy_error = f"ntfy URL rejected: {_reason}"
else:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
ntfy_sent = resp.is_success
if not ntfy_sent:
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
else:
ntfy_error = "No enabled ntfy integration"
except Exception as e:
ntfy_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder ntfy send failed: {e}")
# In-app browser notification ALWAYS fires (regardless of channel). The
# frontend polls `/api/tasks/notifications` and turns any entry with a
# `body` into a real `Notification(...)` — same surface as task-success
# popups. Lets the user see reminders inside the app even when the
# primary channel is email/ntfy and the tab is open.
browser_sent = False
local_browser_sent = (not queue_browser and channel == "browser")
if queue_browser and _scheduler_ref is not None:
try:
_scheduler_ref.add_notification(
task_name=title or "Reminder",
status="success",
task_id=f"reminder-{note_id}",
owner=owner or None,
body=(synthesis or note_body or title or "").strip()[:500] or "Reminder",
)
browser_sent = True
except Exception as _e:
logger.debug(f"dispatch_reminder: in-app notif push failed: {_e}")
# Dedupe across paths: write to the same cache file `action_ping_notes`
# reads, so the background scanner's REPING_MIN window suppresses a
# second send for the same note within 25 min. Without this, a note
# whose due_date fires while the user has the app open got TWO emails
# (frontend-fired here + background-fired by ping_notes 0–5 min later).
if (email_sent or ntfy_sent or webhook_sent or browser_sent or local_browser_sent) and note_id:
try:
import json as _json
from datetime import datetime as _dt, timezone as _tz
from pathlib import Path as _P
# Per-owner cache so the scanner's prune step on user A's run
# doesn't drop user B's just-fired entry (review C4).
_STATE = cache_path
if _STATE is None:
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
_STATE = _P(DATA_DIR) / f"note_pings_{_slug}.json"
_STATE.parent.mkdir(parents=True, exist_ok=True)
try:
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
except Exception:
_cache = {}
sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "webhook" if webhook_sent else "browser"
_cache[cache_key or str(note_id)] = {
"at": _dt.now(_tz.utc).isoformat(),
"channel": sent_channel,
}
_STATE.write_text(_json.dumps(_cache), encoding="utf-8")
except Exception as _e:
logger.debug(f"dispatch_reminder: cache write failed: {_e}")
return {
"channel": channel,
"synthesis": synthesis,
"email_sent": email_sent,
"email_error": email_error,
"ntfy_sent": ntfy_sent,
"ntfy_error": ntfy_error,
"webhook_sent": webhook_sent,
"webhook_error": webhook_error,
"browser_sent": browser_sent or local_browser_sent,
}
# ---------------------------------------------------------------------------
# Router factory
# ---------------------------------------------------------------------------
def setup_note_routes(task_scheduler=None, upload_handler=None):
# Expose the scheduler to module-level `dispatch_reminder` so reminders
# can also push to the in-app notification queue (the polling system
# turns each entry into a real browser Notification + the existing
# tasks-tab badge / dot system).
global _scheduler_ref
_scheduler_ref = task_scheduler
router = APIRouter(prefix="/api/notes", tags=["notes"])
def _owner(request: Request) -> Optional[str]:
# require_user, not bare get_current_user: a request that reaches
# these owner-scoped routes with NO identity (auth-middleware
# regression, SSRF from a sibling service) must fail closed (401)
# when auth is configured — not be treated as the single-user mode
# and handed blanket access to every account's notes. The documented
# anonymous modes (AUTH_ENABLED=false, LOCALHOST_BYPASS on loopback,
# unconfigured first-run) still resolve to None, the single-user
# path. fire_reminder below already gated this way; the CRUD routes
# did not.
return require_user(request) or None
def _reserve_note_uploads(owner: Optional[str], *values) -> None:
missing_id = reserve_upload_references(upload_handler, owner, *values)
if missing_id:
raise HTTPException(409, f"Referenced upload is no longer available: {missing_id}")
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
if user == INTERNAL_TOOL_USER:
return True
if not user:
# require_user() already admitted this request, which only happens
# for auth-disabled, loopback-bypass, or unconfigured single-user
# modes. There is no separate non-admin account boundary there.
return True
try:
from core.auth import AuthManager
auth_mgr = getattr(request.app.state, "auth_manager", None) or AuthManager()
if not getattr(auth_mgr, "is_configured", True):
return True
return bool(auth_mgr.is_admin(user))
except Exception:
return False
# --- LIST ---
@router.get("")
def list_notes(
request: Request,
archived: Optional[bool] = None,
label: Optional[str] = None,
):
user = _owner(request)
db = SessionLocal()
try:
q = db.query(Note)
if user is not None:
q = q.filter(Note.owner == user)
if archived is not None:
q = q.filter(Note.archived == archived)
else:
q = q.filter(Note.archived == False)
if label:
q = q.filter(Note.label == label)
# Archived view: most recently archived first. Active view: pin + manual order.
if archived is True:
notes = q.order_by(Note.updated_at.desc()).all()
else:
notes = q.order_by(Note.pinned.desc(), Note.sort_order.asc(), Note.updated_at.desc()).all()
return {"notes": [_note_to_dict(n) for n in notes]}
finally:
db.close()
# --- CREATE ---
@router.post("")
def create_note(request: Request, body: NoteCreate):
user = _owner(request)
_reserve_note_uploads(
user,
body.image_url,
body.color,
body.content,
json.dumps(body.items) if body.items is not None else None,
)
db = SessionLocal()
try:
note = Note(
id=str(uuid.uuid4()),
owner=user,
title=body.title,
content=body.content,
items=json.dumps(body.items) if body.items is not None else None,
note_type=body.note_type,
color=body.color,
label=body.label,
pinned=body.pinned,
due_date=body.due_date,
source=body.source,
session_id=body.session_id,
image_url=body.image_url,
repeat=body.repeat or "none",
sort_order=body.sort_order if body.sort_order is not None else 0,
)
db.add(note)
db.commit()
db.refresh(note)
return _note_to_dict(note)
finally:
db.close()
# --- GET ONE ---
@router.get("/{note_id}")
def get_note(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
return _note_to_dict(note)
finally:
db.close()
# --- UPDATE ---
@router.put("/{note_id}")
def update_note(request: Request, note_id: str, body: NoteUpdate):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
_reserve_note_uploads(
user,
body.image_url,
body.color,
body.content,
json.dumps(body.items) if body.items is not None else None,
)
if body.title is not None:
note.title = body.title
if body.content is not None:
note.content = body.content
if body.items is not None:
note.items = json.dumps(body.items)
flag_modified(note, "items")
if body.note_type is not None:
note.note_type = body.note_type
if body.color is not None:
note.color = body.color
if body.label is not None:
note.label = body.label
if body.pinned is not None:
note.pinned = body.pinned
if body.archived is not None:
note.archived = body.archived
if body.due_date is not None:
note.due_date = body.due_date
if body.image_url is not None:
note.image_url = body.image_url
if body.repeat is not None:
note.repeat = body.repeat
if body.sort_order is not None:
note.sort_order = body.sort_order
if body.agent_session_id is not None:
note.agent_session_id = body.agent_session_id
db.commit()
db.refresh(note)
return _note_to_dict(note)
finally:
db.close()
# --- DELETE ---
@router.delete("/{note_id}")
def delete_note(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
db.delete(note)
db.commit()
return {"ok": True}
finally:
db.close()
# --- TOGGLE PIN ---
@router.post("/{note_id}/pin")
def toggle_pin(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
note.pinned = not note.pinned
db.commit()
return {"ok": True, "pinned": note.pinned}
finally:
db.close()
# --- TOGGLE ARCHIVE ---
@router.post("/{note_id}/archive")
def toggle_archive(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
note.archived = not note.archived
db.commit()
return {"ok": True, "archived": note.archived}
finally:
db.close()
# --- TOGGLE CHECKLIST ITEM ---
@router.post("/{note_id}/items/{index}/toggle")
def toggle_item(request: Request, note_id: str, index: int):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
if not note.items:
raise HTTPException(400, "Note has no checklist items")
items = json.loads(note.items)
if index < 0 or index >= len(items):
raise HTTPException(400, f"Item index {index} out of range")
items[index]["done"] = not items[index].get("done", False)
note.items = json.dumps(items)
flag_modified(note, "items")
db.commit()
return {"ok": True, "items": items}
finally:
db.close()
# --- FIRE REMINDER ---
@router.post("/fire-reminder")
async def fire_reminder(request: Request):
"""Dispatch a reminder according to user settings.
Called by the frontend when a reminder fires. Optionally generates an
LLM synthesis line and/or sends an email through configured SMTP.
Returns {synthesis, email_sent}.
"""
# Gate against anonymous callers — LLM synthesis can burn tokens.
user = require_user(request)
body = await request.json()
note_id = str(body.get("note_id") or "").strip()
if not note_id:
raise HTTPException(400, "note_id required")
caller = _owner(request)
is_test = note_id.startswith("test-")
is_admin = _is_admin_or_single_user(request, user or caller)
_override: dict = {}
if is_test:
if not is_admin:
raise HTTPException(403, "Admin only")
title = (body.get("title") or "Test Reminder").strip() or "Test Reminder"
note_body = (body.get("body") or "").strip()
# Optional overrides let the admin settings test button pass the
# current UI values directly so it never races a pending save.
if body.get("channel"):
_override["reminder_channel"] = body["channel"]
if body.get("webhook_integration_id"):
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
if body.get("webhook_payload_template"):
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
# Mirror the in-UI AI Synthesis toggle + persona so the test
# actually exercises the synthesis path before/without a Save.
if "llm_synthesis" in body:
_override["reminder_llm_synthesis"] = bool(body["llm_synthesis"])
if "llm_persona" in body:
_override["reminder_llm_persona"] = str(body["llm_persona"] or "")
else:
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
if caller is not None and note.owner != caller:
raise HTTPException(404, "Note not found")
title, note_body = _reminder_text_from_note(note)
finally:
db.close()
return await dispatch_reminder(
title=title, note_body=note_body, note_id=note_id,
owner=caller or "",
queue_browser=False,
settings_override=_override or None,
)
# --- REORDER NOTES ---
@router.post("/reorder")
async def reorder_notes(request: Request):
"""Update sort_order for a list of note IDs in the order provided."""
user = _owner(request)
body = await request.json()
ids = body.get("ids", [])
if not isinstance(ids, list):
raise HTTPException(400, "ids must be a list")
# v2 review HIGH-12: drop the legacy `(owner == user) | (owner ==
# None)` OR which let an authenticated user silently reorder
# every legacy-null-owner note belonging to other accounts. In
# an unconfigured (single-user) auth deploy the OR is still safe
# because there's no second user to attack; we keep that branch
# explicit and gated on AuthManager.is_configured.
try:
from core.auth import AuthManager
_allow_null = not AuthManager().is_configured
except Exception:
_allow_null = False
db = SessionLocal()
try:
for i, nid in enumerate(ids):
q = db.query(Note).filter(Note.id == nid)
if user is not None:
if _allow_null:
q = q.filter((Note.owner == user) | (Note.owner == None)) # noqa: E711
else:
q = q.filter(Note.owner == user)
note = q.first()
if note:
note.sort_order = i
db.commit()
return {"ok": True, "count": len(ids)}
finally:
db.close()
return router
+14 -911
View File
@@ -1,915 +1,18 @@
# routes/note_routes.py """Backward-compat shim — canonical location is routes/note/note_routes.py.
"""Google Keep-style notes / checklists API."""
import json This module is replaced in ``sys.modules`` by the canonical module object so
import uuid that ``import routes.note_routes``, ``from routes.note_routes import X``,
import logging ``importlib.import_module("routes.note_routes")``, and the
from typing import Dict, Any, Optional ``import ... as note_routes`` + ``monkeypatch.setattr(note_routes, "SessionLocal",
...)`` pattern used by test_note_reminder_fire_scope.py /
test_notes_fail_closed_auth.py all operate on the *same* object the
application actually uses. Keeps existing import paths working after
slice 2f (#4082/#4071). Source-introspection tests read the canonical file
by path.
"""
from fastapi import APIRouter, HTTPException, Request import sys as _sys
from pydantic import BaseModel
from core.database import SessionLocal, Note from routes.note import note_routes as _canonical # noqa: F401
from core.middleware import INTERNAL_TOOL_USER
from src.auth_helpers import require_user
from src.constants import DATA_DIR
from sqlalchemy.orm.attributes import flag_modified
logger = logging.getLogger(__name__) _sys.modules[__name__] = _canonical
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class NoteCreate(BaseModel):
title: str = ""
content: Optional[str] = None
items: Optional[list] = None
note_type: str = "note"
color: Optional[str] = None
label: Optional[str] = None
pinned: bool = False
due_date: Optional[str] = None
source: str = "user"
session_id: Optional[str] = None
image_url: Optional[str] = None
repeat: Optional[str] = "none"
sort_order: Optional[int] = None
class NoteUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
items: Optional[list] = None
note_type: Optional[str] = None
color: Optional[str] = None
label: Optional[str] = None
pinned: Optional[bool] = None
archived: Optional[bool] = None
due_date: Optional[str] = None
image_url: Optional[str] = None
repeat: Optional[str] = None
sort_order: Optional[int] = None
agent_session_id: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _note_to_dict(note: Note) -> Dict[str, Any]:
items = None
if note.items:
try:
items = json.loads(note.items)
except (json.JSONDecodeError, TypeError):
items = None
ai_cls = None
raw_ai = getattr(note, "ai_classification", None)
if raw_ai:
try:
ai_cls = json.loads(raw_ai)
except (json.JSONDecodeError, TypeError):
ai_cls = None
return {
"id": note.id,
"owner": note.owner,
"title": note.title,
"content": note.content,
"items": items,
"note_type": note.note_type,
"color": note.color,
"label": note.label,
"pinned": note.pinned,
"archived": note.archived,
"due_date": note.due_date,
"source": note.source,
"session_id": note.session_id,
"sort_order": note.sort_order or 0,
"image_url": note.image_url,
"repeat": note.repeat or "none",
"ai_classification": ai_cls,
"ai_content_hash": getattr(note, "ai_content_hash", None),
"agent_session_id": getattr(note, "agent_session_id", None),
"created_at": note.created_at.isoformat() if note.created_at else None,
"updated_at": note.updated_at.isoformat() if note.updated_at else None,
}
def _reminder_text_from_note(note: Note) -> tuple[str, str]:
"""Return the reminder title/body from a stored note row."""
title = (note.title or "Note reminder").strip() or "Note reminder"
if note.items:
try:
items = json.loads(note.items)
except (json.JSONDecodeError, TypeError):
items = None
if isinstance(items, list):
pending: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
if item.get("done") or item.get("checked"):
continue
text = str(item.get("text") or "").strip()
if text:
pending.append(text)
if pending:
shown = "\n".join(f"- {text}" for text in pending[:8])
extra = f"\n...and {len(pending) - 8} more" if len(pending) > 8 else ""
return title, f"Pending ({len(pending)}):\n{shown}{extra}"
return title, f"{len(items)} item{'s' if len(items) != 1 else ''}"
return title, (note.content or "").strip()[:400]
# ---------------------------------------------------------------------------
# Reminder dispatch — module-level so background tasks (built-in actions)
# can call it directly without an HTTP roundtrip + auth cookie. The route
# version below is a thin wrapper that pulls `owner` from the request.
# ---------------------------------------------------------------------------
# Scheduler reference — set by setup_note_routes() so dispatch_reminder can
# push a parallel in-app notification (frontend polls the scheduler's queue
# and fires real browser Notification(...) popups). Optional; works without it.
_scheduler_ref = None
async def dispatch_reminder(
title: str,
note_body: str,
note_id: str,
owner: str = "",
queue_browser: bool = True,
settings_override: dict | None = None,
) -> dict:
"""Fire a reminder via the configured channel (browser/email/ntfy/webhook).
Args:
title: short headline shown to the user
note_body: longer body text
note_id: stable id (used as tag/dedupe in browser notifications)
owner: the user this reminder belongs to — scopes SMTP config to
their account so we don't cross-leak credentials
Returns: {synthesis, email_sent, ntfy_sent}. Browser channel is wired via
the in-memory notification queue picked up by the frontend poller, so
nothing is "sent" synchronously for it — the channel just routes there.
"""
from src.settings import load_settings
settings = {**load_settings(), **(settings_override or {})}
channel = settings.get("reminder_channel", "browser")
llm_on = bool(settings.get("reminder_llm_synthesis", False))
title = (title or "").strip()
note_body = (note_body or "").strip()
cache_key = str(note_id) if note_id else ""
cache = {}
cache_path = None
if cache_key:
try:
import json as _json
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
from pathlib import Path as _P
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
cache_path = _P(DATA_DIR) / f"note_pings_{_slug}.json"
if cache_path.exists():
cache = _json.loads(cache_path.read_text(encoding="utf-8"))
last = cache.get(cache_key)
if last:
last_channel = None
if isinstance(last, dict):
last_channel = last.get("channel")
last = last.get("at")
last_dt = _dt.fromisoformat(str(last))
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=_tz.utc)
# Legacy cache values were plain timestamps and could be
# written by the frontend even when the email/ntfy send failed.
# Treat those as browser-only dedupe so email reminders can be
# retried by the backend scanner after a failed frontend path.
should_skip = last_dt >= _dt.now(_tz.utc) - _td(minutes=25)
if should_skip and channel in ("email", "ntfy", "webhook"):
should_skip = last_channel == channel
if should_skip:
return {
"synthesis": None,
"email_sent": False,
"ntfy_sent": False,
"webhook_sent": False,
"browser_sent": True,
"skipped": True,
}
except Exception as _e:
logger.debug(f"dispatch_reminder: cache read failed: {_e}")
synthesis = None
_SYNTH_FAILED_TAG = "[utility model unavailable — no summary generated]"
if llm_on:
try:
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
from src.reminder_personas import synthesis_system_prompt
url, model, headers = resolve_endpoint("utility", owner=owner or None)
if not url:
url, model, headers = resolve_endpoint("default", owner=owner or None)
if url and model:
persona_id = (settings.get("reminder_llm_persona") or "").strip()
sys_prompt = synthesis_system_prompt(persona_id)
raw = await llm_call_async(
url=url, model=model,
messages=[
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"Title: {title}\n\n{note_body}".strip()},
],
temperature=0.7, max_tokens=200, headers=headers, timeout=30,
)
from src.text_helpers import strip_think as _strip_think
# prose=True strips untagged "The user wants me to…" chain-of-thought.
# prompt_echo=True strips Qwen-style "Thinking Process:" / leaked
# prompt prefixes. Both are safe here because this is a
# one-sentence LLM-only output, not user-pasted content.
synthesis = _strip_think(raw or "", prose=True, prompt_echo=True)
# Reminder synthesis is supposed to be ONE sentence. Strip-think's
# paragraph-based heuristic misses cases where the model puts
# reasoning + answer on consecutive lines inside one paragraph
# (e.g. "I should write... [\n] You have one task waiting...").
# Walk lines, drop reasoning/prompt-echo lines, then keep the
# last surviving line — that's the actual warm sentence.
if synthesis:
import re as _re
# Tightened: target ACTUAL self-talk (model narrating what
# it'll do) rather than any first-person sentence. The old
# pattern killed legit warm sentences like "I'll see you
# tomorrow" or "I should be done by then". New rules:
# • "I (need|should|have|'ll|will) (write|draft|reply|…)"
# only matches when followed by a TASK verb taking an
# OBJECT (so first-person + intransitive verb passes).
# • Self-instructional patterns the model emits verbatim:
# "I should write something that reminds them…",
# "I need to draft…", "Let me think…".
# • Explicit instructions echoed back from the prompt:
# "Keep it under 25 words", "No greetings".
_reasoning = _re.compile(
r"^\s*(?:"
# "I should write/draft/compose…" with a task-object follow
r"i (?:need|should|have|'ll|will|am going|am)\s+to\s+"
r"(?:write|draft|compose|craft|generate|produce|create|"
r"summarize|answer|provide|note|address|remind|output)"
r"\s+(?:a |an |the |something|this|that|here|them|him|her|"
r"you|user|reply|response|sentence|message|line|warm)|"
# The model literally narrating about the user
r"the user (?:wants|is asking|asks|needs|wrote|said|requested) (?:me )?(?:to|for|that|about|something)|"
# "Let me [think/write/draft/…] (about/for/the …)"
r"let me (?:think|write|draft|consider|note|see|check)\b\s+(?:about|for|the|this|that|if|whether)|"
# "Looking at the/this/that …"
r"looking at (?:the|this|that)\b|"
# "Based on the/this/what …"
r"based on (?:the|this|what|context|that)\b|"
# Prompt-echo of length / style instructions
r"keep it under \d+ words\b|"
r"(?:no greetings|no preamble|no hashtags|just output the)\b"
r").*",
_re.IGNORECASE,
)
# Echo of the prompt's "Pending:" / "<N> pending" tail.
_echo = _re.compile(
r"^\s*(?:pending\s*[:.]|(?:\d+|one|two|three|four|five)\s+pending\b)",
_re.IGNORECASE,
)
lines = [ln for ln in synthesis.splitlines() if ln.strip()]
cleaned = [ln for ln in lines if not _reasoning.match(ln) and not _echo.match(ln)]
if cleaned:
# The model's actual answer is normally the LAST surviving
# line — reasoning leads, answer trails.
synthesis = cleaned[-1].strip()
else:
synthesis = _SYNTH_FAILED_TAG
except Exception as e:
logger.warning(f"Reminder LLM synthesis failed: {e}")
synthesis = _SYNTH_FAILED_TAG
if synthesis:
_s = synthesis.strip(); _low = _s.lower()
if (not _s or _low.startswith("error:") or _low.startswith("[error")
or "operation failed" in _low
or ("upstream" in _low and "failed" in _low)) and synthesis != _SYNTH_FAILED_TAG:
logger.warning(f"Reminder synthesis looked like an error, replacing: {_s[:120]!r}")
synthesis = _SYNTH_FAILED_TAG
email_sent = False
email_error = ""
if channel == "email":
try:
from routes.email_routes import _get_email_config
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime as _dt
# `reminder_email_account_id` lets the user pick WHICH email
# account to send reminders from (when they have several
# configured in Integrations). Falls back to the default
# account when no explicit choice is saved.
_acc_id = (settings.get("reminder_email_account_id") or "").strip() or None
cfg = _get_email_config(account_id=_acc_id, owner=owner or "")
if not (cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")):
try:
from core.database import SessionLocal as _SL, EmailAccount as _EA
from sqlalchemy import and_, or_
db = _SL()
try:
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
if owner:
unowned = or_(_EA.owner == None, _EA.owner == "") # noqa: E711
same_mailbox = or_(_EA.imap_user == owner, _EA.from_address == owner)
q = q.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox)))
for row in q.order_by(_EA.is_default.desc(), _EA.created_at.asc()).all():
trial = _get_email_config(account_id=row.id, owner=owner or "")
if trial.get("smtp_host") and trial.get("smtp_user") and trial.get("smtp_password"):
cfg = trial
break
finally:
db.close()
except Exception as _fallback_error:
logger.debug(f"Reminder SMTP fallback lookup failed: {_fallback_error}")
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
recipient = (settings.get("reminder_email_to") or "").strip() or from_addr
# Loud diagnostic so we can see WHY a reminder didn't send (the
# previous "silently no-op when cfg has no smtp_host" was invisible).
logger.info(
"dispatch_reminder[email] note_id=%s owner=%r "
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
note_id, owner,
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
bool(from_addr), bool(recipient),
)
missing = []
if not cfg.get("smtp_host"):
missing.append("SMTP host")
if not cfg.get("smtp_user"):
missing.append("SMTP user")
if not cfg.get("smtp_password"):
missing.append("SMTP password")
if not from_addr:
missing.append("from address")
if not recipient:
missing.append("recipient")
if missing:
email_error = "Missing " + ", ".join(missing)
logger.warning(
"Reminder email not sent for note_id=%s account=%r: %s",
note_id, cfg.get("account_name"), email_error,
)
else:
msg = MIMEMultipart("alternative")
msg["From"] = from_addr
msg["To"] = recipient
_t = title or 'Note'
_t = _t[len('Reminder:'):].strip() if _t.lower().startswith('reminder:') else _t
msg["Subject"] = f"Reminder (Odysseus): {_t}"
msg["Date"] = _dt.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
msg["X-Odysseus-Origin"] = "odysseus-ui"
msg["X-Odysseus-Kind"] = "reminder"
msg["X-Odysseus-Ref"] = str(note_id)
# Body shape: synthesis (warm sentence) → blank line → bold
# title header → note details. The title was previously only
# in the subject line, so the email read like a faceless
# to-do list with no anchor to which note triggered it.
_body_chunks = []
if synthesis:
_body_chunks.append(synthesis)
if _t:
_body_chunks.append(_t)
if note_body:
_body_chunks.append(note_body)
plain = "\n\n".join(_body_chunks) if _body_chunks else title
msg.attach(MIMEText(plain, "plain", "utf-8"))
def _smtp_send():
from routes.email_helpers import _send_smtp_message
_send_smtp_message(cfg, from_addr, [recipient], msg.as_string())
import asyncio as _aio
await _aio.to_thread(_smtp_send)
email_sent = True
except Exception as e:
email_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder email send failed: {e}")
webhook_sent = False
webhook_error = ""
if channel == "webhook":
try:
import httpx
import json as _wjson
from src.integrations import load_integrations
# Built-in payload defaults for known presets so users don't have
# to configure a template just to use a standard service.
_PRESET_TEMPLATE_DEFAULTS = {
"discord_webhook": '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}',
}
intg_id = settings.get("reminder_webhook_integration_id", "").strip()
template = settings.get("reminder_webhook_payload_template", "").strip()
if not intg_id:
webhook_error = "No webhook integration selected"
else:
intg = next(
(i for i in load_integrations()
if i.get("id") == intg_id and i.get("base_url")),
None,
)
if not intg:
webhook_error = f"Integration {intg_id!r} not found or missing base URL"
else:
# Fall back to a built-in default for known presets so
# users don't have to configure a template for standard
# services like Discord.
if not template:
template = _PRESET_TEMPLATE_DEFAULTS.get(intg.get("preset", ""), "")
if not template:
webhook_error = "No payload template configured"
else:
# Render template: JSON-escape the values so the result
# is always valid JSON regardless of special characters.
# dumps() returns `"value"` — strip outer quotes.
msg = (synthesis or note_body or title or "Reminder")[:4000]
_t = _wjson.dumps(title or "Reminder")[1:-1]
_m = _wjson.dumps(msg)[1:-1]
rendered = template.replace("{{title}}", _t).replace("{{message}}", _m)
hdrs = {"Content-Type": "application/json"}
api_key = intg.get("api_key", "")
auth_type = (intg.get("auth_type") or "none").lower()
if api_key:
if auth_type == "bearer":
hdrs["Authorization"] = f"Bearer {api_key}"
elif auth_type == "header":
hdrs[intg.get("auth_header") or "Authorization"] = api_key
url = intg["base_url"].rstrip("/")
# SSRF guard — matches the pattern used by webhook_routes,
# CalDAV, search, and embeddings. Blocks link-local / metadata
# addresses (169.254.x.x) by default; set
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true to also block
# RFC-1918 ranges for locked-down deployments.
import os as _os
from src.url_safety import check_outbound_url as _chk
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
_ok, _reason = _chk(url, block_private=_block)
if not _ok:
webhook_error = f"Webhook URL rejected: {_reason}"
else:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, content=rendered.encode(), headers=hdrs)
webhook_sent = resp.is_success
if not webhook_sent:
webhook_error = f"Webhook returned HTTP {resp.status_code}"
except Exception as e:
webhook_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder webhook send failed: {e}")
ntfy_sent = False
ntfy_error = ""
if channel == "ntfy":
try:
from src.integrations import load_integrations
import httpx
intg = next(
(i for i in load_integrations()
if i.get("preset") == "ntfy" and i.get("enabled", True) and i.get("base_url")),
None,
)
if intg:
base = intg["base_url"].rstrip("/")
topic = settings.get("reminder_ntfy_topic") or "reminders"
ntfy_body = synthesis or note_body or title
hdrs = {"Title": title or "Reminder", "Priority": "high", "Tags": "bell"}
api_key = intg.get("api_key", "")
if api_key:
hdrs["Authorization"] = f"Bearer {api_key}"
# SSRF guard — same check (and env knob) as the webhook branch
# above: link-local / metadata addresses are always rejected;
# REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true also blocks RFC-1918
# so a ntfy base_url can't be pointed at internal services.
import os as _os
from src.url_safety import check_outbound_url as _chk
_block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true"
_ok, _reason = _chk(f"{base}/{topic}", block_private=_block)
if not _ok:
ntfy_error = f"ntfy URL rejected: {_reason}"
else:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(f"{base}/{topic}", content=ntfy_body, headers=hdrs)
ntfy_sent = resp.is_success
if not ntfy_sent:
ntfy_error = f"ntfy returned HTTP {resp.status_code}"
else:
ntfy_error = "No enabled ntfy integration"
except Exception as e:
ntfy_error = str(e) or e.__class__.__name__
logger.warning(f"Reminder ntfy send failed: {e}")
# In-app browser notification ALWAYS fires (regardless of channel). The
# frontend polls `/api/tasks/notifications` and turns any entry with a
# `body` into a real `Notification(...)` — same surface as task-success
# popups. Lets the user see reminders inside the app even when the
# primary channel is email/ntfy and the tab is open.
browser_sent = False
local_browser_sent = (not queue_browser and channel == "browser")
if queue_browser and _scheduler_ref is not None:
try:
_scheduler_ref.add_notification(
task_name=title or "Reminder",
status="success",
task_id=f"reminder-{note_id}",
owner=owner or None,
body=(synthesis or note_body or title or "").strip()[:500] or "Reminder",
)
browser_sent = True
except Exception as _e:
logger.debug(f"dispatch_reminder: in-app notif push failed: {_e}")
# Dedupe across paths: write to the same cache file `action_ping_notes`
# reads, so the background scanner's REPING_MIN window suppresses a
# second send for the same note within 25 min. Without this, a note
# whose due_date fires while the user has the app open got TWO emails
# (frontend-fired here + background-fired by ping_notes 0–5 min later).
if (email_sent or ntfy_sent or webhook_sent or browser_sent or local_browser_sent) and note_id:
try:
import json as _json
from datetime import datetime as _dt, timezone as _tz
from pathlib import Path as _P
# Per-owner cache so the scanner's prune step on user A's run
# doesn't drop user B's just-fired entry (review C4).
_STATE = cache_path
if _STATE is None:
_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
_STATE = _P(DATA_DIR) / f"note_pings_{_slug}.json"
_STATE.parent.mkdir(parents=True, exist_ok=True)
try:
_cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {})
except Exception:
_cache = {}
sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "webhook" if webhook_sent else "browser"
_cache[cache_key or str(note_id)] = {
"at": _dt.now(_tz.utc).isoformat(),
"channel": sent_channel,
}
_STATE.write_text(_json.dumps(_cache), encoding="utf-8")
except Exception as _e:
logger.debug(f"dispatch_reminder: cache write failed: {_e}")
return {
"channel": channel,
"synthesis": synthesis,
"email_sent": email_sent,
"email_error": email_error,
"ntfy_sent": ntfy_sent,
"ntfy_error": ntfy_error,
"webhook_sent": webhook_sent,
"webhook_error": webhook_error,
"browser_sent": browser_sent or local_browser_sent,
}
# ---------------------------------------------------------------------------
# Router factory
# ---------------------------------------------------------------------------
def setup_note_routes(task_scheduler=None):
# Expose the scheduler to module-level `dispatch_reminder` so reminders
# can also push to the in-app notification queue (the polling system
# turns each entry into a real browser Notification + the existing
# tasks-tab badge / dot system).
global _scheduler_ref
_scheduler_ref = task_scheduler
router = APIRouter(prefix="/api/notes", tags=["notes"])
def _owner(request: Request) -> Optional[str]:
# require_user, not bare get_current_user: a request that reaches
# these owner-scoped routes with NO identity (auth-middleware
# regression, SSRF from a sibling service) must fail closed (401)
# when auth is configured — not be treated as the single-user mode
# and handed blanket access to every account's notes. The documented
# anonymous modes (AUTH_ENABLED=false, LOCALHOST_BYPASS on loopback,
# unconfigured first-run) still resolve to None, the single-user
# path. fire_reminder below already gated this way; the CRUD routes
# did not.
return require_user(request) or None
def _is_admin_or_single_user(request: Request, user: str | None) -> bool:
if user == INTERNAL_TOOL_USER:
return True
if not user:
# require_user() already admitted this request, which only happens
# for auth-disabled, loopback-bypass, or unconfigured single-user
# modes. There is no separate non-admin account boundary there.
return True
try:
from core.auth import AuthManager
auth_mgr = getattr(request.app.state, "auth_manager", None) or AuthManager()
if not getattr(auth_mgr, "is_configured", True):
return True
return bool(auth_mgr.is_admin(user))
except Exception:
return False
# --- LIST ---
@router.get("")
def list_notes(
request: Request,
archived: Optional[bool] = None,
label: Optional[str] = None,
):
user = _owner(request)
db = SessionLocal()
try:
q = db.query(Note)
if user is not None:
q = q.filter(Note.owner == user)
if archived is not None:
q = q.filter(Note.archived == archived)
else:
q = q.filter(Note.archived == False)
if label:
q = q.filter(Note.label == label)
# Archived view: most recently archived first. Active view: pin + manual order.
if archived is True:
notes = q.order_by(Note.updated_at.desc()).all()
else:
notes = q.order_by(Note.pinned.desc(), Note.sort_order.asc(), Note.updated_at.desc()).all()
return {"notes": [_note_to_dict(n) for n in notes]}
finally:
db.close()
# --- CREATE ---
@router.post("")
def create_note(request: Request, body: NoteCreate):
user = _owner(request)
db = SessionLocal()
try:
note = Note(
id=str(uuid.uuid4()),
owner=user,
title=body.title,
content=body.content,
items=json.dumps(body.items) if body.items is not None else None,
note_type=body.note_type,
color=body.color,
label=body.label,
pinned=body.pinned,
due_date=body.due_date,
source=body.source,
session_id=body.session_id,
image_url=body.image_url,
repeat=body.repeat or "none",
sort_order=body.sort_order if body.sort_order is not None else 0,
)
db.add(note)
db.commit()
db.refresh(note)
return _note_to_dict(note)
finally:
db.close()
# --- GET ONE ---
@router.get("/{note_id}")
def get_note(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
return _note_to_dict(note)
finally:
db.close()
# --- UPDATE ---
@router.put("/{note_id}")
def update_note(request: Request, note_id: str, body: NoteUpdate):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
if body.title is not None:
note.title = body.title
if body.content is not None:
note.content = body.content
if body.items is not None:
note.items = json.dumps(body.items)
flag_modified(note, "items")
if body.note_type is not None:
note.note_type = body.note_type
if body.color is not None:
note.color = body.color
if body.label is not None:
note.label = body.label
if body.pinned is not None:
note.pinned = body.pinned
if body.archived is not None:
note.archived = body.archived
if body.due_date is not None:
note.due_date = body.due_date
if body.image_url is not None:
note.image_url = body.image_url
if body.repeat is not None:
note.repeat = body.repeat
if body.sort_order is not None:
note.sort_order = body.sort_order
if body.agent_session_id is not None:
note.agent_session_id = body.agent_session_id
db.commit()
db.refresh(note)
return _note_to_dict(note)
finally:
db.close()
# --- DELETE ---
@router.delete("/{note_id}")
def delete_note(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
db.delete(note)
db.commit()
return {"ok": True}
finally:
db.close()
# --- TOGGLE PIN ---
@router.post("/{note_id}/pin")
def toggle_pin(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
note.pinned = not note.pinned
db.commit()
return {"ok": True, "pinned": note.pinned}
finally:
db.close()
# --- TOGGLE ARCHIVE ---
@router.post("/{note_id}/archive")
def toggle_archive(request: Request, note_id: str):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
note.archived = not note.archived
db.commit()
return {"ok": True, "archived": note.archived}
finally:
db.close()
# --- TOGGLE CHECKLIST ITEM ---
@router.post("/{note_id}/items/{index}/toggle")
def toggle_item(request: Request, note_id: str, index: int):
user = _owner(request)
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
# SECURITY: strict ownership — previously `note.owner and note.owner != user`
# let any user touch a row whose owner field was null/empty.
if user is not None and note.owner != user:
raise HTTPException(404, "Note not found")
if not note.items:
raise HTTPException(400, "Note has no checklist items")
items = json.loads(note.items)
if index < 0 or index >= len(items):
raise HTTPException(400, f"Item index {index} out of range")
items[index]["done"] = not items[index].get("done", False)
note.items = json.dumps(items)
flag_modified(note, "items")
db.commit()
return {"ok": True, "items": items}
finally:
db.close()
# --- FIRE REMINDER ---
@router.post("/fire-reminder")
async def fire_reminder(request: Request):
"""Dispatch a reminder according to user settings.
Called by the frontend when a reminder fires. Optionally generates an
LLM synthesis line and/or sends an email through configured SMTP.
Returns {synthesis, email_sent}.
"""
# Gate against anonymous callers — LLM synthesis can burn tokens.
user = require_user(request)
body = await request.json()
note_id = str(body.get("note_id") or "").strip()
if not note_id:
raise HTTPException(400, "note_id required")
caller = _owner(request)
is_test = note_id.startswith("test-")
is_admin = _is_admin_or_single_user(request, user or caller)
_override: dict = {}
if is_test:
if not is_admin:
raise HTTPException(403, "Admin only")
title = (body.get("title") or "Test Reminder").strip() or "Test Reminder"
note_body = (body.get("body") or "").strip()
# Optional overrides let the admin settings test button pass the
# current UI values directly so it never races a pending save.
if body.get("channel"):
_override["reminder_channel"] = body["channel"]
if body.get("webhook_integration_id"):
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
if body.get("webhook_payload_template"):
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
# Mirror the in-UI AI Synthesis toggle + persona so the test
# actually exercises the synthesis path before/without a Save.
if "llm_synthesis" in body:
_override["reminder_llm_synthesis"] = bool(body["llm_synthesis"])
if "llm_persona" in body:
_override["reminder_llm_persona"] = str(body["llm_persona"] or "")
else:
db = SessionLocal()
try:
note = db.query(Note).filter(Note.id == note_id).first()
if not note:
raise HTTPException(404, "Note not found")
if caller is not None and note.owner != caller:
raise HTTPException(404, "Note not found")
title, note_body = _reminder_text_from_note(note)
finally:
db.close()
return await dispatch_reminder(
title=title, note_body=note_body, note_id=note_id,
owner=caller or "",
queue_browser=False,
settings_override=_override or None,
)
# --- REORDER NOTES ---
@router.post("/reorder")
async def reorder_notes(request: Request):
"""Update sort_order for a list of note IDs in the order provided."""
user = _owner(request)
body = await request.json()
ids = body.get("ids", [])
if not isinstance(ids, list):
raise HTTPException(400, "ids must be a list")
# v2 review HIGH-12: drop the legacy `(owner == user) | (owner ==
# None)` OR which let an authenticated user silently reorder
# every legacy-null-owner note belonging to other accounts. In
# an unconfigured (single-user) auth deploy the OR is still safe
# because there's no second user to attack; we keep that branch
# explicit and gated on AuthManager.is_configured.
try:
from core.auth import AuthManager
_allow_null = not AuthManager().is_configured
except Exception:
_allow_null = False
db = SessionLocal()
try:
for i, nid in enumerate(ids):
q = db.query(Note).filter(Note.id == nid)
if user is not None:
if _allow_null:
q = q.filter((Note.owner == user) | (Note.owner == None)) # noqa: E711
else:
q = q.filter(Note.owner == user)
note = q.first()
if note:
note.sort_order = i
db.commit()
return {"ok": True, "count": len(ids)}
finally:
db.close()
return router
+90 -19
View File
@@ -1,11 +1,13 @@
# routes/personal_routes.py # routes/personal_routes.py
"""Routes for personal documents management.""" """Routes for personal documents management."""
import asyncio
import os import os
import logging import logging
import shutil import shutil
import uuid import uuid
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Tuple
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
from fastapi.concurrency import run_in_threadpool
from src.request_models import DirectoryRequest from src.request_models import DirectoryRequest
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
from src.rag_singleton import get_rag_manager from src.rag_singleton import get_rag_manager
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str: def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
"""Return the per-owner upload directory used for direct RAG uploads.""" """Return the per-owner upload directory used for direct RAG uploads."""
owner_segment = secure_filename((owner or "local").strip())[:80] or "local" owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
""" """
router = APIRouter(prefix="/api/personal") router = APIRouter(prefix="/api/personal")
# Serializes directory index jobs across requests. Indexing runs in the
# threadpool (#5558), so concurrent requests would otherwise run in parallel
# and race PersonalDocsManager's unsynchronized list mutations and file
# writes; before the threadpool move they serialized on the blocked event
# loop, so one-at-a-time is behavior parity.
#
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
# request parks on the event loop instead of pinning a threadpool worker (an
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
# tokens while blocked, starving every other run_in_threadpool caller).
# add/remove/reload all take this lock, so their mutations never interleave.
# Per-router (not module-global) so each app binds it to its own event loop.
# Scope is the single process: multi-worker deployments would need a shared
# lock (out of scope for #5558).
_index_job_lock = asyncio.Lock()
def _rag(): def _rag():
"""Get the current RAG manager, retrying init if needed.""" """Get the current RAG manager, retrying init if needed."""
return get_rag_manager() return get_rag_manager()
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
return {"files": files, "directories": directories} return {"files": files, "directories": directories}
@router.post("/reload") @router.post("/reload")
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)): async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
personal_docs_manager.refresh_index() # refresh_index() re-extracts text across every tracked directory —
# blocking work. Take the shared job lock (so it cannot race an add /
# remove) and run it off the event loop.
async with _index_job_lock:
await run_in_threadpool(personal_docs_manager.refresh_index)
return {"ok": True, "count": len(personal_docs_manager.index)} return {"ok": True, "count": len(personal_docs_manager.index)}
@router.post("/add_directory") @router.post("/add_directory")
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
# Use the RAGManager to index the directory # Use the RAGManager to index the directory
rag = _rag() rag = _rag()
if rag: if rag:
def _index_directory():
result = rag.index_personal_documents(directory, owner=owner) result = rag.index_personal_documents(directory, owner=owner)
if result["success"]:
# Also update the personal_docs_manager to track this
# directory. Kept inside the offloaded call: it triggers
# refresh_index(), which re-extracts text across tracked
# directories.
personal_docs_manager.add_directory(directory, index=False)
return result
# Indexing walks, embeds, and stores the whole tree — minutes
# on a real directory. The handler is async, so calling it
# inline runs it on the event loop and every other request
# queues behind it until it finishes (#5558). Serialize on the
# async job lock BEFORE offloading so a queued request parks on
# the loop instead of pinning a threadpool worker.
async with _index_job_lock:
result = await run_in_threadpool(_index_directory)
if result["success"]: if result["success"]:
# Also update the personal_docs_manager to track this directory
personal_docs_manager.add_directory(directory, index=False)
return { return {
"success": True, "success": True,
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}", "message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
@@ -251,18 +286,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
logger.info(f"Removing directory from RAG: {directory}") logger.info(f"Removing directory from RAG: {directory}")
# Always remove from personal_docs_manager tracking rag = _rag()
def _remove_directory():
# Always remove from personal_docs_manager tracking. This
# mutates the same unsynchronized list/index an add job touches
# and re-extracts text (refresh_index), so it is blocking work.
if hasattr(personal_docs_manager, 'remove_directory'): if hasattr(personal_docs_manager, 'remove_directory'):
personal_docs_manager.remove_directory(directory) personal_docs_manager.remove_directory(directory)
# Remove from RAG vector store (best-effort).
# Remove from RAG vector store (best-effort)
rag = _rag()
if rag: if rag:
try: try:
rag.remove_directory(directory) rag.remove_directory(directory)
except Exception as e: except Exception as e:
logger.warning(f"RAG removal failed for directory {directory}: {e}") logger.warning(f"RAG removal failed for directory {directory}: {e}")
# Same job lock as add/reload so remove cannot interleave with an
# in-flight add; offloaded off the event loop.
async with _index_job_lock:
await run_in_threadpool(_remove_directory)
return { return {
"success": True, "success": True,
"message": f"Successfully removed {directory} from RAG index", "message": f"Successfully removed {directory} from RAG index",
@@ -289,14 +332,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
total_failed = 0 total_failed = 0
uploaded_files = [] uploaded_files = []
# Chunking, embedding and the tracking update are blocking work over the
# same vector/tracking state add_directory mutates (#5634). Take the
# shared job lock BEFORE offloading so a queued request parks on the loop
# instead of pinning a threadpool worker, matching add_directory.
# Read and process one capped payload at a time so a multi-file request
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
async with _index_job_lock:
for upload in files: for upload in files:
try: try:
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename) file_path, stored_name, safe_name = _unique_personal_upload_path(
upload_dir, upload.filename
)
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1) content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES: if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}") logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
total_failed += 1 total_failed += 1
continue continue
def _index_upload():
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
f.write(content_bytes) f.write(content_bytes)
@@ -308,10 +362,10 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
text = content_bytes.decode("utf-8", errors="replace") text = content_bytes.decode("utf-8", errors="replace")
if not text or not text.strip(): if not text or not text.strip():
total_failed += 1 return 0, 1, None
continue
# Chunk and index indexed = 0
failed = 0
chunks = rag._split_into_chunks(text, chunk_size=500) chunks = rag._split_into_chunks(text, chunk_size=500)
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
metadata = { metadata = {
@@ -325,18 +379,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
if user: if user:
metadata["owner"] = user metadata["owner"] = user
if rag.add_document(chunk, metadata): if rag.add_document(chunk, metadata):
total_indexed += 1 indexed += 1
else: else:
total_failed += 1 failed += 1
return indexed, failed, safe_name
uploaded_files.append(safe_name) indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
total_indexed += indexed
total_failed += failed
if uploaded_name:
uploaded_files.append(uploaded_name)
except Exception as e: except Exception as e:
logger.error(f"Failed to upload/index {upload.filename}: {e}") logger.error(f"Failed to upload/index {upload.filename}: {e}")
total_failed += 1 total_failed += 1
# Track uploads directory # Same transition, same lock: the tracking update must not land
# while another job is mid-write over the same state.
if uploaded_files and hasattr(personal_docs_manager, "add_directory"): if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
personal_docs_manager.add_directory(upload_dir, index=False) await run_in_threadpool(
personal_docs_manager.add_directory, upload_dir, index=False
)
return { return {
"success": True, "success": True,
@@ -349,6 +411,7 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)): async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
"""Delete a specific file from RAG index and optionally from disk.""" """Delete a specific file from RAG index and optionally from disk."""
try: try:
def _delete_file():
# Remove chunks from RAG vector store (best-effort) # Remove chunks from RAG vector store (best-effort)
removed = 0 removed = 0
rag = _rag() rag = _rag()
@@ -381,6 +444,14 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
# Exclude the file from the listing (persists across restarts) # Exclude the file from the listing (persists across restarts)
personal_docs_manager.exclude_file(filepath) personal_docs_manager.exclude_file(filepath)
return removed, deleted_from_disk
# Vector removal, the disk unlink and the exclusion write are one
# transition over the same state add_directory mutates (#5634), and
# all three block. Take the shared job lock BEFORE offloading, as
# add_directory does.
async with _index_job_lock:
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
return { return {
"success": True, "success": True,
+53 -19
View File
@@ -1,12 +1,16 @@
"""User preferences API — per-user key/value store backed by a JSON file.""" """User preferences API — per-user key/value store backed by a JSON file."""
import json import json
import os
from typing import Optional from typing import Optional
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from core.atomic_io import atomic_write_json
from src.auth_helpers import get_current_user from src.auth_helpers import get_current_user
from src.constants import USER_PREFS_FILE from src.constants import USER_PREFS_FILE
PREFS_FILE = USER_PREFS_FILE PREFS_FILE = USER_PREFS_FILE
_FOREGROUND_POLICY_KEYS = (
"foreground_fallback_enabled",
"foreground_model_fallbacks",
)
def _load(): def _load():
@@ -20,26 +24,33 @@ def _load():
def _save(prefs): def _save(prefs):
os.makedirs(os.path.dirname(PREFS_FILE) or ".", exist_ok=True) atomic_write_json(PREFS_FILE, prefs, indent=2)
tmp = f"{PREFS_FILE}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(prefs, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, PREFS_FILE)
def _load_for_user(user: Optional[str] = None) -> dict: def _load_for_user(user: Optional[str] = None) -> dict:
"""Load preferences for a specific user.""" """Load preferences for a specific user."""
all_prefs = _load() all_prefs = _load()
if "_users" in all_prefs: users = all_prefs.get("_users")
if isinstance(users, dict):
if user is None: if user is None:
# Auth disabled — return first user's prefs for backward compat # Auth disabled — return first user's prefs for backward compat
users = all_prefs["_users"] prefs = dict(next(iter(users.values()), {}))
return dict(next(iter(users.values()), {})) # Foreground fallback consent is never borrowed from a named
return dict(all_prefs["_users"].get(user, {})) # owner. Auth-disabled operation has a separate flat/root opt-in
# Legacy flat format — return as-is # that remains inert when authentication is enabled again.
return dict(all_prefs) for key in _FOREGROUND_POLICY_KEYS:
prefs.pop(key, None)
if key in all_prefs:
prefs[key] = all_prefs[key]
return prefs
prefs = users.get(user, {})
return dict(prefs) if isinstance(prefs, dict) else {}
# A legacy flat store belongs only to auth-disabled single-user mode.
# Copying it into the first named user's new `_users` record during an
# auth transition would silently transfer another user's preferences and,
# critically, foreground fallback consent. Named owners therefore start
# with an empty record and must write their own preferences explicitly.
return dict(all_prefs) if user is None else {}
def _save_for_user(user: Optional[str], prefs: dict): def _save_for_user(user: Optional[str], prefs: dict):
@@ -51,17 +62,40 @@ def _save_for_user(user: Optional[str], prefs: dict):
# `prefs` flat would overwrite the whole `_users` map and destroy every # `prefs` flat would overwrite the whole `_users` map and destroy every
# other user's preferences. Instead write back into the same (first) # other user's preferences. Instead write back into the same (first)
# slot _load_for_user(None) reads from, preserving the others. # slot _load_for_user(None) reads from, preserving the others.
if "_users" in all_prefs: users = all_prefs.get("_users")
users = all_prefs["_users"] if isinstance(users, dict):
first_key = next(iter(users), None) first_key = next(iter(users), None)
if first_key is not None: if first_key is not None:
users[first_key] = prefs existing_named = users.get(first_key)
existing_named = (
dict(existing_named)
if isinstance(existing_named, dict)
else {}
)
named_foreground = {
key: existing_named[key]
for key in _FOREGROUND_POLICY_KEYS
if key in existing_named
}
users[first_key] = {
key: value
for key, value in prefs.items()
if key not in _FOREGROUND_POLICY_KEYS
}
users[first_key].update(named_foreground)
for key in _FOREGROUND_POLICY_KEYS:
if key in prefs:
all_prefs[key] = prefs[key]
_save(all_prefs) _save(all_prefs)
return return
_save(prefs) _save(prefs)
return return
if "_users" not in all_prefs: if not isinstance(all_prefs.get("_users"), dict):
all_prefs = {"_users": {}} # Preserve the flat single-user object as inert legacy data while
# creating the first named-owner namespace. In particular, historical
# fallback values must not be deleted or copied into the new owner.
all_prefs = dict(all_prefs)
all_prefs["_users"] = {}
all_prefs["_users"][user] = prefs all_prefs["_users"][user] = prefs
_save(all_prefs) _save(all_prefs)
+9 -6
View File
@@ -9,13 +9,13 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional 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 fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from core.middleware import INTERNAL_TOOL_USER from core.middleware import INTERNAL_TOOL_USER
from src.endpoint_resolver import resolve_endpoint 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 core.auth import RESERVED_USERNAMES from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.constants import DEEP_RESEARCH_DIR from src.constants import DEEP_RESEARCH_DIR
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$") _SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
@@ -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: 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: def _require_user(request: Request) -> str:
"""All research endpoints require an authenticated user. Research """All research endpoints require an authenticated user. Research
data isn't owner-scoped in the on-disk JSON yet, so we at least data isn't owner-scoped in the on-disk JSON yet, so we at least
block anonymous access. Multi-tenant deploys should additionally block anonymous access. Multi-tenant deploys should additionally
verify the session belongs to this user.""" verify the session belongs to this user."""
user = get_current_user(request) user = require_interactive_request(request)
if not user: if not user:
if _auth_disabled(): if _auth_disabled():
return "" return ""
@@ -496,7 +499,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
user = require_privilege(request, "can_use_research") user = require_privilege(request, "can_use_research")
if user == INTERNAL_TOOL_USER: if user == INTERNAL_TOOL_USER:
tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip() tool_owner = (request.headers.get("X-Odysseus-Owner") or "").strip()
if tool_owner and tool_owner not in RESERVED_USERNAMES: if tool_owner and tool_owner not in REQUEST_SENTINEL_OWNERS:
auth_mgr = getattr(request.app.state, "auth_manager", None) auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False): if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
try: try:
+5
View File
@@ -0,0 +1,5 @@
"""Search route domain package (slice 2j, #4082/#4071).
Contains search_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/search_routes.py re-exports from here.
"""
+119
View File
@@ -0,0 +1,119 @@
"""Search routes — /api/search/config GET, /api/search POST."""
import logging
from typing import Dict, Any
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__)
async def _request_values(request: Request) -> Dict[str, Any]:
"""Accept JSON, form data, or query params for search endpoints.
The browser UI posts FormData, while the agent's generic app_api tool
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
runs, which made the model think SearXNG was broken.
"""
values: Dict[str, Any] = dict(request.query_params)
content_type = (request.headers.get("content-type") or "").lower()
try:
if "application/json" in content_type:
body = await request.json()
if isinstance(body, dict):
values.update(body)
else:
form = await request.form()
values.update(dict(form))
except Exception:
pass
return values
def setup_search_routes(config) -> APIRouter:
router = APIRouter(
tags=["search"],
dependencies=[Depends(require_interactive_request)],
)
@router.get("/api/search/config")
async def get_search_settings(request: Request) -> Dict[str, Any]:
require_interactive_request(request)
return get_search_config()
@router.post("/api/search")
async def do_web_search(request: Request) -> Dict[str, Any]:
"""Standalone web search — returns context string + source list.
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:
return {"context": "", "sources": [], "error": "query is required"}
time_filter = values.get("time_filter") or values.get("freshness")
if time_filter is not None:
time_filter = str(time_filter).strip() or None
try:
context, sources = comprehensive_web_search(
query, return_sources=True, time_filter=time_filter,
)
return {"context": context, "sources": sources}
except Exception as e:
logger.error(f"Standalone web search failed: {e}")
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/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":
continue
available = True
if needs_key and not _get_provider_key(pid):
available = False
if needs_url and pid == "searxng" and not _get_search_instance():
available = False
providers.append({
"id": pid,
"label": label,
"available": available,
})
return providers
@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()
try:
count = int(values.get("count") or values.get("limit") or 10)
except Exception:
count = 10
if not query:
return {"results": [], "provider": provider, "error": "query is required"}
if provider not in PROVIDER_INFO or provider == "disabled":
return {"results": [], "provider": provider, "error": "Unknown provider"}
t0 = time.time()
try:
results = _call_provider(provider, query, min(count, 20))
elapsed = round(time.time() - t0, 2)
return {"results": results, "provider": provider, "time": elapsed}
except Exception as e:
elapsed = round(time.time() - t0, 2)
logger.error(f"Search provider {provider} failed: {e}")
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
return router
+9 -107
View File
@@ -1,111 +1,13 @@
"""Search routes — /api/search/config GET, /api/search POST.""" """Backward-compat shim — canonical location is routes/search/search_routes.py.
import logging This module is replaced in ``sys.modules`` by the canonical module object so
from typing import Dict, Any that ``import routes.search_routes`` and ``from routes.search_routes import X``
keep resolving to the canonical module. Keeps existing import paths working
after slice 2j (#4082/#4071).
"""
from fastapi import APIRouter, Request import sys as _sys
import time from routes.search import search_routes as _canonical # noqa: F401
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO _sys.modules[__name__] = _canonical
from services.search.core import _call_provider
from services.search.providers import _get_provider_key, _get_search_instance
logger = logging.getLogger(__name__)
async def _request_values(request: Request) -> Dict[str, Any]:
"""Accept JSON, form data, or query params for search endpoints.
The browser UI posts FormData, while the agent's generic app_api tool
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
runs, which made the model think SearXNG was broken.
"""
values: Dict[str, Any] = dict(request.query_params)
content_type = (request.headers.get("content-type") or "").lower()
try:
if "application/json" in content_type:
body = await request.json()
if isinstance(body, dict):
values.update(body)
else:
form = await request.form()
values.update(dict(form))
except Exception:
pass
return values
def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
return get_search_config()
@router.post("/api/search")
async def do_web_search(request: Request) -> Dict[str, Any]:
"""Standalone web search — returns context string + source list.
Used by Compare mode to pre-search once and share results across panes.
"""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
return {"context": "", "sources": [], "error": "query is required"}
time_filter = values.get("time_filter") or values.get("freshness")
if time_filter is not None:
time_filter = str(time_filter).strip() or None
try:
context, sources = comprehensive_web_search(
query, return_sources=True, time_filter=time_filter,
)
return {"context": context, "sources": sources}
except Exception as e:
logger.error(f"Standalone web search failed: {e}")
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/search/providers")
async def list_search_providers():
"""Return available search providers with config status."""
providers = []
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
if pid == "disabled":
continue
available = True
if needs_key and not _get_provider_key(pid):
available = False
if needs_url and pid == "searxng" and not _get_search_instance():
available = False
providers.append({
"id": pid,
"label": label,
"available": available,
})
return providers
@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."""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
try:
count = int(values.get("count") or values.get("limit") or 10)
except Exception:
count = 10
if not query:
return {"results": [], "provider": provider, "error": "query is required"}
if provider not in PROVIDER_INFO or provider == "disabled":
return {"results": [], "provider": provider, "error": "Unknown provider"}
t0 = time.time()
try:
results = _call_provider(provider, query, min(count, 20))
elapsed = round(time.time() - t0, 2)
return {"results": results, "provider": provider, "time": elapsed}
except Exception as e:
elapsed = round(time.time() - t0, 2)
logger.error(f"Search provider {provider} failed: {e}")
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
return router
+194 -21
View File
@@ -4,15 +4,30 @@ import html
import json import json
import uuid import uuid
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Form, HTTPException, Response, Request from fastapi import APIRouter, Depends, Form, HTTPException, Response, Request
import logging import logging
from core.session_manager import SessionManager from core.session_manager import SessionManager
from core.models import ChatMessage from core.models import ChatMessage
from src.request_models import SessionResponse from src.request_models import SessionResponse
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive 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.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: def _sanitize_export_filename(name: str) -> str:
@@ -122,7 +137,11 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
logger = logging.getLogger(__name__) 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: def _current_user_is_admin(request: Request, user: str | None) -> bool:
if not user: if not user:
@@ -151,7 +170,10 @@ def _reject_raw_endpoint_url_for_non_admin(
# Raw URLs make the server dial whatever host the request supplies. For # 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 # non-admin users, require a saved endpoint row so normal owner scoping and
# endpoint validation have already happened. # 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") raise HTTPException(403, "Choose a registered model endpoint")
@@ -203,7 +225,12 @@ def _pick_endpoint_for_sort(owner=None):
return url, model, headers return url, model, headers
return None, None, None return None, None, None
def setup_session_routes(session_manager: SessionManager, config: dict, webhook_manager=None): def setup_session_routes(
session_manager: SessionManager,
config: dict,
webhook_manager=None,
upload_handler=None,
):
"""Setup session routes with the provided manager and config""" """Setup session routes with the provided manager and config"""
REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20) REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20)
@@ -213,7 +240,9 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.get("/sessions") @router.get("/sessions")
def list_sessions(request: Request): def list_sessions(request: Request):
require_chat_scope(request)
user = effective_user(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 # Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
# from the DB and session_manager so they vanish on the next page refresh. # from the DB and session_manager so they vanish on the next page refresh.
# BUT: skip sessions that were created within the last 10 minutes. # BUT: skip sessions that were created within the last 10 minutes.
@@ -223,6 +252,11 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
# session is current and won't delete the live one — this server-side # session is current and won't delete the live one — this server-side
# purge exists only to catch ghosts the frontend missed (tab close, # purge exists only to catch ghosts the frontend missed (tab close,
# crash). Only clean up rows old enough to be definitely orphaned. # crash). Only clean up rows old enough to be definitely orphaned.
# 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: try:
from datetime import timedelta as _td from datetime import timedelta as _td
_cutoff = utcnow_naive() - _td(minutes=10) _cutoff = utcnow_naive() - _td(minutes=10)
@@ -234,6 +268,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
DbSession.created_at < _cutoff, DbSession.created_at < _cutoff,
).all() ).all()
for _g in _ghosts: 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.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
_purge_db.delete(_g) _purge_db.delete(_g)
if hasattr(session_manager, "delete_session"): if hasattr(session_manager, "delete_session"):
@@ -328,10 +364,14 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
api_key: str = Form(""), api_key: str = Form(""),
endpoint_id: 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" skip_val = str(skip_validation).lower() == "true"
user = effective_user(request) user = effective_user(request)
endpoint_api_key = "" endpoint_api_key = ""
endpoint_base_url = "" endpoint_base_url = ""
endpoint_row = None
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url) _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
if endpoint_id and endpoint_id.strip(): if endpoint_id and endpoint_id.strip():
from core.database import ModelEndpoint from core.database import ModelEndpoint
@@ -365,7 +405,14 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
from src.endpoint_resolver import build_headers from src.endpoint_resolver import build_headers
validation_headers = build_headers(effective_api_key, endpoint_base_url or endpoint_url) 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. # skip_validation = trust the caller and do NOT probe /v1/models.
# Used for custom endpoints AND for bare placeholder sessions with no # 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 # model at all (e.g. an email reply draft just needs a session to live
@@ -379,6 +426,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
headers=validation_headers, headers=validation_headers,
owner=user, owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None, endpoint_id=endpoint_id.strip() if endpoint_id else None,
**probe_kwargs,
) )
if not ids: if not ids:
raise HTTPException(400, "Cannot reach /v1/models") raise HTTPException(400, "Cannot reach /v1/models")
@@ -390,6 +438,12 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
chat_ids = [m for m in ids if not any(p in m.lower() for p in _NON_CHAT)] 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] model_to_use = (chat_ids or ids)[0]
else: else:
# 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 from src.llm_core import list_model_ids
import os as _os import os as _os
req_base = _os.path.basename(model_to_use.rstrip("/")) req_base = _os.path.basename(model_to_use.rstrip("/"))
@@ -399,6 +453,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
headers=validation_headers, headers=validation_headers,
owner=user, owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None, endpoint_id=endpoint_id.strip() if endpoint_id else None,
**probe_kwargs,
) )
if not avail: if not avail:
raise HTTPException(400, "Cannot reach /v1/models") raise HTTPException(400, "Cannot reach /v1/models")
@@ -423,6 +478,20 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
rag=str(rag).lower() == "true" if rag else False, rag=str(rag).lower() == "true" if rag else False,
owner=user, 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 # Set auth headers for custom API-key endpoints
resolved_key = request_api_key resolved_key = request_api_key
resolved_base = endpoint_url resolved_base = endpoint_url
@@ -433,6 +502,9 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
from src.endpoint_resolver import build_headers from src.endpoint_resolver import build_headers
session.headers = build_headers(resolved_key, resolved_base) session.headers = build_headers(resolved_key, resolved_base)
_persist_session_headers(sid, session.headers) _persist_session_headers(sid, session.headers)
# 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) # Fire webhook (sync-safe)
if webhook_manager: if webhook_manager:
webhook_manager.fire_and_forget("session.created", { webhook_manager.fire_and_forget("session.created", {
@@ -455,6 +527,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
model: str = Form(None), endpoint_url: str = Form(None), model: str = Form(None), endpoint_url: str = Form(None),
endpoint_id: str = Form(None), endpoint_id: str = Form(None),
): ):
require_chat_scope(request)
_verify_session_owner(request, sid) _verify_session_owner(request, sid)
try: try:
session = session_manager.get_session(sid) session = session_manager.get_session(sid)
@@ -482,6 +555,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url) _reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
endpoint_api_key = "" endpoint_api_key = ""
endpoint_base_url = "" endpoint_base_url = ""
endpoint_row = None
if endpoint_id: if endpoint_id:
from core.database import ModelEndpoint from core.database import ModelEndpoint
from src.auth_helpers import owner_filter from src.auth_helpers import owner_filter
@@ -497,13 +571,23 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
ep = q.first() ep = q.first()
if not ep: if not ep:
raise HTTPException(400, "Model endpoint no longer exists") raise HTTPException(400, "Model endpoint no longer exists")
endpoint_row = ep
endpoint_base_url = ep.base_url or "" endpoint_base_url = ep.base_url or ""
endpoint_api_key = ep.api_key or "" endpoint_api_key = ep.api_key or ""
endpoint_url = build_chat_url(normalize_base(endpoint_base_url)) endpoint_url = build_chat_url(normalize_base(endpoint_base_url))
finally: finally:
_db.close() _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.model = model
session.endpoint_url = endpoint_url 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 # Update auth headers from the endpoint's stored API key
if endpoint_api_key: if endpoint_api_key:
from src.endpoint_resolver import build_headers from src.endpoint_resolver import build_headers
@@ -518,6 +602,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
db_session.model = model db_session.model = model
db_session.endpoint_url = endpoint_url db_session.endpoint_url = endpoint_url
db_session.headers = session.headers or {} 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_session.updated_at = utcnow_naive()
db.commit() db.commit()
finally: finally:
@@ -529,6 +615,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.post("/session/{sid}/inject_messages") @router.post("/session/{sid}/inject_messages")
async def inject_messages(request: Request, sid: str): async def inject_messages(request: Request, sid: str):
"""Bulk-inject messages into a session's history (for group chat sync).""" """Bulk-inject messages into a session's history (for group chat sync)."""
require_chat_scope(request)
_verify_session_owner(request, sid) _verify_session_owner(request, sid)
try: try:
sess = session_manager.get_session(sid) sess = session_manager.get_session(sid)
@@ -537,19 +624,41 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
body = await request.json() body = await request.json()
messages = body.get("messages", []) messages = body.get("messages", [])
from core.models import ChatMessage from core.models import ChatMessage
owner = effective_user(request)
try:
for message in messages:
missing_id = reserve_message_upload_references(
upload_handler,
owner,
message.get("content"),
sanitize_client_message_metadata(message.get("metadata")),
)
if missing_id:
raise HTTPException(
409,
f"Referenced upload is no longer available: {missing_id}",
)
except (AttributeError, TypeError, ValueError) as exc:
raise HTTPException(400, "Invalid message attachment metadata") from exc
for m in messages: 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() session_manager.save_sessions()
return {"ok": True, "count": len(messages)} return {"ok": True, "count": len(messages)}
@router.post("/session/{sid}/delete") @router.post("/session/{sid}/delete")
def delete_session_beacon(request: Request, sid: str): def delete_session_beacon(request: Request, sid: str):
"""Delete session via POST (for navigator.sendBeacon on page close).""" """Delete session via POST (for navigator.sendBeacon on page close)."""
require_chat_scope(request)
return delete_session(request, sid) return delete_session(request, sid)
@router.post("/sessions/bulk-delete") @router.post("/sessions/bulk-delete")
async def bulk_delete_sessions(request: Request): async def bulk_delete_sessions(request: Request):
"""Delete multiple sessions (for compare cleanup via sendBeacon).""" """Delete multiple sessions (for compare cleanup via sendBeacon)."""
require_chat_scope(request)
from core.database import ChatMessage as _CM from core.database import ChatMessage as _CM
try: try:
body = await request.json() body = await request.json()
@@ -579,6 +688,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.delete("/session/{sid}") @router.delete("/session/{sid}")
def delete_session(request: Request, sid: str): def delete_session(request: Request, sid: str):
"""Permanently delete a session and all its messages.""" """Permanently delete a session and all its messages."""
require_chat_scope(request)
_verify_session_owner(request, sid, session_manager) _verify_session_owner(request, sid, session_manager)
try: try:
# Block deletion of starred/favorited sessions # Block deletion of starred/favorited sessions
@@ -613,19 +723,50 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.delete("/sessions/all") @router.delete("/sessions/all")
def delete_all_sessions(request: Request): def delete_all_sessions(request: Request):
"""Admin only: permanently delete ALL sessions and their messages.""" """Admin only: permanently delete ALL sessions and their messages."""
require_chat_scope(request)
from core.middleware import require_admin from core.middleware import require_admin
require_admin(request) require_admin(request)
db = SessionLocal() db = SessionLocal()
try: try:
from core.database import ChatMessage as DbChatMessage from core.database import ChatMessage as DbChatMessage
session_ids = [row[0] for row in db.query(DbSession.id).all()]
count = db.query(DbSession).count() count = db.query(DbSession).count()
image_ids: set[str] = set()
filenames: set[str] = set()
for sid in session_ids:
ids, names = session_image_refs(db, sid)
image_ids.update(ids)
filenames.update(names)
image_query = db.query(GalleryImage).filter(GalleryImage.session_id.in_(session_ids)) if session_ids else db.query(GalleryImage).filter(False)
if image_ids or filenames:
from sqlalchemy import or_
clauses = []
if session_ids:
clauses.append(GalleryImage.session_id.in_(session_ids))
if image_ids:
clauses.append(GalleryImage.id.in_(list(image_ids)))
if filenames:
clauses.append(GalleryImage.filename.in_(list(filenames)))
image_query = db.query(GalleryImage).filter(or_(*clauses))
images = image_query.all()
removed_images = 0
for img in images:
img.is_active = False
if img.filename:
path = _generated_image_path_for_cleanup(img.filename)
if path and path.exists():
try:
path.unlink()
except Exception as exc:
logger.warning("Could not remove generated image %s during all-session delete: %s", img.filename, exc)
removed_images += 1
db.query(DbChatMessage).delete() db.query(DbChatMessage).delete()
db.query(DbSession).delete() db.query(DbSession).delete()
db.commit() db.commit()
session_manager.sessions.clear() session_manager.sessions.clear()
logger.info(f"Admin deleted all {count} sessions") logger.info(f"Admin deleted all {count} sessions and {removed_images} linked images")
return {"status": "deleted", "count": count} return {"status": "deleted", "count": count, "images_deleted": removed_images}
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f"Error deleting all sessions: {e}") logger.error(f"Error deleting all sessions: {e}")
@@ -636,6 +777,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.post("/session/{sid}/archive") @router.post("/session/{sid}/archive")
def archive_session(request: Request, sid: str): def archive_session(request: Request, sid: str):
"""Archive a session, keeping its data but removing it from active sessions.""" """Archive a session, keeping its data but removing it from active sessions."""
require_chat_scope(request)
_verify_session_owner(request, sid) _verify_session_owner(request, sid)
try: try:
# First check if session exists # First check if session exists
@@ -674,6 +816,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.post("/session/{sid}/unarchive") @router.post("/session/{sid}/unarchive")
def unarchive_session(request: Request, sid: str): def unarchive_session(request: Request, sid: str):
"""Restore an archived session back to the active session list.""" """Restore an archived session back to the active session list."""
require_chat_scope(request)
_verify_session_owner(request, sid) _verify_session_owner(request, sid)
db = SessionLocal() db = SessionLocal()
try: try:
@@ -704,6 +847,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.get("/sessions/archived") @router.get("/sessions/archived")
def list_archived_sessions(request: Request, search: str = "", offset: int = 0, limit: int = 20, sort: str = "recent", model: str = ""): 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.""" """List archived sessions for the archive browser."""
require_chat_scope(request)
user = effective_user(request) user = effective_user(request)
db = SessionLocal() db = SessionLocal()
try: try:
@@ -745,21 +889,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
finally: finally:
db.close() db.close()
@router.get("/history/{sid}")
def get_history(request: Request, sid: str):
_verify_session_owner(request, sid)
try:
session = session_manager.get_session(sid)
except KeyError:
raise HTTPException(404, f"Session {sid} not found")
return {"history": [msg.to_dict() for msg in session.history]}
@router.get("/session/{sid}/export") @router.get("/session/{sid}/export")
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""): def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
"""Export conversation history as a downloadable file. """Export conversation history as a downloadable file.
Supported formats: md (markdown), txt (plain text), json, html Supported formats: md (markdown), txt (plain text), json, html
""" """
require_chat_scope(request)
_verify_session_owner(request, sid) _verify_session_owner(request, sid)
try: try:
session = session_manager.get_session(sid) session = session_manager.get_session(sid)
@@ -846,6 +982,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.post("/sessions/save") @router.post("/sessions/save")
def sessions_save_now(request: Request): def sessions_save_now(request: Request):
require_chat_scope(request)
user = effective_user(request) user = effective_user(request)
if not user: if not user:
raise HTTPException(401, "Not authenticated") raise HTTPException(401, "Not authenticated")
@@ -859,6 +996,15 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
model: str = Form("gpt-4o"), model: str = Form("gpt-4o"),
rag: str = Form(None) 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: if not OPENAI_API_KEY:
raise HTTPException(400, "Server missing OPENAI_API_KEY") raise HTTPException(400, "Server missing OPENAI_API_KEY")
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
@@ -873,6 +1019,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
) )
session.headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"} session.headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
session_manager.save_sessions() session_manager.save_sessions()
if not is_bearer_principal(request):
from src.event_bus import fire_event from src.event_bus import fire_event
fire_event("session_created", user) fire_event("session_created", user)
return {"id": sid, "name": "", "model": model} return {"id": sid, "name": "", "model": model}
@@ -880,6 +1027,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.post("/session/{session_id}/important") @router.post("/session/{session_id}/important")
async def mark_session_important(request: Request, session_id: str, important: bool = Form(True)): 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.""" """Mark a session as important to protect it from automatic cleanup."""
require_chat_scope(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
# Validate session exists # Validate session exists
@@ -917,6 +1065,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.post("/session/{session_id}/compact") @router.post("/session/{session_id}/compact")
async def compact_session(request: Request, session_id: str): async def compact_session(request: Request, session_id: str):
"""Summarize older messages into one compacted history entry.""" """Summarize older messages into one compacted history entry."""
require_chat_scope(request)
capability = request_capability(request)
_verify_session_owner(request, session_id) _verify_session_owner(request, session_id)
try: try:
session = session_manager.get_session(session_id) session = session_manager.get_session(session_id)
@@ -936,14 +1086,23 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
if not older: if not older:
raise HTTPException(400, "Nothing old enough to compact") 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.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async from src.llm_core import llm_call_async
owner = getattr(session, "owner", None) or effective_user(request) owner = getattr(session, "owner", None) or effective_user(request)
if capability.allow_live_probes:
from src.endpoint_resolver import resolve_endpoint
url, model, headers = resolve_endpoint("utility", owner=owner) url, model, headers = resolve_endpoint("utility", owner=owner)
if not url or not model: if not url or not model:
url, model, headers = session.endpoint_url, session.model, session.headers 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: if not url or not model:
raise HTTPException(400, "No model configured for compaction") raise HTTPException(400, "No model configured for compaction")
@@ -961,6 +1120,9 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
for m in older for m in older
) )
try: try:
compact_kwargs = {}
if not capability.allow_live_probes:
compact_kwargs["allow_live_probes"] = False
summary = await llm_call_async( summary = await llm_call_async(
url, url,
model, model,
@@ -969,6 +1131,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
max_tokens=1024, max_tokens=1024,
headers=headers, headers=headers,
timeout=60, timeout=60,
**compact_kwargs,
) )
except Exception as e: except Exception as e:
logger.error("Manual compaction failed: %s", e) logger.error("Manual compaction failed: %s", e)
@@ -994,7 +1157,10 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
"message_count": len(new_history), "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): def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders. """Use AI to categorize all sessions into folders.
@@ -1003,6 +1169,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
after Phase 1 — used by the "Tidy (no AI)" UI affordance so after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens. users can clean junk without spending tokens.
""" """
require_chat_scope(request)
require_interactive_request(request)
from src.llm_core import llm_call from src.llm_core import llm_call
user = effective_user(request) user = effective_user(request)
single_user_mode = not user and _auth_disabled() single_user_mode = not user and _auth_disabled()
@@ -1283,6 +1451,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.get("/session/{session_id}/context_info") @router.get("/session/{session_id}/context_info")
async def get_context_info(request: Request, session_id: str): async def get_context_info(request: Request, session_id: str):
"""Get the real context length for a session's model from the endpoint.""" """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) _verify_session_owner(request, session_id)
session = session_manager.get_session(session_id) session = session_manager.get_session(session_id)
if not session: if not session:
@@ -1291,7 +1461,10 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
return {"context_length": None} return {"context_length": None}
try: try:
from src.model_context import get_context_length 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} return {"context_length": ctx, "model": session.model}
except Exception: except Exception:
return {"context_length": None} return {"context_length": None}
+203 -4
View File
@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
from core.platform_compat import IS_APPLE_SILICON, which_tool from core.platform_compat import IS_APPLE_SILICON, which_tool
from core.middleware import INTERNAL_TOOL_USER from core.middleware import INTERNAL_TOOL_USER
from src.auth_helpers import is_bearer_principal
from src.host_docker_access import ( from src.host_docker_access import (
HOST_DOCKER_ACCESS_HINT, HOST_DOCKER_ACCESS_HINT,
host_docker_access_enabled as _host_docker_access_enabled, host_docker_access_enabled as _host_docker_access_enabled,
@@ -53,6 +54,11 @@ from core.platform_compat import (
def _require_admin(request: Request): def _require_admin(request: Request):
"""Reject non-admin callers. Shell exec is admin-only — never expose to """Reject non-admin callers. Shell exec is admin-only — never expose to
regular users; that's RCE-after-signup.""" 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) auth_manager = getattr(request.app.state, "auth_manager", None)
if not auth_manager: if not auth_manager:
# No auth at all — only safe in fully-trusted localhost dev mode # No auth at all — only safe in fully-trusted localhost dev mode
@@ -157,6 +163,7 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {} binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {} dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {} modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
if name == "vllm": if name == "vllm":
return bool(binaries.get("vllm")) return bool(binaries.get("vllm"))
@@ -166,11 +173,43 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module")) return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module"))
if name == "mlx_lm": if name == "mlx_lm":
return bool(dists.get("mlx-lm") or modules.get("mlx_lm", {}).get("real_module")) return bool(dists.get("mlx-lm") or modules.get("mlx_lm", {}).get("real_module"))
if name == "mflux":
return bool(
dists.get("mflux")
or modules.get("mflux", {}).get("real_module")
or binaries.get("mflux-generate-qwen")
or binaries.get("mflux-generate")
)
if name == "boogu_image_mlx":
return bool(
dists.get("boogu-image-mlx")
or modules.get("boogu_image_mlx", {}).get("real_module")
)
if name == "mlx_lama_swift":
return bool(
(binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"))
and (files.get("mlx.metallib") or files.get("default.metallib"))
)
if name == "mlx_ddcolor_swift":
return bool(
(binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"))
and (files.get("mlx.metallib") or files.get("default.metallib"))
)
if name == "diffusers": if name == "diffusers":
return bool( return bool(
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module")) (dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
and (dists.get("torch") or modules.get("torch", {}).get("real_module")) and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
) )
if name == "krea_diffusers":
return bool(
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
)
if name == "sam_mask":
return bool(
(dists.get("transformers") or modules.get("transformers", {}).get("real_module"))
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
)
if name == "hf_transfer": if name == "hf_transfer":
return bool( return bool(
dists.get("hf-transfer") dists.get("hf-transfer")
@@ -183,6 +222,7 @@ def _package_status_note(name: str, probe: dict) -> str:
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {} binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {} modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {} dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
module = modules.get(name) if isinstance(modules.get(name), dict) else {} module = modules.get(name) if isinstance(modules.get(name), dict) else {}
locations = module.get("locations") or [] locations = module.get("locations") or []
if name == "vllm": if name == "vllm":
@@ -212,10 +252,53 @@ def _package_status_note(name: str, probe: dict) -> str:
if _package_installed_from_probe(name, probe): if _package_installed_from_probe(name, probe):
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}" return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
return "Diffusers serving needs both diffusers and torch." return "Diffusers serving needs both diffusers and torch."
if name == "krea_diffusers":
if _package_installed_from_probe(name, probe):
return f"Latest Diffusers runtime: diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}. Use Update/Reinstall to pull latest Diffusers from Git."
return "Some newer image models need torch plus latest Diffusers from Git."
if name == "sam_mask":
if _package_installed_from_probe(name, probe):
return f"SAM object masks: transformers {dists.get('transformers', 'available')} with torch {dists.get('torch', 'available')}"
return "SAM click/object mask selection needs transformers and torch."
if name == "mlx_lm": if name == "mlx_lm":
if _package_installed_from_probe(name, probe): if _package_installed_from_probe(name, probe):
return f"MLX LM {dists.get('mlx-lm', 'available')}" return f"MLX LM {dists.get('mlx-lm', 'available')}"
return "MLX serving needs mlx-lm on an Apple Silicon Mac." return "MLX serving needs mlx-lm on an Apple Silicon Mac."
if name == "mflux":
if _package_installed_from_probe(name, probe):
parts = []
if dists.get("mflux"):
parts.append(f"mflux {dists['mflux']}")
if binaries.get("mflux-generate-qwen"):
parts.append(f"Qwen CLI: {binaries['mflux-generate-qwen']}")
if binaries.get("mflux-generate"):
parts.append(f"Flux CLI: {binaries['mflux-generate']}")
return "; ".join(parts) if parts else "mflux available"
return "MLX image serving needs mflux on an Apple Silicon Mac."
if name == "boogu_image_mlx":
if _package_installed_from_probe(name, probe):
return f"Boogu MLX pipeline {dists.get('boogu-image-mlx', 'available')}"
return "Boogu image models need boogu-image-mlx on an Apple Silicon Mac."
if name == "mlx_lama_swift":
if _package_installed_from_probe(name, probe):
found = [
binaries.get("odysseus-mlx-inpaint"),
binaries.get("mlx-lama-serve"),
]
return f"LaMa/MI-GAN Swift MLX runner: {next((p for p in found if p), 'available')}"
if binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"):
return "LaMa/MI-GAN Swift runner is installed, but mlx.metallib is missing next to the runner."
return "LaMa/MI-GAN inpainting models need an Odysseus-compatible mlx-lama-swift bridge on an Apple Silicon Mac."
if name == "mlx_ddcolor_swift":
if _package_installed_from_probe(name, probe):
found = [
binaries.get("odysseus-mlx-colorize"),
binaries.get("mlx-ddcolor-serve"),
]
return f"DDColor Swift MLX runner: {next((p for p in found if p), 'available')}"
if binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"):
return "DDColor Swift runner is installed, but mlx.metallib is missing next to the runner."
return "DDColor colorization models need an Odysseus-compatible mlx-ddcolor-swift bridge on an Apple Silicon Mac."
if name in dists: if name in dists:
return f"{name} {dists[name]}" return f"{name} {dists[name]}"
return "" return ""
@@ -314,12 +397,22 @@ dist_names={{
'llama_cpp':['llama-cpp-python'], 'llama_cpp':['llama-cpp-python'],
'sglang':['sglang'], 'sglang':['sglang'],
'mlx_lm':['mlx-lm'], 'mlx_lm':['mlx-lm'],
'mlx_vlm':['mlx-vlm'],
'mflux':['mflux'],
'boogu_image_mlx':['boogu-image-mlx'],
'mlx_lama_swift':[],
'mlx_ddcolor_swift':[],
'diffusers':['diffusers','torch'], 'diffusers':['diffusers','torch'],
'krea_diffusers':['diffusers','torch'],
'sam_mask':['transformers','torch'],
'hf_transfer':['hf-transfer','hf_transfer'], 'hf_transfer':['hf-transfer','hf_transfer'],
}} }}
bin_names={{ bin_names={{
'vllm':['vllm'], 'vllm':['vllm'],
'llama_cpp':['llama-server'], 'llama_cpp':['llama-server'],
'mflux':['mflux-generate-qwen', 'mflux-generate'],
'mlx_lama_swift':['odysseus-mlx-inpaint', 'mlx-lama-serve'],
'mlx_ddcolor_swift':['odysseus-mlx-colorize', 'mlx-ddcolor-serve'],
'tmux':['tmux'], 'tmux':['tmux'],
}} }}
@@ -372,7 +465,19 @@ def probe(n):
mods['torch'] = mod_status('torch') mods['torch'] = mod_status('torch')
dists = dist_status(dist_names.get(n, [n])) dists = dist_status(dist_names.get(n, [n]))
bins = {{b: shutil.which(b) for b in bin_names.get(n, [])}} bins = {{b: shutil.which(b) for b in bin_names.get(n, [])}}
return {{'modules': mods, 'dists': dists, 'binaries': bins}} files = {{}}
if n in ('mlx_lama_swift', 'mlx_ddcolor_swift'):
for key in ('mlx.metallib', 'default.metallib'):
found = None
for b in bins.values():
if not b:
continue
p = os.path.join(os.path.dirname(b), key)
if os.path.exists(p):
found = p
break
files[key] = found
return {{'modules': mods, 'dists': dists, 'binaries': bins, 'files': files}}
print(json.dumps({{n: probe(n) for n in names}})) print(json.dumps({{n: probe(n) for n in names}}))
""" """
@@ -1088,6 +1193,8 @@ def setup_shell_routes() -> APIRouter:
ssh_port: str | None = None, ssh_port: str | None = None,
venv: str | None = None, venv: str | None = None,
backend: str | None = None, backend: str | None = None,
platform: str | None = None,
model_hint: str | None = None,
): ):
"""Check which optional packages are installed. """Check which optional packages are installed.
@@ -1104,6 +1211,14 @@ def setup_shell_routes() -> APIRouter:
import site import site
import sys import sys
platform_l = (platform or "").strip().lower()
model_hint_l = (model_hint or "").strip().lower()
has_krea_model = "krea" in model_hint_l
has_lama_mlx_model = any(
key in model_hint_l
for key in ("lama", "mi-gan", "migan", "inpainting-mlx")
)
has_ddcolor_mlx_model = "ddcolor" in model_hint_l
_prepend_user_install_bins_to_path() _prepend_user_install_bins_to_path()
importlib.invalidate_caches() importlib.invalidate_caches()
try: try:
@@ -1158,7 +1273,7 @@ def setup_shell_routes() -> APIRouter:
"name": "hf_transfer", "name": "hf_transfer",
"pip": "hf_transfer", "pip": "hf_transfer",
"desc": "Fast model downloads from HuggingFace", "desc": "Fast model downloads from HuggingFace",
"category": "LLM", "category": "Tools",
"target": "remote", "target": "remote",
}, },
{ {
@@ -1210,8 +1325,52 @@ def setup_shell_routes() -> APIRouter:
# ── Image ── editor + diffusion model serving # ── Image ── editor + diffusion model serving
{ {
"name": "diffusers", "name": "diffusers",
"pip": "diffusers[torch]", "pip": "diffusers[torch] torchvision accelerate scipy python-multipart",
"desc": "Image generation/editing pipelines (SD, Flux) with PyTorch", "desc": "Image generation/editing pipelines with PyTorch and Diffusers",
"category": "Image",
"target": "remote",
},
{
"name": "krea_diffusers",
"pip": "git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart",
"desc": "Latest Diffusers from Git for newly released image pipelines",
"category": "Image",
"target": "remote",
},
{
"name": "mflux",
"pip": "mflux",
"desc": "MLX image generation runtime for Apple Silicon models like Qwen Image",
"category": "Image",
"target": "remote",
},
{
"name": "boogu_image_mlx",
"pip": "git+https://github.com/xocialize/boogu-image-mlx.git",
"desc": "MLX image generation pipeline for Boogu Image models on Apple Silicon",
"category": "Image",
"target": "remote",
},
{
"name": "mlx_lama_swift",
"pip": "",
"desc": "Swift MLX runtime for LaMa / MI-GAN inpainting and object removal",
"category": "Image",
"target": "remote",
"install_hint": "Build an Odysseus-compatible mlx-lama-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-inpaint or mlx-lama-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable image-edit CLI.",
},
{
"name": "mlx_ddcolor_swift",
"pip": "",
"desc": "Swift MLX runtime for DDColor automatic image colorization",
"category": "Image",
"target": "remote",
"install_hint": "Build an Odysseus-compatible mlx-ddcolor-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable colorize CLI.",
},
{
"name": "mlx_vlm",
"pip": "mlx-vlm",
"desc": "MLX-VLM backbone used by HiDream image models on Apple Silicon",
"category": "Image", "category": "Image",
"target": "remote", "target": "remote",
}, },
@@ -1222,6 +1381,13 @@ def setup_shell_routes() -> APIRouter:
"category": "Image", "category": "Image",
"target": "remote", "target": "remote",
}, },
{
"name": "sam_mask",
"pip": "torch torchvision transformers accelerate pillow",
"desc": "Neutral click/box/object segmentation masks for the image editor",
"category": "Image",
"target": "local",
},
{ {
"name": "rembg", "name": "rembg",
"pip": "rembg[gpu]", "pip": "rembg[gpu]",
@@ -1251,6 +1417,21 @@ def setup_shell_routes() -> APIRouter:
for pkg in packages: for pkg in packages:
pkg.setdefault("install_cmd", None) pkg.setdefault("install_cmd", None)
pkg.setdefault("update_cmd", None) pkg.setdefault("update_cmd", None)
if not has_krea_model:
packages = [
p for p in packages
if p.get("name") not in {"krea_diffusers", "transformers"}
]
if not has_lama_mlx_model:
packages = [
p for p in packages
if p.get("name") != "mlx_lama_swift"
]
if not has_ddcolor_mlx_model:
packages = [
p for p in packages
if p.get("name") != "mlx_ddcolor_swift"
]
# Remote check: for remote-target packages, probe the selected server's # Remote check: for remote-target packages, probe the selected server's
# venv over SSH so a remote `pip install` actually reflects here. # venv over SSH so a remote `pip install` actually reflects here.
remote_status: dict = {} remote_status: dict = {}
@@ -1381,8 +1562,22 @@ def setup_shell_routes() -> APIRouter:
target_os_id = "" target_os_id = ""
if sys.platform == "darwin": if sys.platform == "darwin":
target_os_id = "macos" target_os_id = "macos"
if not target_os_id and platform_l in {"darwin", "macos", "mac"}:
target_os_id = "macos"
for pkg in packages: for pkg in packages:
if pkg.get("name") in {"mflux", "boogu_image_mlx", "mlx_vlm", "mlx_lama_swift", "mlx_ddcolor_swift"}:
is_apple_target = target_os_id == "macos" or (
not host and IS_APPLE_SILICON
)
known_non_apple_target = bool(target_os_id and target_os_id != "macos") or (
not host and not IS_APPLE_SILICON
)
pkg["applicable"] = is_apple_target
if known_non_apple_target:
pkg["installed"] = None
pkg["status_note"] = "Only relevant for Apple Silicon / MLX image serving."
continue
on_remote = bool(host and pkg.get("target") == "remote") on_remote = bool(host and pkg.get("target") == "remote")
probe = None probe = None
if on_remote: if on_remote:
@@ -1588,6 +1783,10 @@ def setup_shell_routes() -> APIRouter:
"sglang[all]", "sglang[all]",
"diffusers", "diffusers",
"diffusers[torch]", "diffusers[torch]",
"git+https://github.com/huggingface/diffusers.git",
"mflux",
"git+https://github.com/xocialize/boogu-image-mlx.git",
"mlx-vlm",
"transformers", "transformers",
"TTS", "TTS",
"bark", "bark",

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