Compare commits

...

62 commits

Author SHA1 Message Date
RaresKeY
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](9c091bb21b...3d3c42e5aa)

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

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

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](8aad20d150...ff2f1c621b)

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](8aad20d150...ff2f1c621b)

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

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

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

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](8aad20d150...ff2f1c621b)

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

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

---
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
RaresKeY
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 Adhran
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
RaresKeY
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
RaresKeY
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éo
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éo
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
RaresKeY
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
RaresKeY
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
RaresKeY
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 Teixeira
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 Grey
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éo
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
Boody
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 Sidak
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
Michael
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
Alexandre Teixeira
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
Mubelotix
45fc3938e0
Fixes Star History section in README
Fixes part of #5563
2026-08-06 19:18:56 +02:00
310 changed files with 38843 additions and 4249 deletions

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.
@ -151,6 +163,21 @@ SEARXNG_INSTANCE=http://localhost:8080
# Local HTTP setups may use the callback URL inferred by the application. # 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 # 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
# ============================================================ # ============================================================

7
.gitattributes vendored
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

View file

@ -26,6 +26,18 @@ body:
- 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:

View file

@ -28,6 +28,7 @@ Fixes #
- [ ] 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

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');
} }

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) {
'⚠️ **PR description — action needed**', commentLines.push(
'', '⚠️ **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 {
} await github.rest.issues.removeLabel({ owner, repo, issue_number: prNum, name });
try { } catch (e) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove }); if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
} catch (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.`);
} }
}; };

View file

@ -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).
@ -105,7 +105,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Make Python test validation authoritative for the configured scope. # Make Python test validation authoritative for the configured scope.
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
@ -132,7 +132,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"

View file

@ -27,15 +27,15 @@ jobs:
language: [actions, javascript-typescript, python] language: [actions, javascript-typescript, python]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: none build-mode: none
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with: with:
category: "/language:${{ matrix.language }}" category: "/language:${{ matrix.language }}"

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

View file

@ -52,17 +52,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 +93,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 +119,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

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'

View file

@ -45,20 +45,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 +86,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 +103,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: |

View file

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

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:

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.

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'

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 |

View file

@ -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](docs/setup.md#security-notes).
## Star History ## Star History
<a href="https://www.star-history.com/?repos=odysseus-dev%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=odysseus-dev/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=odysseus-dev/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=odysseus-dev/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>

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.

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.

44
app.py
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)
@ -630,13 +643,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}
@ -760,7 +784,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

View file

@ -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"

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

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,19 +200,27 @@ 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)
hosts = _pairing.lan_ip_candidates() if configured_origin:
host = hosts[0] if hosts else "127.0.0.1" host, port = configured_origin
port = request.url.port or _pairing.default_port() hosts = [host]
else:
hosts = _pairing.lan_ip_candidates()
host = hosts[0] if hosts else "127.0.0.1"
port = request.url.port or _pairing.default_port()
payload = _pairing.pairing_payload(host, port, raw_token) payload = _pairing.pairing_payload(host, port, raw_token)
qr = _pairing.pairing_qr_png_data_uri(payload) qr = _pairing.pairing_qr_png_data_uri(payload)
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=(",", ":"))

View file

@ -30,11 +30,20 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
""" """
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{uuid.uuid4().hex}" tmp = f"{path}.tmp.{uuid.uuid4().hex}"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent) try:
f.flush() with open(tmp, "w", encoding="utf-8") as f:
os.fsync(f.fileno()) json.dump(data, f, indent=indent)
os.replace(tmp, path) f.flush()
os.fsync(f.fileno())
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:
@ -42,8 +51,17 @@ def atomic_write_text(path: str, text: str) -> None:
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.{uuid.uuid4().hex}" tmp = f"{path}.tmp.{uuid.uuid4().hex}"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text) try:
f.flush() with open(tmp, "w", encoding="utf-8") as f:
os.fsync(f.fileno()) f.write(text)
os.replace(tmp, path) f.flush()
os.fsync(f.fileno())
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

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]:

View file

@ -1491,8 +1491,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}'")

View file

@ -3,10 +3,14 @@
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
# 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 +19,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:
@ -47,7 +73,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")

View file

@ -8,6 +8,11 @@ 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,
CHAT_SESSION_APPROVAL_DECISION,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .session_manager import SessionManager from .session_manager import SessionManager
@ -31,6 +36,35 @@ 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:
"""Return whether this exact chat has a resolved session-scope grant."""
expected_session = str(session_id or "")
if not expected_session:
return False
for message in reversed(history or []):
metadata = getattr(message, "metadata", None)
if not isinstance(metadata, dict):
continue
tool_events = metadata.get("tool_events")
if not isinstance(tool_events, list):
continue
for event in reversed(tool_events):
ask_user = event.get("ask_user") if isinstance(event, dict) else None
if not isinstance(ask_user, dict):
continue
if (
ask_user.get("kind") == "tool_approval"
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
and str(ask_user.get("session_id") or "") == expected_session
):
return True
return False
@dataclass @dataclass
class ChatMessage: class ChatMessage:
"""A single chat message.""" """A single chat message."""
@ -116,11 +150,27 @@ 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() msg.to_dict()
for msg in self.history for msg in self.history
if (msg.metadata or {}).get("source") != "slash" if (msg.metadata or {}).get("source") != "slash"
] ]
if not _history_grants_chat_session_approval(self.history, self.id):
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."""

View file

@ -14,6 +14,8 @@ 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.attachment_refs import persistable_message_content
@ -92,14 +94,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:

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:-}
@ -74,6 +75,11 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-} - 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
@ -129,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:-}

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:-}
@ -73,6 +74,11 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-} - 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
@ -132,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:-}

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:-}
@ -62,6 +63,11 @@ services:
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-} - 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
@ -110,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:-}

View file

@ -441,10 +441,19 @@ A grab-bag of small gotchas that otherwise turn into long debugging sessions.
| Package | Feature unlocked | | Package | Feature unlocked |
|---------|-----------------| |---------|-----------------|
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. | | `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
| `kokoro`, `soundfile` | Local Kokoro-82M text-to-speech on a CUDA GPU. The pinned Kokoro release supports Odysseus installs on Python 3.11-3.12; these packages are intentionally skipped on Python 3.13+ (including the Python 3.14 container image). |
| `ddgs` | DuckDuckGo as a search provider option. | | `ddgs` | DuckDuckGo as a search provider option. |
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) | | `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). | | `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
Install the optional set only when you need these features:
```bash
pip install -r requirements-optional.txt
```
The default Docker image currently uses Python 3.14, while Kokoro 0.9.4 declares Python `>=3.10,<3.13`. Odysseus itself continues to support Python 3.11+, but this pinned optional local-TTS feature requires a native Python 3.11 or 3.12 environment. Kokoro declares `torch`, but the local provider only activates when that torch build has CUDA and a GPU is visible; install the CUDA build appropriate for your host. Browser and configured endpoint TTS remain available on Python 3.13+ and in the container image.
### Faster, reproducible installs with uv (optional) ### Faster, reproducible installs with uv (optional)
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the [uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use: venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
@ -475,7 +484,7 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
- 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.
- Use `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. Use `SECURE_COOKIES=true` to force it on for a proxy whose scheme Odysseus cannot see, or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer. - Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default. - Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin. - Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
@ -486,6 +495,14 @@ Odysseus is a self-hosted workspace with powerful local tools: shell access, fil
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer. - Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged. - Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
> **Upgrading an existing install:** `SECURE_COOKIES` used to default to
> `false`, so an install set up before scheme derivation may still carry
> `SECURE_COOKIES=false` in its own `.env`. That explicit value stays
> authoritative, so HTTPS logins keep getting a non-`Secure` session cookie.
> Pulling this change updates the tracked Compose files, but nothing rewrites
> your `.env` — drop the line from it unless you deliberately serve plain HTTP
> alongside HTTPS and want the escape hatch.
### Private or proxied deployments ### Private or proxied deployments
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is: Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
@ -494,9 +511,162 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
3. Put the authenticated Odysseus web/API entrypoint behind that layer. 3. Put the authenticated Odysseus web/API entrypoint behind that layer.
4. Keep raw service and model ports internal-only. 4. Keep raw service and model ports internal-only.
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`. Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true` and `LOCALHOST_BYPASS=false`. Any proxy that forwards `X-Forwarded-Proto: https` gets `Secure` session cookies without configuration, so `SECURE_COOKIES` only needs setting when you want to override that — force it on for a proxy that forwards no scheme at all, or off while you still serve plain HTTP.
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry. `ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
#### Faster over the network: HTTP/2
The frontend is raw ES modules with no bundler, so a page load is a few hundred
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
number of concurrent connections per host (commonly around six), so many of
those requests are serialized across multiple round trips. On localhost that
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
part of load time, especially as latency increases.
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
HTTPS but not HTTP/2 — uvicorn does not speak it.
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
for your platform; on macOS, `brew install caddy`.
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
uses `7860`.
Public domain, Caddy obtains and renews the certificate itself:
```
odysseus.example.com {
reverse_proxy 127.0.0.1:7000
}
```
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
```bash
tailscale cert myhost.tailnet-name.ts.net
```
```
myhost.tailnet-name.ts.net {
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
reverse_proxy 127.0.0.1:7000
}
```
LAN with your own certificate — same shape, your own files:
```
odysseus.lan {
tls /path/to/cert.pem /path/to/key.pem
reverse_proxy 127.0.0.1:7000
}
```
Give `tls` absolute paths: a service starts in a working directory you did not
choose. If port 443 is already taken, append a port to the site address
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
start with `listen tcp :80: bind: address already in use` if something else
holds it. Turn the redirect off with a global block at the top of the file:
```
{
auto_https disable_redirects
}
```
**3. Run it in the foreground first:**
```bash
caddy run --config ./Caddyfile
```
Once that works, run it as a service:
```bash
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
sudo systemctl enable --now caddy # Linux, if your package installed the unit
```
Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
run the proxy as another container, or on the host pointing at the published
port.
**4. Point Odysseus at the new origin** in `.env`, then restart it.
A proxy that exposes the HTTPS request scheme to Odysseus needs no `SECURE_COOKIES` setting. Only force it on when the proxy cannot expose that scheme:
```bash
# only if the proxy cannot expose the external HTTPS scheme to Odysseus:
SECURE_COOKIES=true
# only if you use remote MCP servers with OAuth:
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
```
Gmail OAuth needs nothing here when the proxy runs on the same host: the
redirect URI is built from the incoming request, and uvicorn rewrites the
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
`127.0.0.1`. A proxy in a separate container or on another machine is not
trusted, so pin the URI there:
```bash
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
```
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
environment uvicorn starts with — `.env` is read by the app afterwards, too
late for it to take effect.)
**5. Confirm HTTP/2 is really on:**
```bash
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
# 2
```
The status code is not the thing to check here — a logged-out request redirects
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
the part that matters. The browser reports the same in the Network panel's
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
enable it by right-clicking the column headers.
Three things bite when moving an existing install behind TLS:
- Leave `SECURE_COOKIES` unset when Odysseus can see the external HTTPS scheme;
the cookie then follows the request automatically. If your proxy cannot expose
that scheme, set `SECURE_COOKIES=true` **at the same time** you stop serving
plain HTTP, not before. An explicit `true` applies to every login, so while an
HTTP entrypoint is still reachable the browser will reject the `Secure` cookie
there and login will appear to loop.
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
Gmail redirect URI it cannot be derived from a request — it is registered
with each MCP authorization server up front — so set it to the external
origin if you use remote MCP servers over OAuth.
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
https`. HSTS applies to the whole hostname and ignores the port, so any other
plain-HTTP service on that same hostname becomes unreachable in browsers that
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
the proxy (`header_down -Strict-Transport-Security` in Caddy).
Server-sent events are not buffered by this configuration, so chat streaming
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
for the same reason.
Changing the external origin also affects state scoped to it. Service workers
and their caches are origin-scoped, so moving to a different origin starts with
a cold load. Cookies follow their own domain/path/security rules rather than
being port-scoped: changing the hostname normally requires a new login, while
changing only the scheme or port does not by itself guarantee that existing
cookies disappear.
Common internal-only ports from the default docs/compose setup: Common internal-only ports from the default docs/compose setup:
| Port | Service | | Port | Service |
@ -527,7 +697,7 @@ Key settings:
| `AUTH_ENABLED` | `true` | Enable/disable login | | `AUTH_ENABLED` | `true` | Enable/disable login |
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. | | `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. | | `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. | | `SECURE_COOKIES` | derived from the request scheme | Marks session cookies `Secure` on HTTPS requests. Set true to force it on, false to force it off. |
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string | | `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. | | `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. | | `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |

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

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.

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.

View file

@ -1802,7 +1802,6 @@ 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:
@ -1843,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"}

8
package-lock.json generated
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": {

View file

@ -4,6 +4,6 @@
"url": "https://github.com/odysseus-dev/odysseus.git" "url": "https://github.com/odysseus-dev/odysseus.git"
}, },
"devDependencies": { "devDependencies": {
"@antithesishq/bombadil": "^0.6.1" "@antithesishq/bombadil": "^0.7.0"
} }
} }

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.

View file

@ -16,7 +16,7 @@ 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 get_current_user
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
@ -90,11 +90,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:

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:
@ -689,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)
@ -709,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]
@ -721,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 ----

View file

@ -15,7 +15,7 @@ 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 effective_user
from src.prompt_security import untrusted_context_message from src.prompt_security import untrusted_context_message
from src.attachment_refs import attachment_ref from src.attachment_refs import attachment_ref
@ -152,10 +152,38 @@ 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."""
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.
@ -185,10 +213,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)
@ -287,96 +313,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 = (
@ -687,6 +623,9 @@ 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,
) -> ChatContext: ) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call. """Build the full context (preface + messages) for an LLM call.
@ -710,14 +649,14 @@ async def build_chat_context(
# Add user message to history. Nobody/incognito uses a request-local # Add user message to history. Nobody/incognito uses a request-local
# transcript store instead of session history so stale saved chats cannot # transcript store instead of session history so stale saved chats cannot
# bleed into context and the turn is not persisted. # bleed into context and the turn is not persisted.
if incognito: if persist_user_message and incognito:
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta) _append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
else: elif persist_user_message:
add_user_message(sess, chat_handler, preprocessed, incognito=False) add_user_message(sess, chat_handler, preprocessed, incognito=False)
# 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)
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user; # Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
@ -729,7 +668,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)
@ -766,7 +710,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,
@ -830,13 +782,22 @@ 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)
messages, context_length, was_compacted = await maybe_compact( # Explicit fallback routing must shape from the same route-neutral prompt
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user, # 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_length = get_context_length(sess.endpoint_url, sess.model)
was_compacted = False
else:
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
_before_trim_messages = len(messages) _before_trim_messages = len(messages)
_before_trim_tokens = estimate_tokens(messages) _before_trim_tokens = estimate_tokens(messages)
messages = trim_for_context(messages, context_length) if not defer_context_shaping:
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)
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens _context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
@ -860,6 +821,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,
) )

File diff suppressed because it is too large Load diff

View file

@ -1204,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 ""

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,
@ -1336,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
@ -2166,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)

View file

@ -5004,7 +5004,6 @@ def setup_email_routes():
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
@ -5066,8 +5065,6 @@ def setup_email_routes():
pass pass
for cand in resolve_utility_fallback_candidates(owner=owner) or []: for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand) _add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
if not candidates: if not candidates:
return {"success": False, "error": "No LLM endpoint configured"} return {"success": False, "error": "No LLM endpoint configured"}
@ -5327,13 +5324,11 @@ def setup_email_routes():
# Build a candidate chain so a stale session-stored API key # Build a candidate chain so a stale session-stored API key
# (the most common cause of "authentication failed" here) # (the most common cause of "authentication failed" here)
# doesn't kill AI Reply outright — fall through to the # doesn't kill AI Reply outright — fall through to the
# user's Utility / Default endpoints and the active Utility # user's Utility / Default endpoints and active Utility fallback
# fallback chain. The retired default-fallback hook stays empty. # chain. Dedupe by url+model so we don't retry the same endpoint.
# Dedupe by url+model so we don't retry the same broken endpoint.
from src.llm_core import llm_call_async_with_fallback from src.llm_core import llm_call_async_with_fallback
from src.endpoint_resolver import ( from src.endpoint_resolver import (
resolve_utility_fallback_candidates, resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
) )
_seen = set() _seen = set()
_candidates = [] _candidates = []
@ -5358,11 +5353,9 @@ def setup_email_routes():
_add(_d_url, _d_model, _d_headers) _add(_d_url, _d_model, _d_headers)
except Exception: except Exception:
pass pass
# Active Utility fallbacks, then the retired default hook. # Active Utility fallbacks last.
for cand in resolve_utility_fallback_candidates(owner=owner) or []: for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand) _add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
_messages = [ _messages = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg}, {"role": "user", "content": user_msg},

View file

@ -475,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
return RedirectResponse(auth_url) return RedirectResponse(auth_url)
else: else:
# Remote device — show paste-back page # Remote device — show paste-back page
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri)) return HTMLResponse(_oauth_authorize_page(auth_url, server_id, redirect_uri))
finally: finally:
db.close() db.close()
@ -612,15 +612,13 @@ def setup_mcp_routes(mcp_manager: McpManager):
def _oauth_authorize_page( def _oauth_authorize_page(
auth_url: str, auth_url: str,
server_id: str, server_id: str,
host: str, redirect_uri: str,
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
) -> str: ) -> str:
"""Page with Google sign-in link and URL paste-back form for remote access.""" """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 # Escape values interpolated into the page: `server_id` comes from the OAuth
# Host header and `server_id` from the OAuth state — neither is trusted. # state and is not trusted.
auth_url = html.escape(auth_url, quote=True) auth_url = html.escape(auth_url, quote=True)
server_id = html.escape(server_id, quote=True) server_id = html.escape(server_id, quote=True)
host = html.escape(host, quote=True)
redirect_uri = html.escape(redirect_uri, quote=True) redirect_uri = html.escape(redirect_uri, quote=True)
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html><head> <html><head>
@ -664,7 +662,15 @@ def _oauth_authorize_page(
</div> </div>
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a> <a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
<div class="divider"></div> <div class="divider"></div>
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}"> <!-- 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> <p>Paste the URL from your browser after signing in:</p>
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required> <input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
<br><button type="submit">Connect</button> <br><button type="submit">Connect</button>

View file

@ -46,6 +46,7 @@ _ENDPOINT_SETTING_FIELDS = {
} }
_ENDPOINT_FALLBACK_FIELDS = { _ENDPOINT_FALLBACK_FIELDS = {
"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",
} }
@ -180,7 +181,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):
@ -1345,14 +1351,14 @@ def _legacy_visible_api_models(ep) -> List[str]:
def _picker_models_for_endpoint(ep, base_url: str, kind: str): def _picker_models_for_endpoint(ep, base_url: str, kind: str):
"""Return model IDs that should appear in the picker for an endpoint. """Return model IDs that should appear in the picker for an endpoint.
API providers expose remote inventory from /v1/models. Treat that cache as API providers expose remote inventory from /v1/models. Default to that
inventory, not approval: only manually pinned API models should appear in visible inventory until an explicit pinned-model allow-list is saved.
the picker. Local/self-hosted endpoints keep the older hide-list behavior. Local/self-hosted endpoints keep the older hide-list behavior.
""" """
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None)) pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
if _picker_requires_pinning(base_url, kind): if _picker_requires_pinning(base_url, kind):
if not _has_explicit_pinned_models(ep): if not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else [] pinned = _legacy_visible_api_models(ep)
return pinned, pinned return pinned, pinned
return _visible_models( return _visible_models(
_cached_model_ids(ep), _cached_model_ids(ep),
@ -2336,9 +2342,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)
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
pinned = _legacy_visible_api_models(ep)
pinned_set = set(pinned) pinned_set = set(pinned)
return [ return [
{ {

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:
result = rag.index_personal_documents(directory, owner=owner) def _index_directory():
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,17 +286,25 @@ 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
if hasattr(personal_docs_manager, 'remove_directory'):
personal_docs_manager.remove_directory(directory)
# Remove from RAG vector store (best-effort)
rag = _rag() rag = _rag()
if rag:
try: def _remove_directory():
rag.remove_directory(directory) # Always remove from personal_docs_manager tracking. This
except Exception as e: # mutates the same unsynchronized list/index an add job touches
logger.warning(f"RAG removal failed for directory {directory}: {e}") # and re-extracts text (refresh_index), so it is blocking work.
if hasattr(personal_docs_manager, 'remove_directory'):
personal_docs_manager.remove_directory(directory)
# Remove from RAG vector store (best-effort).
if rag:
try:
rag.remove_directory(directory)
except Exception as 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,
@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
total_failed = 0 total_failed = 0
uploaded_files = [] uploaded_files = []
for upload in files: # Chunking, embedding and the tracking update are blocking work over the
try: # same vector/tracking state add_directory mutates (#5634). Take the
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename) # shared job lock BEFORE offloading so a queued request parks on the loop
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1) # instead of pinning a threadpool worker, matching add_directory.
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES: # Read and process one capped payload at a time so a multi-file request
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}") # cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
total_failed += 1 async with _index_job_lock:
continue for upload in files:
with open(file_path, "wb") as f: try:
f.write(content_bytes) file_path, stored_name, safe_name = _unique_personal_upload_path(
upload_dir, upload.filename
ext = os.path.splitext(safe_name)[1].lower() )
if ext == ".pdf": content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
from src.personal_docs import extract_pdf_text if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
text = extract_pdf_text(file_path) logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
else:
text = content_bytes.decode("utf-8", errors="replace")
if not text or not text.strip():
total_failed += 1
continue
# Chunk and index
chunks = rag._split_into_chunks(text, chunk_size=500)
for i, chunk in enumerate(chunks):
metadata = {
"source": file_path,
"filename": safe_name,
"stored_filename": stored_name,
"directory": upload_dir,
"type": ext,
"chunk_id": i,
}
if user:
metadata["owner"] = user
if rag.add_document(chunk, metadata):
total_indexed += 1
else:
total_failed += 1 total_failed += 1
continue
uploaded_files.append(safe_name) def _index_upload():
except Exception as e: with open(file_path, "wb") as f:
logger.error(f"Failed to upload/index {upload.filename}: {e}") f.write(content_bytes)
total_failed += 1
# Track uploads directory ext = os.path.splitext(safe_name)[1].lower()
if uploaded_files and hasattr(personal_docs_manager, "add_directory"): if ext == ".pdf":
personal_docs_manager.add_directory(upload_dir, index=False) from src.personal_docs import extract_pdf_text
text = extract_pdf_text(file_path)
else:
text = content_bytes.decode("utf-8", errors="replace")
if not text or not text.strip():
return 0, 1, None
indexed = 0
failed = 0
chunks = rag._split_into_chunks(text, chunk_size=500)
for i, chunk in enumerate(chunks):
metadata = {
"source": file_path,
"filename": safe_name,
"stored_filename": stored_name,
"directory": upload_dir,
"type": ext,
"chunk_id": i,
}
if user:
metadata["owner"] = user
if rag.add_document(chunk, metadata):
indexed += 1
else:
failed += 1
return indexed, failed, 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:
logger.error(f"Failed to upload/index {upload.filename}: {e}")
total_failed += 1
# 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"):
await run_in_threadpool(
personal_docs_manager.add_directory, upload_dir, index=False
)
return { return {
"success": True, "success": True,
@ -349,38 +411,47 @@ 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:
# Remove chunks from RAG vector store (best-effort) def _delete_file():
removed = 0 # Remove chunks from RAG vector store (best-effort)
rag = _rag() removed = 0
if rag: rag = _rag()
try: if rag:
removed = rag.delete_by_source(filepath) try:
except Exception as e: removed = rag.delete_by_source(filepath)
logger.warning(f"RAG removal failed for {filepath}: {e}") except Exception as e:
logger.warning(f"RAG removal failed for {filepath}: {e}")
# Delete file from disk if it's in the caller's own uploads dir. # Delete file from disk if it's in the caller's own uploads dir.
# Scope to the per-owner subdir, not the shared uploads root, so one # Scope to the per-owner subdir, not the shared uploads root, so one
# admin can't delete another user's personal files by path. # admin can't delete another user's personal files by path.
deleted_from_disk = False deleted_from_disk = False
try:
abs_target = os.path.realpath(filepath)
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
in_uploads = (
abs_target == base_abs
or os.path.commonpath([abs_target, base_abs]) == base_abs
)
except ValueError:
# commonpath raises on mixed drives / non-comparable paths
in_uploads = False
if in_uploads and abs_target != base_abs:
try: try:
os.remove(abs_target) abs_target = os.path.realpath(filepath)
deleted_from_disk = True base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
except FileNotFoundError: in_uploads = (
pass # already gone — race with another request or cleanup abs_target == base_abs
or os.path.commonpath([abs_target, base_abs]) == base_abs
)
except ValueError:
# commonpath raises on mixed drives / non-comparable paths
in_uploads = False
if in_uploads and abs_target != base_abs:
try:
os.remove(abs_target)
deleted_from_disk = True
except FileNotFoundError:
pass # already gone — race with another request or cleanup
# 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,

View file

@ -7,6 +7,10 @@ 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():
@ -26,14 +30,27 @@ def _save(prefs):
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):
@ -45,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)

View file

@ -15,7 +15,7 @@ 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, get_current_user
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}$")
@ -496,7 +496,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:

View file

@ -18,6 +18,7 @@ from pydantic import BaseModel, Field
from services.memory.skills import SkillsManager from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user from src.auth_helpers import get_current_user
from src.prompt_security import untrusted_context_message
from core.middleware import require_admin from core.middleware import require_admin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -107,6 +108,23 @@ def _skill_test_task(skill: dict) -> str:
) )
def _skill_test_messages(md: str, task: str) -> list[dict]:
"""Keep user-editable skill text out of the trusted system role."""
return [
{
"role": "system",
"content": (
"You are TESTING a skill. Follow the supplied reusable procedure "
"to complete the user's task for real, using available tools step "
"by step. If the skill is wrong, unclear, or references tools that "
"do not exist, do your best; the problems will be reviewed afterward."
),
},
untrusted_context_message("skill under test", md),
{"role": "user", "content": task},
]
async def _eval_skill_run(skill_md: str, task: str, transcript: str, async def _eval_skill_run(skill_md: str, task: str, transcript: str,
url: str, model: str, headers: Optional[dict]) -> dict: url: str, model: str, headers: Optional[dict]) -> dict:
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only. """LLM-as-judge: grade a skill test run from its transcript. Advisory only.
@ -411,7 +429,21 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
_skill_test_jobs: dict = {} _skill_test_jobs: dict = {}
async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, skills_manager=None): async def _run_skill_test_job(
key,
name,
md,
task,
url,
model,
headers,
owner,
skills_manager=None,
*,
messages=None,
transcript=None,
exact_approval=None,
):
"""Background coroutine: run the skill in an agent loop, capture a condensed """Background coroutine: run the skill in an agent loop, capture a condensed
log + transcript, then have the judge grade it. Writes into _skill_test_jobs.""" log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
import json as _json import json as _json
@ -421,7 +453,7 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
if job is None: if job is None:
return return
log = job["log"] log = job["log"]
transcript = [] transcript = transcript if isinstance(transcript, list) else []
say_buf = [] say_buf = []
def _flush_say(): def _flush_say():
@ -429,18 +461,12 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
log.append({"type": "say", "text": "".join(say_buf)}) log.append({"type": "say", "text": "".join(say_buf)})
say_buf.clear() say_buf.clear()
messages = [ messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
{"role": "system", "content":
"You are TESTING a skill. Below is a reusable skill (a procedure). Follow it "
"to complete the user's task for real, using your available tools, step by "
"step. If the skill is wrong, unclear, or references tools that don't exist, "
"do your best — the problems will be reviewed afterward.\n\n=== SKILL ===\n" + md},
{"role": "user", "content": task},
]
try: try:
async for chunk in stream_agent_loop( async for chunk in stream_agent_loop(
url, model, messages, headers=headers, url, model, messages, headers=headers,
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner, temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
exact_approval=exact_approval,
): ):
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]": if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
continue continue
@ -458,8 +484,25 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
elif d.get("type") == "tool_output": elif d.get("type") == "tool_output":
_flush_say() _flush_say()
out = str(d.get("output") or "")[:600] out = str(d.get("output") or "")[:600]
log.append({"type": "tool_output", "output": out}) tool_log = {"type": "tool_output", "output": out}
approval = d.get("ask_user")
if isinstance(approval, dict):
tool_log["ask_user"] = approval
log.append(tool_log)
transcript.append(f"[output] {out}\n") transcript.append(f"[output] {out}\n")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
and approval.get("approval_id")
):
# Manual skill tests have their own polling UI instead of a
# chat session. Pause the run and retain only server-side
# continuation state until the same owner approves/denies
# this exact sealed action.
job["status"] = "awaiting_approval"
job["approval"] = approval
job["_transcript"] = transcript
return
elif d.get("type") == "agent_step": elif d.get("type") == "agent_step":
_flush_say() _flush_say()
log.append({"type": "agent_step", "round": d.get("round")}) log.append({"type": "agent_step", "round": d.get("round")})
@ -471,6 +514,9 @@ async def _run_skill_test_job(key, name, md, task, url, model, headers, owner, s
_flush_say() _flush_say()
log.append({"type": "error", "error": str(e)}) log.append({"type": "error", "error": str(e)})
job.pop("approval", None)
job.pop("_transcript", None)
job.pop("_run", None)
log.append({"type": "evaluating"}) log.append({"type": "evaluating"})
try: try:
job["verdict"] = await _eval_skill_run(md, task, "".join(transcript), url, model, headers) job["verdict"] = await _eval_skill_run(md, task, "".join(transcript), url, model, headers)
@ -694,12 +740,8 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
import json as _json import json as _json
from src.agent_loop import stream_agent_loop from src.agent_loop import stream_agent_loop
transcript = [] transcript = []
messages = [ approval_required = None
{"role": "system", "content": messages = _skill_test_messages(md, task)
"You are TESTING a skill. Follow this skill's procedure to complete the task "
"for real, using your tools, step by step.\n\n=== SKILL ===\n" + md},
{"role": "user", "content": task},
]
try: try:
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama, # max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
# OpenAI-compat) generate an empty completion, which manifested as # OpenAI-compat) generate an empty completion, which manifested as
@ -719,11 +761,44 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n") transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
elif d.get("type") == "tool_output": elif d.get("type") == "tool_output":
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n") transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
approval = d.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_required = approval
break
elif d.get("type") == "agent_step": elif d.get("type") == "agent_step":
transcript.append(f"\n--- round {d.get('round')} ---\n") transcript.append(f"\n--- round {d.get('round')} ---\n")
except Exception as e: except Exception as e:
transcript.append(f"\n[run error] {e}\n") transcript.append(f"\n[run error] {e}\n")
text = "".join(transcript) text = "".join(transcript)
if approval_required is not None:
# Unattended audits have no authority to approve and no UI that could
# resume this record. Destructively deny it now instead of leaving a
# reusable opaque grant pending until TTL/cap eviction.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_required.get("approval_id"),
decision="deny",
owner=owner,
session_id=None,
)
except Exception:
logger.debug("Could not retire unattended skill approval", exc_info=True)
return text, {
"verdict": "inconclusive",
"confidence": 1.0,
"summary": (
"This automated audit reached an exact action that requires "
"a human approval; no action was executed."
),
"issues": [
"Run this skill's manual test and review the sealed action."
],
"approval_required": True,
}
verdict = await _eval_skill_run(md, task, text, url, model, headers) verdict = await _eval_skill_run(md, task, text, url, model, headers)
return text, verdict return text, verdict
@ -863,6 +938,26 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
transcript, verdict = await _run_skill_test_once(md, task, url, model, headers, owner) transcript, verdict = await _run_skill_test_once(md, task, url, model, headers, owner)
v = verdict.get("verdict") v = verdict.get("verdict")
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})") log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
if verdict.get("approval_required"):
# An unattended audit is not authority for an action influenced by the
# skill under test. Preserve the skill's current publication/confidence
# state and route the exact action to the manual test UI instead of
# letting a safety pause demote, rewrite, or auto-publish the skill.
skills_manager.set_audit(
name,
"inconclusive",
by_teacher=False,
worker_model=model,
owner=owner,
)
status = skill.get("status") or "draft"
log(f"{name}: {status} unchanged — exact action needs manual approval")
return {
"skill": name,
"result": "approval_required",
"verdict": verdict,
"status": status,
}
if v == "pass": if v == "pass":
# Procedure works. If the reviewer still flagged metadata (tags/category/ # Procedure works. If the reviewer still flagged metadata (tags/category/
# when_to_use/description), do ONE fixer pass to correct the frontmatter # when_to_use/description), do ONE fixer pass to correct the frontmatter
@ -1431,6 +1526,19 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
logger.warning(f"Skill-test model resolve failed: {_e}") logger.warning(f"Skill-test model resolve failed: {_e}")
key = (user or "", name) key = (user or "", name)
previous_job = _skill_test_jobs.get(key) or {}
previous_approval = previous_job.get("approval") or {}
if previous_approval.get("approval_id"):
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
previous_approval["approval_id"],
decision="deny",
owner=user,
session_id=None,
)
except Exception:
logger.debug("Could not retire replaced skill approval", exc_info=True)
_skill_test_jobs[key] = { _skill_test_jobs[key] = {
"status": "running", "status": "running",
"task": task, "task": task,
@ -1439,10 +1547,138 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"started": _time.time(), "started": _time.time(),
"log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}], "log": [{"type": "skill_test_start", "task": task, "skill": name, "model": model}],
"verdict": None, "verdict": None,
"_run": {
"md": md,
"url": url,
"model": model,
"headers": headers,
"owner": user,
},
} }
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager)) _asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
return {"ok": True, "status": "running", "skill": name, "model": model} return {"ok": True, "status": "running", "skill": name, "model": model}
@router.post("/{skill_id}/test-approval")
async def approve_skill_test_action(request: Request, skill_id: str):
"""Resume a manual skill test with one exact server-sealed action."""
import asyncio as _asyncio
from src.tool_approvals import tool_approval_store
user = _owner(request)
skills = skills_manager.load(owner=user)
match = next(
(s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id),
None,
)
if not match:
raise HTTPException(404, "Skill not found")
_verify_owner(match, user)
name = match.get("name")
key = (user or "", name)
job = _skill_test_jobs.get(key)
if not job or job.get("status") != "awaiting_approval":
raise HTTPException(409, "This skill test is not awaiting an approval.")
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Tool approval body must be a JSON object.")
approval_id = str(body.get("approval_id") or "")
decision = str(body.get("decision") or "").strip().lower()
expected = job.get("approval") or {}
if approval_id != str(expected.get("approval_id") or ""):
raise HTTPException(409, "This approval does not match the pending skill test action.")
if decision not in {"approve", "deny"}:
raise HTTPException(400, "Invalid tool approval decision.")
pending = tool_approval_store.peek(approval_id)
normalized_owner = str(user or "").strip().casefold()
if (
pending is None
or pending.owner != normalized_owner
or pending.session_id != ""
):
raise HTTPException(409, "This tool approval is invalid or expired.")
exact_approval = tool_approval_store.consume(
approval_id,
decision=decision,
owner=user,
session_id=None,
# The button here says "Allow once" and there is no chat to carry a
# scope into, so the gate must re-arm behind the sealed action.
allow_continuation=False,
)
if decision == "approve" and exact_approval is None:
raise HTTPException(409, "This tool approval could not be consumed.")
job.pop("approval", None)
if decision == "deny":
job.pop("_transcript", None)
job.pop("_run", None)
job["log"].append({
"type": "approval_denied",
"text": "Exact action denied; the skill test stopped without executing it.",
})
job["verdict"] = {
"verdict": "inconclusive",
"confidence": 1.0,
"summary": "The test stopped because its exact action was denied.",
"issues": [],
}
job["status"] = "done"
return {"ok": True, "status": "done", "decision": "deny"}
run = job.get("_run") or {}
transcript = job.pop("_transcript", [])
# stream_agent_loop owns its per-round message list internally. Rebuild
# continuation context from the original untrusted skill plus the
# accumulated transcript so repeated approvals do not lose earlier
# approved results, while keeping every transcript byte tainted.
messages = _skill_test_messages(
run.get("md", ""),
job.get("task", ""),
)
if transcript:
messages.append(untrusted_context_message(
"skill test transcript",
"".join(str(item) for item in transcript),
))
messages.extend([
{
"role": "assistant",
"content": str(expected.get("question") or "Allow this exact action once?"),
},
{
"role": "user",
"content": (
f"Approved the exact {exact_approval.pending.tool_name} "
"action shown above once."
),
},
])
job["status"] = "running"
job["log"].append({
"type": "approval_granted",
"text": (
f"Approved exact {exact_approval.pending.tool_name} action once; "
"resuming test."
),
})
_asyncio.create_task(_run_skill_test_job(
key,
name,
run.get("md", ""),
job.get("task", ""),
run.get("url"),
run.get("model"),
run.get("headers"),
run.get("owner"),
skills_manager,
messages=messages,
transcript=transcript,
exact_approval=exact_approval,
))
return {"ok": True, "status": "running", "decision": "approve"}
@router.get("/{skill_id}/test-status") @router.get("/{skill_id}/test-status")
async def test_skill_status(request: Request, skill_id: str): async def test_skill_status(request: Request, skill_id: str):
"""Current background-test state for a skill (status / log / verdict).""" """Current background-test state for a skill (status / log / verdict)."""
@ -1459,6 +1695,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"model": job.get("model"), "model": job.get("model"),
"log": job.get("log", []), "log": job.get("log", []),
"verdict": job.get("verdict"), "verdict": job.get("verdict"),
"approval": job.get("approval"),
} }
@router.post("/audit-all") @router.post("/audit-all")

5
routes/task/__init__.py Normal file
View file

@ -0,0 +1,5 @@
"""Task route domain package (slice 2p, #4082/#4071).
Contains task_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/task_routes.py re-exports from here.
"""

1181
routes/task/task_routes.py Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Make retained SearXNG settings inherit defaults without replacing them."""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import yaml
from yaml.nodes import MappingNode
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
_UTF8_BOM = b"\xef\xbb\xbf"
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
"""Parse settings with the same safe YAML semantics SearXNG uses."""
try:
loaded = yaml.safe_load(text)
node = yaml.compose(text, Loader=yaml.SafeLoader)
except yaml.YAMLError:
raise ValueError("settings file is not valid single-document YAML") from None
if loaded is None and node is None:
return None, {}
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
raise ValueError("settings root is not a mapping")
return node, loaded
def _flow_mapping_start(text: str) -> int:
"""Return the root flow mapping's opening-brace character offset."""
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if isinstance(token, FlowMappingStartToken):
return token.start_mark.index
except yaml.YAMLError:
pass
raise ValueError("flow-style settings mapping has no opening brace")
def _newline_for(contents: bytes) -> bytes:
first_lf = contents.find(b"\n")
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
return b"\r\n"
return b"\n"
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
"""Return a safe character offset and indent for a root block mapping key."""
if root is None:
return len(text), 0
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if not isinstance(token, BlockMappingStartToken):
continue
line_start = token.start_mark.index - token.start_mark.column
if not text[line_start : token.start_mark.index].strip():
return line_start, token.start_mark.column
return root.end_mark.index, token.start_mark.column
except yaml.YAMLError:
pass
return root.end_mark.index, root.start_mark.column
def _add_block_default_inheritance(
contents: bytes, text: str, root: MappingNode | None
) -> bytes:
newline = _newline_for(contents)
character_offset, indent_width = _block_mapping_position(text, root)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[:character_offset].encode("utf-8"))
separator = b""
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
separator = newline
addition = (
separator
+ b" " * indent_width
+ b"use_default_settings: true"
+ newline
)
return contents[:offset] + addition + contents[offset:]
def migrate_settings(path: Path) -> bool:
"""Add the missing inheritance key atomically; return whether the file changed."""
source_stat = path.lstat()
if not stat.S_ISREG(source_stat.st_mode):
raise ValueError(f"settings path is not a regular file: {path}")
contents = path.read_bytes()
if not contents:
return False
text = contents.decode("utf-8-sig")
root, loaded = _parse_root_mapping(text)
if "use_default_settings" in loaded:
return False
if root is not None and root.flow_style:
start = _flow_mapping_start(text)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[: start + 1].encode("utf-8"))
separator = b", " if root.value else b""
updated = (
contents[:offset]
+ b"use_default_settings: true"
+ separator
+ contents[offset:]
)
else:
updated = _add_block_default_inheritance(contents, text, root)
fd, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.odysseus-", dir=path.parent
)
temporary = Path(temporary_name)
try:
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
# file belongs to searxng:searxng — which every retained settings file
# does, because searxng's entrypoint chowns /etc/searxng — root can no
# longer chmod it and the migration dies with EPERM.
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
with os.fdopen(fd, "wb") as handle:
fd = -1
handle.write(updated)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if fd >= 0:
os.close(fd)
temporary.unlink(missing_ok=True)
return True
def main(argv: list[str]) -> int:
if len(argv) > 2:
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
return 2
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
try:
changed = migrate_settings(path)
except (OSError, UnicodeError, ValueError) as exc:
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
return 1
if changed:
print("Added use_default_settings inheritance to retained SearXNG settings")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View file

@ -327,7 +327,12 @@ def list_models():
@app.post("/v1/images/generations") @app.post("/v1/images/generations")
def generate(req: ImageRequest): def generate(req: ImageRequest):
model = req.model or _args.model # The served model is the one this process was launched with. `req.model`
# is accepted for OpenAI wire compatibility and ignored, matching
# scripts/diffusion_server.py: honouring it would let a caller point the
# generator at any local directory or Hugging Face repo, and the HiDream
# branch runs a python script from inside that directory.
model = _args.model
width, height = _size(req.size) width, height = _size(req.size)
out_images = [] out_images = []
count = max(1, min(int(req.n or 1), 4)) count = max(1, min(int(req.n or 1), 4))
@ -393,7 +398,7 @@ async def edit_image(
size: str = Form("1024x1024"), size: str = Form("1024x1024"),
response_format: str = Form("b64_json"), response_format: str = Form("b64_json"),
): ):
active_model = model or _args.model active_model = _args.model # pinned; see generate()
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model): if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
image_raw = await image.read() image_raw = await image.read()
mask_raw = await mask.read() if mask is not None else None mask_raw = await mask.read() if mask is not None else None

View file

@ -2,7 +2,7 @@
"""odysseus-webhook — shell wrapper for scheduled-task webhook tokens. """odysseus-webhook — shell wrapper for scheduled-task webhook tokens.
Tasks in the scheduled-task system can carry a `webhook_token`. Any Tasks in the scheduled-task system can carry a `webhook_token`. Any
HTTP POST to `/api/webhook/<token>` fires the task. This CLI lists, HTTP POST to `/api/tasks/<task-id>/webhook/<token>` fires the task. This CLI lists,
rotates, and revokes those tokens. rotates, and revokes those tokens.
odysseus-webhook list # tasks that have a token odysseus-webhook list # tasks that have a token
@ -21,6 +21,7 @@ quiet_logs()
import argparse, json, logging, os, secrets, sys import argparse, json, logging, os, secrets, sys
from pathlib import Path from pathlib import Path
from urllib.parse import quote
try: try:
from core.database import SessionLocal, ScheduledTask from core.database import SessionLocal, ScheduledTask
@ -53,6 +54,14 @@ def _summary(t: "ScheduledTask", reveal: bool = False) -> dict:
} }
def _task_webhook_url(base: str, task_id: str, token: str) -> str:
"""Build the live task-route URL without leaking ids into path syntax."""
root = (base or "http://localhost:7000").rstrip("/")
task_part = quote(str(task_id), safe="")
token_part = quote(str(token), safe="")
return f"{root}/api/tasks/{task_part}/webhook/{token_part}"
def cmd_list(args): def cmd_list(args):
db = SessionLocal() db = SessionLocal()
try: try:
@ -109,8 +118,7 @@ def cmd_url(args):
fail(f"no task with id {args.id!r}") fail(f"no task with id {args.id!r}")
if not t.webhook_token: if not t.webhook_token:
fail(f"task {args.id!r} has no webhook token (rotate one first)") fail(f"task {args.id!r} has no webhook token (rotate one first)")
base = (args.base or "http://localhost:7000").rstrip("/") url = _task_webhook_url(args.base, t.id, t.webhook_token)
url = f"{base}/api/webhook/{t.webhook_token}"
emit({ emit({
"task_id": t.id, "task_id": t.id,
"name": t.name, "name": t.name,

View file

@ -50,16 +50,46 @@ class DocsService:
List of DocChunk objects List of DocChunk objects
""" """
results = self.rag.search(query, k=top_k) results = self.rag.search(query, k=top_k)
return [ chunks = []
DocChunk(
text=r.get("text", r.get("content", "")), for result in results:
source=r.get("source", r.get("metadata", {}).get("source", "unknown")), if not isinstance(result, dict):
score=r.get("score", 0.0), continue
metadata=r.get("metadata"),
metadata = result.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
text = result.get("document")
if text is None:
text = result.get("text")
if text is None:
text = result.get("content")
if text is None:
text = ""
source = result.get("source")
if source is None:
source = metadata.get("source")
if source is None:
source = "unknown"
score = result.get("similarity")
if score is None:
score = result.get("score")
if score is None:
score = 0.0
chunks.append(
DocChunk(
text=text,
source=source,
score=score,
metadata=metadata,
)
) )
for r in results
if isinstance(r, dict) return chunks
]
async def index(self, directory: str) -> IndexResult: async def index(self, directory: str) -> IndexResult:
""" """
@ -73,8 +103,8 @@ class DocsService:
""" """
result = self.rag.index_personal_documents(directory) result = self.rag.index_personal_documents(directory)
return IndexResult( return IndexResult(
indexed=result.get("indexed", 0), indexed=result.get("indexed_count", result.get("indexed", 0)),
failed=result.get("failed", 0), failed=result.get("failed_count", result.get("failed", 0)),
errors=result.get("errors", []), errors=result.get("errors", []),
) )

View file

@ -1,16 +1,18 @@
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs.""" """Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
from __future__ import annotations from __future__ import annotations
import ipaddress
import logging import logging
import os import os
import re import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple from typing import Dict, Iterable, List, Optional, Tuple, cast
from urllib.parse import quote, urljoin, urlparse from urllib.parse import quote, urljoin, urlparse
import httpcore
import httpx import httpx
from src.url_safety import check_outbound_url from src.url_safety import _default_resolver, check_outbound_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,6 +27,7 @@ TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
_GITHUB_HOSTS = frozenset({ _GITHUB_HOSTS = frozenset({
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com", "github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
}) })
_SKILLS_SH_HOSTS = frozenset({"skills.sh", "www.skills.sh"})
def _github_host(url: str) -> str: def _github_host(url: str) -> str:
@ -72,18 +75,158 @@ def _is_text_file(name: str) -> bool:
_MAX_FETCH_REDIRECTS = 5 _MAX_FETCH_REDIRECTS = 5
def _check_fetch_url(url: str) -> None: def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
"""SSRF guard for skill-import fetches (defense-in-depth). """Parse and de-duplicate one resolver snapshot in resolver order."""
ips: List[ipaddress._BaseAddress] = []
seen = set()
for raw in raw_ips:
if not isinstance(raw, str):
continue
try:
ip = ipaddress.ip_address(raw.split("%", 1)[0])
except ValueError:
continue
if ip in seen:
continue
seen.add(ip)
ips.append(ip)
return ips
Skill bundles only ever come from public GitHub, never an internal
address, so block private/loopback/link-local targets on every hop def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
matching the hardened web-fetch path in """Return the exact address snapshot approved for one fetch hop."""
``services/search/content.py:_get_public_url`` rather than the lenient resolved_ips: List[str] = []
default used for admin-configured model endpoints.
""" def _recording_resolver(host: str) -> List[str]:
ok, reason = check_outbound_url(url, block_private=True) answers = list(_default_resolver(host))
resolved_ips[:] = answers
return answers
ok, reason = check_outbound_url(
url,
block_private=True,
resolver=_recording_resolver,
)
if not ok: if not ok:
raise SkillImportError(reason) raise SkillImportError(f"outbound URL blocked: {reason}")
pinned_ips = _validated_ips(resolved_ips)
if not pinned_ips:
raise SkillImportError("outbound URL blocked: host did not resolve to a usable address")
return pinned_ips
# Backward compatibility alias for tests importing _check_fetch_url directly
_check_fetch_url = _resolve_and_check_url
class _PinnedBackend(httpcore.NetworkBackend):
"""Connect only to addresses from one validated DNS snapshot."""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._ips = [str(ip) for ip in ips]
self._real = httpcore.SyncBackend()
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
):
deadline = None if timeout is None else time.monotonic() + timeout
last_exc: Optional[Exception] = None
for ip in self._ips:
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
try:
return self._real.connect_tcp(
ip,
port,
remaining,
local_address,
socket_options,
)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
last_exc = exc
if deadline is not None and time.monotonic() >= deadline:
break
if last_exc is not None:
raise last_exc
raise httpcore.ConnectError("no validated address available")
def connect_unix_socket(self, path, timeout=None, socket_options=None):
return self._real.connect_unix_socket(path, timeout, socket_options)
def sleep(self, seconds: float) -> None:
return self._real.sleep(seconds)
_HTTPCORE_TO_HTTPX_EXC = {
httpcore.ConnectError: httpx.ConnectError,
httpcore.ConnectTimeout: httpx.ConnectTimeout,
httpcore.LocalProtocolError: httpx.LocalProtocolError,
httpcore.NetworkError: httpx.NetworkError,
httpcore.PoolTimeout: httpx.PoolTimeout,
httpcore.ProtocolError: httpx.ProtocolError,
httpcore.ProxyError: httpx.ProxyError,
httpcore.ReadError: httpx.ReadError,
httpcore.ReadTimeout: httpx.ReadTimeout,
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
httpcore.TimeoutException: httpx.TimeoutException,
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
httpcore.WriteError: httpx.WriteError,
httpcore.WriteTimeout: httpx.WriteTimeout,
}
class _PinnedTransport(httpx.BaseTransport):
"""Pin socket connects while preserving URL authority, Host, and TLS SNI."""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._pinned_ips = list(ips)
self._pool = httpcore.ConnectionPool(
ssl_context=httpx.create_ssl_context(),
http1=True,
http2=False,
network_backend=_PinnedBackend(ips),
)
def handle_request(self, request: httpx.Request) -> httpx.Response:
core_request = httpcore.Request(
method=request.method,
url=httpcore.URL(
scheme=request.url.raw_scheme,
host=request.url.raw_host,
port=request.url.port,
target=request.url.raw_path,
),
headers=request.headers.raw,
content=request.stream,
extensions=request.extensions,
)
core_response = None
try:
core_response = self._pool.handle_request(core_request)
content = b"".join(cast(Iterable[bytes], core_response.stream))
except Exception as exc:
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
if mapped is not None:
raise mapped(str(exc)) from exc
raise
finally:
if core_response is not None:
core_response.close()
return httpx.Response(
status_code=core_response.status,
headers=core_response.headers,
content=content,
extensions=core_response.extensions,
)
def close(self) -> None:
self._pool.close()
def _get_checked( def _get_checked(
@ -100,49 +243,76 @@ def _get_checked(
hand lets us re-validate every hop, closing that blind-SSRF gap. hand lets us re-validate every hop, closing that blind-SSRF gap.
""" """
current = url current = url
with httpx.Client(follow_redirects=False, timeout=timeout) as client: for _ in range(_MAX_FETCH_REDIRECTS + 1):
for _ in range(_MAX_FETCH_REDIRECTS + 1): pinned_ips = _resolve_and_check_url(current)
_check_fetch_url(current) with httpx.Client(
transport=_PinnedTransport(pinned_ips),
follow_redirects=False,
timeout=timeout,
) as client:
r = client.get(current, headers=headers) r = client.get(current, headers=headers)
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("location") if r.status_code in (301, 302, 303, 307, 308):
if not location: location = r.headers.get("location")
return r if not location:
current = urljoin(str(r.url), location) return r
continue current = urljoin(str(r.url), location)
return r continue
return r
raise SkillImportError("too many redirects while fetching skill bundle") raise SkillImportError("too many redirects while fetching skill bundle")
def parse_skill_source(url: str) -> ResolvedSource: def parse_skill_source(url: str) -> ResolvedSource:
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path.""" """Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
raw = (url or "").strip() url = (url or "").strip()
if not raw: if not url:
raise SkillImportError("URL is required") raise SkillImportError("URL is required")
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later. # ``urlparse`` only reports an unambiguous scheme when the URL carries the
if "skills.sh" in raw and "github.com" not in raw: # ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
r = _get_checked(raw, timeout=20.0) # schemeless ``host:port`` both parse a "scheme" that is not one, so they
# fall through to the host check below and are rejected on the host instead.
scheme = urlparse(url).scheme.lower()
if scheme not in ("http", "https"):
if scheme and url.lower().startswith(f"{scheme}://"):
raise SkillImportError(f"unsupported URL scheme: {scheme}")
# Schemeless "github.com/owner/repo" — accept only a supported host.
rough_host = (urlparse("//" + url).hostname or "").lower()
if rough_host not in _GITHUB_HOSTS and rough_host not in _SKILLS_SH_HOSTS:
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
url = "https://" + url
parsed = urlparse(url)
hostname = (parsed.hostname or "").lower()
if hostname not in _GITHUB_HOSTS and hostname not in _SKILLS_SH_HOSTS:
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
# A skills.sh link is only usable if it redirects to an exact supported
# GitHub host. Scraping the page body for a github.com link cannot work:
# skill pages only ever link the repository root, never the skill's
# subdirectory, so the scrape resolves every skill in a repo to the same
# (wrong) bundle. Fail with an actionable message instead.
if hostname in _SKILLS_SH_HOSTS:
r = _get_checked(url, timeout=20.0)
if r.status_code >= 400: if r.status_code >= 400:
raise _github_response_error(r) raise _github_response_error(r)
final = str(r.url) final = str(r.url)
_assert_github_url(final, context="redirect target") if _github_host(final) not in _GITHUB_HOSTS:
# Page may embed a github link; prefer final URL if redirected. raise SkillImportError(
if "github.com" in final: "skills.sh did not redirect to GitHub — open the skill's "
raw = final "repository on GitHub, navigate to the exact skill folder or "
else: "SKILL.md file, and paste that URL; the repository-root link "
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "") "alone is not sufficient"
if m: )
raw = m.group(0).rstrip(".,)") url = final
parsed = urlparse(raw) # Update parsed and hostname to reflect the new GitHub URL
host = _github_host(raw) parsed = urlparse(url)
if host not in _GITHUB_HOSTS: hostname = (parsed.hostname or "").lower()
raise SkillImportError(
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
)
if host == "raw.githubusercontent.com": _assert_github_url(url)
if hostname == "raw.githubusercontent.com":
# /owner/repo/ref/path/to/file # /owner/repo/ref/path/to/file
bits = [p for p in parsed.path.split("/") if p] bits = [p for p in parsed.path.split("/") if p]
if len(bits) < 4: if len(bits) < 4:

View file

@ -2,22 +2,18 @@
import copy import copy
import io import io
import ipaddress
import json import json
import os import os
import re import re
import logging import logging
import socket
import ssl
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Iterable, List, cast from typing import List
from urllib.parse import urljoin, urlparse
import httpx import httpx
import httpcore
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT from src.constants import WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES, WEB_FETCH_USER_AGENT
from src import outbound_fetch as _outbound_fetch
from .analytics import RateLimitError, error_logger from .analytics import RateLimitError, error_logger
from .cache import ( from .cache import (
@ -29,336 +25,40 @@ from .cache import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_PRIVATE_NETWORKS = ( def _is_private_address(addr):
ipaddress.ip_network("0.0.0.0/8"), return _outbound_fetch._is_private_address(addr)
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
)
def _is_private_address(addr: ipaddress._BaseAddress) -> bool: def _resolve_hostname_ips(hostname):
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None: return _outbound_fetch._resolve_hostname_ips(hostname)
addr = addr.ipv4_mapped
return (
addr.is_private def _public_http_url(url):
or addr.is_loopback return _outbound_fetch._public_http_url(url, resolver=_resolve_hostname_ips)
or addr.is_link_local
or addr.is_reserved
or addr.is_multicast def _resolve_public_ips(url):
or addr.is_unspecified return _outbound_fetch._resolve_public_ips(url, resolver=_resolve_hostname_ips)
or any(addr in net for net in _PRIVATE_NETWORKS)
_PinnedBackend = _outbound_fetch._PinnedBackend
_PinnedTransport = _outbound_fetch._PinnedTransport
BodyTooLargeError = _outbound_fetch.BodyTooLargeError
_CappedFetch = _outbound_fetch._CappedFetch
def _get_public_url(url, headers, timeout, max_redirects=5, max_bytes=None):
return _outbound_fetch._get_public_url(
url,
headers=headers,
timeout=timeout,
max_redirects=max_redirects,
max_bytes=max_bytes,
resolve_public_ips=_resolve_public_ips,
transport_factory=_PinnedTransport,
) )
def _resolve_hostname_ips(hostname: str) -> list[ipaddress._BaseAddress]:
try:
infos = socket.getaddrinfo(hostname, None)
except Exception:
return []
out = []
for info in infos:
try:
out.append(ipaddress.ip_address(info[4][0]))
except Exception:
continue
return out
def _public_http_url(url: str) -> bool:
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
host = (parsed.hostname or "").strip()
if not host:
return False
lower = host.lower()
if lower in ("localhost", "metadata", "metadata.google.internal"):
return False
if lower.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
return False
try:
return not _is_private_address(ipaddress.ip_address(host))
except ValueError:
pass
addrs = _resolve_hostname_ips(host)
return bool(addrs) and not any(_is_private_address(a) for a in addrs)
except Exception:
return False
def _resolve_public_ips(url: str) -> list[ipaddress._BaseAddress]:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise httpx.RequestError(f"Blocked non-public URL: {url}")
host = (parsed.hostname or "").strip().lower()
if host in ("localhost", "metadata", "metadata.google.internal"):
raise httpx.RequestError(f"Blocked non-public hostname: {host}")
try:
ip = ipaddress.ip_address(host)
if _is_private_address(ip):
raise httpx.RequestError(f"Blocked non-public IP literal: {host}")
return [ip]
except httpx.RequestError:
raise
except ValueError:
pass
addrs = _resolve_hostname_ips(host)
if not addrs or any(_is_private_address(a) for a in addrs):
raise httpx.RequestError(f"Blocked non-public URL: {url}")
return addrs
class _PinnedBackend(httpcore.NetworkBackend):
"""Network backend that connects to a pre-resolved IP.
httpcore derives the TLS SNI and the ``Host`` header from the URL's
origin, not from the host argument passed to ``connect_tcp``. So
routing the TCP connect to a resolved IP while leaving the URL
untouched keeps SNI / vhost behaviour correct and closes the
DNS-rebinding TOCTOU between the SSRF check and the connect.
"""
def __init__(self, ip: ipaddress._BaseAddress):
self._ip = str(ip)
self._real = httpcore.SyncBackend()
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
):
return self._real.connect_tcp(
self._ip, port, timeout, local_address, socket_options
)
def connect_unix_socket(self, path, timeout=None, socket_options=None):
return self._real.connect_unix_socket(path, timeout, socket_options)
def sleep(self, seconds: float) -> None:
return self._real.sleep(seconds)
# Map httpcore exception classes to their httpx equivalents. Built
# once at import time from the public exception classes; avoids any
# import of httpx's private transport machinery. httpcore's
# ``ConnectionNotAvailable`` is a pool-internal signal (the pool will
# close and retry on its own) — we never expect to see it surface to
# a transport caller, so it has no httpx counterpart here.
_HTTPCORE_TO_HTTPX_EXC = {
httpcore.ConnectError: httpx.ConnectError,
httpcore.ConnectTimeout: httpx.ConnectTimeout,
httpcore.LocalProtocolError: httpx.LocalProtocolError,
httpcore.NetworkError: httpx.NetworkError,
httpcore.PoolTimeout: httpx.PoolTimeout,
httpcore.ProtocolError: httpx.ProtocolError,
httpcore.ProxyError: httpx.ProxyError,
httpcore.ReadError: httpx.ReadError,
httpcore.ReadTimeout: httpx.ReadTimeout,
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
httpcore.TimeoutException: httpx.TimeoutException,
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
httpcore.WriteError: httpx.WriteError,
httpcore.WriteTimeout: httpx.WriteTimeout,
}
class _PinnedTransport(httpx.BaseTransport):
"""Transport that pins every TCP connect to a pre-resolved IP.
Uses only the public ``httpcore`` and ``httpx`` APIs no
subclassing of ``httpx.HTTPTransport``, no reads of private
``httpcore.ConnectionPool`` attributes, no imports from
``httpx private transport internals``. The URL is passed through unchanged so SNI
/ vhost work as if httpx had been given the hostname directly;
only the TCP destination is pinned, closing the DNS-rebinding
TOCTOU between the SSRF check and the connect.
"""
def __init__(self, ip: ipaddress._BaseAddress, *, http2: bool = False):
self._pool = httpcore.ConnectionPool(
ssl_context=ssl.create_default_context(),
http1=True,
http2=http2,
network_backend=_PinnedBackend(ip),
)
def __enter__(self):
self._pool.__enter__()
return self
def __exit__(self, exc_type=None, exc_value=None, traceback=None) -> None:
self._pool.__exit__(exc_type, exc_value, traceback)
def handle_request(self, request: httpx.Request) -> httpx.Response:
httpcore_req = httpcore.Request(
method=request.method,
url=httpcore.URL(
scheme=request.url.raw_scheme,
host=request.url.raw_host,
port=request.url.port,
target=request.url.raw_path,
),
headers=request.headers.raw,
content=request.stream,
extensions=request.extensions,
)
try:
httpcore_resp = self._pool.handle_request(httpcore_req)
# Eager materialisation matches the original
# ``response.text`` usage in fetch_webpage_content. The
# sync pool's stream is a plain Iterable[bytes] despite
# the httpcore type hint unioning the async variant.
content = b"".join(cast(Iterable[bytes], httpcore_resp.stream))
except Exception as exc:
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
if mapped is not None:
raise mapped(str(exc)) from exc
raise
return httpx.Response(
status_code=httpcore_resp.status,
headers=httpcore_resp.headers,
content=content,
extensions=httpcore_resp.extensions,
)
def close(self) -> None:
self._pool.close()
class BodyTooLargeError(Exception):
"""The server declared a body larger than the hard fetch ceiling."""
def __init__(self, url: str, declared_bytes: int):
self.url = url
self.declared_bytes = declared_bytes
super().__init__(
f"response body is {declared_bytes:,} bytes, over the "
f"{WEB_FETCH_HARD_MAX_BYTES:,}-byte hard cap"
)
class _CappedFetch:
"""Result of a size-capped streaming GET.
Carries just what fetch_webpage_content needs from an httpx.Response,
plus the cap bookkeeping: the (possibly truncated) body, whether the
cap cut it short, and the size the server declared via Content-Length
(wire bytes; None when absent).
"""
__slots__ = ("status_code", "headers", "content", "truncated",
"declared_bytes", "encoding", "url")
def __init__(self, status_code, headers, content, truncated,
declared_bytes, encoding, url):
self.status_code = status_code
self.headers = headers
self.content = content
self.truncated = truncated
self.declared_bytes = declared_bytes
self.encoding = encoding
self.url = url
@property
def text(self) -> str:
return self.content.decode(self.encoding or "utf-8", errors="replace")
def raise_for_status(self):
if self.status_code >= 400:
request = httpx.Request("GET", self.url)
raise httpx.HTTPStatusError(
f"HTTP {self.status_code} for {self.url}",
request=request,
response=httpx.Response(self.status_code, request=request),
)
def _get_public_url(url: str, headers: dict, timeout: int, max_redirects: int = 5,
max_bytes: int = None) -> "_CappedFetch":
"""Capped streaming GET with SSRF-guarded, DNS-pinned manual redirects.
Each hop is resolved once, validated as public, and then the actual TCP
connection is pinned to that resolved IP. The request URL is left unchanged
so Host and TLS SNI keep the original hostname.
"""
cap = min(max_bytes or WEB_FETCH_SOFT_MAX_BYTES, WEB_FETCH_HARD_MAX_BYTES)
current = url
for _ in range(max_redirects + 1):
ips = _resolve_public_ips(current)
# Force identity transfer-encoding. With gzip/deflate the wire bytes
# and Content-Length can be a small fraction of the decoded body, so a
# tiny compressed response could pass the hard-cap preflight and then
# expand past the ceiling in one decoded chunk before the streamed cap
# below can slice it.
req_headers = dict(headers or {})
req_headers["Accept-Encoding"] = "identity"
with httpx.Client(
headers=req_headers,
timeout=timeout,
follow_redirects=False,
transport=_PinnedTransport(ips[0]),
) as client:
with client.stream("GET", current) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location")
if not location:
return _CappedFetch(response.status_code, response.headers, b"",
False, None, response.encoding, str(response.url))
current = urljoin(str(response.url), location)
continue
# A server can ignore the identity request and still return a
# compressed body; httpx.iter_bytes would then decode it, and a
# tiny gzip can balloon into one decoded chunk far past the cap.
# Refuse compressed Content-Encoding so the streamed cap stays
# a real memory bound.
enc = (response.headers.get("content-encoding") or "").strip().lower()
if enc and enc != "identity":
raise httpx.RequestError(
f"Refusing compressed response (Content-Encoding: {enc}) after "
"requesting identity: cannot bound decoded body size",
request=httpx.Request("GET", current),
)
declared = None
raw_len = response.headers.get("content-length")
if raw_len and raw_len.isdigit():
declared = int(raw_len)
if declared is not None and declared > WEB_FETCH_HARD_MAX_BYTES:
raise BodyTooLargeError(current, declared)
chunks = []
read = 0
truncated = False
for chunk in response.iter_bytes():
read += len(chunk)
if read > cap:
keep = cap - (read - len(chunk))
if keep > 0:
chunks.append(chunk[:keep])
truncated = True
break
chunks.append(chunk)
return _CappedFetch(response.status_code, response.headers,
b"".join(chunks), truncated, declared,
response.encoding, str(response.url))
raise httpx.RequestError("Too many redirects", request=httpx.Request("GET", current))
# PDF extraction (optional dependency) # PDF extraction (optional dependency)
try: try:
from pdfminer.high_level import extract_text as pdf_extract_text from pdfminer.high_level import extract_text as pdf_extract_text

88
specs/_readme.md Normal file
View file

@ -0,0 +1,88 @@
# Specs DocumentMap
Last updated: dev@e71f8ce | 2026-08-25
This folder is the compact implementation-truth map for humans and coding agents working on Odysseus. Read this file first, then open only the subsystem specs that match the work.
Specs are living notes about current code shape and intended contracts. They are not product marketing, not PR planning, not templates, and not a replacement for source inspection or tests.
This `_readme.md` is the DocumentMap and control document. It is intentionally exempt from subsystem `Scope` and `Current Gaps` sections; keep it limited to the quality contract, working rules, subsystem map, and cross-cutting update triggers.
## Quality Contract
Each subsystem spec should stay compact and useful under context pressure:
- Start with `Last updated: dev@<short-sha> | YYYY-MM-DD`, using the
upstream `dev` commit the spec text was inspected against.
- Use a concrete `Scope` section that names real files, route surfaces, frontend modules, data stores, and integration points.
- Use domain-specific sections. Do not force every spec into the same headings when the subsystem needs `Streaming`, `Tool Results`, `Optional Dependencies`, `Current Gaps`, or another focused section.
- State ownership clearly: which file owns a mapping, which layer only forwards state, and which caller requests behavior without owning implementation.
- Include runtime behavior bullets for flows that matter.
- Include "Current call sites include" when behavior is spread across many files.
- Record transitional compatibility notes, especially `src/` versus `services/` duplication.
- Record degraded, optional, or platform behavior where it changes runtime expectations.
- Record policy/provenance where relevant: untrusted context, encrypted secrets, API token scopes, optional dependency/license implications, generated media, or user data.
- End with `Current Gaps` only when there is a real known gap, not as filler.
If code and specs disagree, treat code as ground truth. Update specs only when
the current task explicitly includes spec maintenance or the PR intentionally
includes specs; otherwise report the drift in the relevant issue, PR review, or
project documentation.
## Working Rules
- Start here before substantial work.
- Read the related subsystem spec before changing code in that area. For cross-cutting work, include the owning domain spec plus route/runtime, auth/security, persistence, frontend, tool/context, integration, and testing/devops specs as applicable.
- Treat specs as read-only context during ordinary project work, PR review, and code review. Do not edit specs unless the user explicitly asks for spec work or the current PR intentionally includes spec changes.
- During explicit spec-maintenance work, update the related spec when source inspection shows behavior, ownership, security boundaries, data shape, import paths, or implementation contracts have changed.
- During ordinary work, record source/spec drift in the relevant issue, PR review, or project documentation instead of mutating specs.
- Keep specs dense but readable. Prefer current facts and invariants over broad explanation.
- Every non-index `specs/*.md` file should appear exactly once in the Subsystem Map with a one-line description and no dead link.
- Specs contain implementation truth. Planning, research, branch notes, and decisions belong in tracked project docs. Drafts, audit reports, raw exports, and exploratory gap lists are not authoritative until promoted into tracked docs or specs.
- Use repo source and these specs as the authority for Odysseus architecture. Do not treat global skill registries or external agent metadata as repo ground truth.
## Subsystem Map
- [runtime.md](runtime.md): FastAPI startup, router registration, static serving, lifespan, app-wide middleware.
- [auth-security.md](auth-security.md): auth, privileges, API tokens, security headers, untrusted data, SSRF and admin boundaries.
- [persistence.md](persistence.md): SQLite models, startup migrations, encrypted columns, ownership columns, data directory rules.
- [chat.md](chat.md): chat routes, sessions, streaming, uploads-in-chat, compare handoff, research/chat mode dispatch.
- [compare.md](compare.md): model A/B comparison runs, voting/history, compare frontend panes, compare ownership.
- [llm-models.md](llm-models.md): LLM provider calls, endpoint discovery, model context length, fallbacks, model endpoints.
- [model-capability-canonical.md](model-capability-canonical.md): canonical provider/model capability shapes, evidence, payload resolution, and safe fallback.
- [model-quirks.md](model-quirks.md): model-specific behavior observations, evidence, and promotion gates.
- [model-providers/_readme.md](model-providers/_readme.md): provider-by-provider API/catalog shape index and compatibility status.
- [agent-tools.md](agent-tools.md): agent loop, tool schemas, tool execution, tool retrieval, tool security, MCP tool exposure.
- [context-building.md](context-building.md): URL/search/RAG/memory/skills/YouTube/email/tool-output context, untrusted wrapping, unavailable context, intent boundaries.
- [search.md](search.md): web search providers, ranking, cache/analytics, URL fetch/content extraction, `src.search`/`services.search` split.
- [documents-rag-uploads.md](documents-rag-uploads.md): uploads, documents, PDF/form handling, personal docs, RAG/vector stores.
- [memory-skills.md](memory-skills.md): memory storage, semantic memory, skill extraction/formatting, owner isolation.
- [research.md](research.md): deep research jobs, synthesis, sources, research library, research UI panel.
- [calendar-tasks-notes.md](calendar-tasks-notes.md): CalDAV calendars, scheduled tasks, reminders, assistant runs, notes/todos.
- [email-contacts.md](email-contacts.md): IMAP/SMTP email, email library, scheduled mail, contacts/CardDAV.
- [gallery-editor-media.md](gallery-editor-media.md): gallery, generated media, image editor drafts, signatures, emoji/font helpers.
- [cookbook-hwfit.md](cookbook-hwfit.md): model downloads, local/remote model serving, hardware detection, fit ranking.
- [speech.md](speech.md): STT and TTS services, routes, settings, optional dependencies.
- [frontend.md](frontend.md): static SPA, module loading, UI conventions, major JS areas, no-build frontend shape.
- [integrations.md](integrations.md): Codex/Claude scoped APIs, companion pairing, webhooks, external agent access.
- [shell-mcp.md](shell-mcp.md): shell execution, background jobs, MCP manager, built-in MCP servers.
- [settings-admin.md](settings-admin.md): settings, preferences, presets, backup/import/export, diagnostics, admin wipe.
- [testing-devops.md](testing-devops.md): pytest, JS tests, Docker, scripts, requirements, local dev expectations.
## Cross-Cutting Spec Update Triggers
Use these triggers only during explicit spec-maintenance work or a PR that
intentionally includes specs. For ordinary work and code review, use the same
list to choose which specs to read and where to report drift.
- New route file or route prefix: update [runtime.md](runtime.md) and the owning subsystem spec.
- New SQLAlchemy model, column migration, durable JSON/local store, data directory, backup/import domain, or non-SQL persistence behavior: update [persistence.md](persistence.md) and the owning subsystem spec.
- New tool, tool schema, agent prompt rule, or tool security behavior: update [agent-tools.md](agent-tools.md) and [context-building.md](context-building.md) if it adds model context.
- New MCP runtime/config/built-in behavior: update [shell-mcp.md](shell-mcp.md), [agent-tools.md](agent-tools.md), and [context-building.md](context-building.md) when MCP tool results enter model context.
- New external content source, tool result, MCP/app API result, or integration result shown to an LLM: update [context-building.md](context-building.md) and [auth-security.md](auth-security.md).
- New API-token scope, scoped external API, webhook, companion/pairing route, generic integration provider, or external-agent helper bundle: update [integrations.md](integrations.md), [auth-security.md](auth-security.md), and the owning subsystem spec.
- New secret store, decrypted-secret return path, settings backup/import/export behavior, diagnostics/log output, vault/tool secret flow, `.env*` policy change, or credential-bearing CLI output: update [auth-security.md](auth-security.md), [settings-admin.md](settings-admin.md), [testing-devops.md](testing-devops.md), and the owning subsystem spec.
- New optional dependency, degraded fallback, platform/Docker/native/launcher difference, GPU overlay behavior, or retired compatibility shim: update [testing-devops.md](testing-devops.md) and the owning subsystem spec; also update [runtime.md](runtime.md), [llm-models.md](llm-models.md), [shell-mcp.md](shell-mcp.md), [cookbook-hwfit.md](cookbook-hwfit.md), or [persistence.md](persistence.md) when that layer owns the behavior.
- New frontend module or modal/tool surface: update [frontend.md](frontend.md) and the owning subsystem spec.
- New static/PWA/service-worker/cache/CSP behavior: update [frontend.md](frontend.md), [runtime.md](runtime.md), and [auth-security.md](auth-security.md) when headers or trust boundaries change.
- New CLI script: update [testing-devops.md](testing-devops.md) and the owning subsystem spec.

157
specs/agent-tools.md Normal file
View file

@ -0,0 +1,157 @@
# Agent Tools
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers agent/tool behavior in:
- `src/agent_loop.py`;
- `src/llm_core.py`;
- `src/tool_schemas.py`;
- `src/tool_execution.py`;
- `src/tool_policy.py`;
- `src/tool_index.py`;
- `src/tool_parsing.py`;
- `src/tool_security.py`;
- `src/tool_capabilities.py`;
- `src/tool_approval_scopes.py`;
- `src/tool_approvals.py`;
- `src/attachment_refs.py` and shared upload lifecycle helpers in
`src/upload_handler.py` / `src/tool_utils.py`;
- `src/tool_implementations.py`;
- `src/tools/*.py`;
- `src/builtin_actions.py`;
- `src/ai_interaction.py`;
- `src/action_intents.py`;
- `src/goal_based_extractor.py`;
- `src/teacher_escalation.py`;
- `src/agent_tools/` modules and compatibility facade;
- `src/mcp_manager.py`;
- `src/builtin_mcp.py`;
- `src/bg_jobs.py` and `src/bg_monitor.py`;
- `routes/chat_routes.py`, `routes/chat_helpers.py`, `routes/model_routes.py`, `routes/skills_routes.py`, canonical `routes/mcp/mcp_routes.py` plus its shim, and `routes/workspace_routes.py`;
- `mcp_servers/*.py`;
- frontend stream/admin/settings files that display tool events, workspaces, and disabled tools;
- `tests/test_agent_loop.py`, `tests/test_tool_*`, and focused MCP/public-policy/schema tests.
## Agent Loop
`src.agent_loop` owns agent prompt assembly, request-local current date/time insertion, tool retrieval, prompted tool-block handling, native tool-call consumption after `llm_core` normalizes provider events, multi-round execution, tool result insertion, final metrics, and fallback responses. It requests context from documents, skills, tool retrieval, and messages; it should not own domain-specific business logic for every tool. Its prompt rules now bias structured/long-form writing toward living documents, route active compose/email drafts back into existing email documents, and prefer first-class `web_search`/`web_fetch` tools over shell/Python/curl for current web lookups when web tools are enabled.
`src.llm_core` owns provider payloads, native tool-schema emission, and provider stream parsing. `agent_loop` consumes normalized tool-call events and decides whether and how to execute them.
Agent mode enters through chat routes, including auto-escalation from intent helpers, detached `agent_runs` streaming, resume/stop behavior, and frontend tool-event rendering.
Guide-only/no-tools turns are runtime policy, not prompt advice. `src.tool_policy` detects strong latest-turn directives such as guide-only mode, no-tools mode, and explicit requests not to use tools; it builds a `ToolPolicy` that hides schemas, disables known native tools, disables MCP for that turn, skips tool retrieval, suppresses local/workspace context injection, blocks document streaming/teacher escalation, and gives `tool_execution` a final execution backstop.
Plan mode is a read-only investigation path inside the same loop. It adds a denylist for known mutating tools, filters write/unknown MCP tools, prepends plan-mode instructions, and uses the `update_plan` tool only after a plan is approved for execution. The backend path still exists for compatibility, but current browser chat forces incoming `plan_mode` off and the old plan-window UI module is gone.
Workspace mode is request-scoped. Admin chat can send a workspace directory selected through `static/js/workspace.js`; `agent_loop` injects that fact early in the prompt and `tool_execution` confines bash, python, read/write/edit-file, and code-navigation tools to that root. `routes.workspace_routes` owns admin-only browse/vet APIs, skips hidden/symlink directory traversal, caps listings, and rejects sensitive/root paths before a workspace reaches chat.
## Tool Registry
Tool registration is split:
- `src.agent_tools` is now a package/facade. `TOOL_HANDLERS` maps native tool names to handler functions across filesystem, subprocess, web, document, interaction, model-interaction, background-job, session, and admin modules, while `TOOL_TAGS` keeps compatibility metadata and the global MCP manager handle;
- `src.tools` owns domain do_* implementations for calendar, contacts, Cookbook, image, notes, research, search, system, and vault tools. `src.tool_implementations` is now a compatibility facade that re-exports those symbols and lazy-loads admin manage_* symbols to avoid circular imports;
- `src.agent_tools.admin_tools` owns admin manage_* tools for endpoints, MCP, webhooks, tokens, and settings, including command validation for `manage_mcp`;
- `src.tool_parsing._TOOL_NAME_MAP` owns aliases and prompted-block parsing;
- `src.tool_schemas.FUNCTION_TOOL_SCHEMAS` and `function_call_to_tool_block()` own native schema and native-call conversion;
- `src.tool_index.BUILTIN_TOOL_DESCRIPTIONS` owns retrieval text;
- `src.tool_execution.execute_tool_block()` owns dispatch and hard execution gates;
- `routes.model_routes.py` and frontend settings/admin surfaces expose global disabled-tool controls.
When adding, removing, or renaming a tool, update the registry chain, execution dispatch, retrieval text, prompt wording, disabled-tool UI, and tests together.
`src.tool_index.ALWAYS_AVAILABLE` is the retrieval catalog for high-frequency tools such as shell/python, web search/fetch, read/write/edit-file, code-nav, `manage_memory`, `ask_user`, `update_plan`, selected Cookbook serve controls, and `app_api`. Current prompt/schema assembly preserves only selected base tools unconditionally, then adds intent-, skill-, and retrieval-relevant tools so unrelated schemas do not flood small contexts.
## Tool Retrieval And Execution
`src.tool_index.ToolIndex` owns candidate retrieval using embeddings/keywords and cached index data. Security filtering is not its hard boundary: `agent_loop` hides unavailable schemas, and `tool_execution` blocks disabled, admin-only, and public-restricted calls before dispatch.
`src.tool_execution` owns built-in tool execution, MCP dispatch, path confinement, background markers, output truncation, internal HTTP loopback, owner/admin checks, policy-blocked execution results, and formatting tool results for the model/UI. File tools support exact edit diffs, full-file writes, read line ranges, and workspace confinement. Code-navigation tools (`grep`, `glob`, `ls`) prefer `rg`/structured filesystem traversal over ad hoc shell commands. Uploaded-file context uses stable `attachment_ref` manifests and owner-checked URIs; a compatibility local path is exposed only after upload-root and tool-root confinement. Shared truncation, upload-handler registration, and MCP manager compatibility helpers live in `src.tool_utils`.
Tool retrieval has domain-specific hooks beyond generic similarity: contact queries can surface `resolve_contact`/`manage_contact`; matched skills can add `manage_skills` and their required toolsets to the relevant tool set; explicit admin intents can include admin schemas so prompt text and native schema emission match.
Interaction/session/model helper tools are native first-class tools, not prompt-only conventions. `ask_user` and `update_plan` live in `src.agent_tools.interaction_tools`, model delegation/listing helpers live in `model_interaction_tools`, session creation/list/send/manage helpers live in `session_tools`, and `manage_bg_jobs` lives in `bg_job_tools`.
Prompted-tool parsing includes recovery paths for local/provider text leaks: bare JSON after a web-tool mention, OpenAI-style raw `{"function": ...}` payloads, StepFun/Gemma/DSML markup, Hermes/Qwen JSON bodies nested inside `tool_call` wrappers, and `<function_model><function_call>...</function_call><parameters>...</parameters></function_model>` wrappers from local MLX/Exo models. The Qwen bare end marker requires its pipe delimiter so ordinary text cannot terminate a tool block. Non-dict JSON arguments are rejected back to empty args instead of crashing the turn, common `tex` typos normalize to `text`, and delimiter scans are forward-only so unterminated tool markup cannot drive quadratic rescans. Executed raw tool JSON is stripped from assistant text afterward; this is still not a general-purpose JSON-command parser.
Current call sites include:
- agent mode tool calls from `src.agent_loop`;
- MCP route configuration and built-in MCP registration;
- background job monitoring and auto-continue;
- skill tests, teacher escalation, scheduled tasks, and background follow-up loops;
- UI-control and AI interaction helpers.
## Streaming And Continuations
Agent streaming emits normal content plus tool progress/output, document stream/update, ask-user choices, plan updates, budget, round exhaustion, loop-breaker, intent-nudge exhaustion, metrics, teacher escalation, research anchor, and finish/error events. Frontend chat stream code and detached replay depend on stable event names. If the stream generator closes while awaiting an in-flight tool, the loop cancels and awaits that tool task so subprocess-backed work is not left orphaned.
Long-running bash jobs can be detached with background markers. `src.bg_jobs` owns persistent job state/result files; `src.bg_monitor` owns auto-continuation when jobs finish. Detached chat runs are in-memory and do not survive server restart, while background job state is disk-backed.
Loop-breaker final-answer rounds, explicit repeated-tool/intent-nudge guard events, round-cap continuation signals, optional verifier retries, and teacher escalation are recovery behavior owned by `agent_loop` and `src.teacher_escalation`.
Approval replay injects the sealed first tool result before the resumed model round. If that replay round has neither assistant prose nor reasoning, `_append_tool_results()` omits the empty assistant spacer so Anthropic-compatible payloads do not contain a rejected non-final empty assistant message; reasoning-only carriers remain a documented compatibility edge.
## Security And Policy
- `src.tool_security` owns non-admin blocked-tool decisions.
- Non-admin users must not reach admin tools through agent mode, MCP, retrieval, or loopback calls.
- Agent owner is passed from chat route `get_current_user(request)`. In `AUTH_ENABLED=false` mode this is `None`, not the `""` value returned by route dependencies. `blocked_tools_for_owner()`, schema hiding, and `execute_tool_block()` all use that owner.
- Current dev tool security treats explicit `AUTH_ENABLED=false` as single-user even when an auth store exists, while auth-enabled pre-setup callers remain non-admin.
- Path-based tools must remain confined to allowed roots and reject sensitive paths. Sensitive-path checks are case-insensitive and apply to direct file tools and code-navigation tools; `grep`/`glob`/`ls` must not become existence or content oracles for `.env`, SSH/GPG material, `id_rsa`, and similar denylisted paths.
- Tool output is bounded/truncated where native execution owns the path, including displayed agent-tool output through the shared truncation helper. MCP output must be treated as untrusted; central MCP-output truncation before model re-entry remains a gap.
- Provider-emitted native tool calls are requests, not authorization. `tool_execution` and route-level policy remain the authority.
- `src.tool_capabilities` classifies each tool's effects and result integrity. Once external/workspace-untrusted content becomes model-visible, the request/session security context permits only explicitly low-impact tools without interruption and requires exact approval for high-impact, unknown, and arbitrary MCP calls.
- `src.tool_approvals` seals an opaque, expiring exact first action plus server-only selected tools and continuation query to owner, session, origin run, tool content, workspace, capability snapshot, and—when relevant—document id/version/content digest. Chat choices grant the resumed task or the same chat session; both consume the exact first action, task scope bypasses the gate only during that resumed run, and chat scope is reconstructed only from a resolved card bound to the exact session id. The browser never receives selected tools/query and submits only task/chat/deny. Non-chat callers retain single-action behavior; new normal turns and superseding actions retire unresolved approvals without clearing taint.
- Tool results that expose remote or stored untrusted content arm the gate even when their tool status is failed. Content-free failures and server-generated policy/approval placeholders do not. Native/provider tool messages and fenced results carry model-visible untrusted metadata/wrapping instead of relying on prompt wording alone.
- Attachment-bearing document, note, and calendar tools owner-reserve internal
upload references before durable writes and fail without mutation when the
referenced upload is unavailable.
- Guide-only/no-tools mode blocks tools before prompt assembly, before execution, and in chat preprocessing paths that would otherwise fetch context or start tool-backed research.
- Plan mode is policy, not prompt advice: mutating native tools are disabled through schema-derived detection plus a static backstop, and write/unknown MCP tools are hidden and runtime-blocked for that turn.
## Internal Loopback
`do_app_api()` is implemented in `src.tools.system` and re-exported by `src.tool_implementations`. It owns generic app API loopback, OpenAPI discovery, method/path blocklists, and fixed local target behavior. `_internal_headers()` adds the process-secret internal-tool token and optional `X-Odysseus-Owner`; `core.middleware.require_admin()` and auth middleware own the corresponding bypass and owner-stamping rules. Route-specific owner handling must still be audited.
## MCP
`src.mcp_manager` owns configured MCP server lifecycle, discovered tool state, qualified MCP names, OpenAI schema conversion, call routing, generation invalidation, and connect/disconnect status. It supports stdio, SSE, and Streamable HTTP transports; Streamable HTTP can publish a `needs_auth` state and uses `src.mcp_oauth` for OAuth/OIDC-style authorization, token refresh, and encrypted token storage. Arbitrary MCP tools classify fail-high for approvals. `src.builtin_mcp` owns built-in server registration and the native-vs-MCP split. `mcp_servers/` owns server-specific tools for email, image generation, memory, RAG, and optional browser tooling.
Native bash, python, file, web search, and web fetch tools continue through native fallback even when MCP is unavailable. Browser MCP is optional and can be skipped when cached Playwright/NPX packages are missing. Public users get no MCP schemas, and any `mcp__*` execution attempt must be blocked.
MCP prompt/schema rendering includes server-provided input schemas, but names, types, and parameter hint text are sanitized and length-capped before entering the prompt. Per-server disabled tools filter listings, prompt descriptions, and function schemas; execution-time disabled-tool enforcement remains a separate hardening item.
## Intent And Recovery Helpers
`src.action_intents` owns deterministic chat-to-agent promotion hints and returns a category/reason so route logs can explain auto-escalation decisions. Explicit web-search language is category `web`; it can promote the turn into agent mode and narrow tools toward web search/fetch, but route policy requires explicit web-search enablement and honors explicit denial. It must avoid promoting explanatory questions into agent mode. `src.builtin_actions` owns scheduler/background actions outside the normal live agent loop. `src.teacher_escalation` owns recovery/escalation and skill-creation flows. `src.goal_based_extractor` is research-adjacent and should stay cross-referenced from research behavior rather than treated as ordinary tool execution.
When an email reader is active, browser chat passes active email metadata and the agent loop injects it as protected, untrusted context so default reply/draft behavior targets the selected message. Active email compose documents are handled as existing email drafts rather than generic new-document requests.
## Degraded Behavior
- ToolIndex can degrade to keyword selection when embeddings, Chroma, index
warmup, or vector retrieval timeouts fail.
- Agent mode can degrade from native function schemas to prompted fenced-block parsing based on provider/tool-support heuristics. Local Ollama `/v1` and native `/api` endpoints default to text tools unless the endpoint explicitly advertises `supports_tools`; `gpt-oss` remains text-tool by default unless the endpoint opts in.
- MCP startup failure is non-critical; route/status surfaces expose per-server errors.
- `ODYSSEUS_DISABLE_MCP`, missing `mcp`, uncached browser MCP packages, and per-server disabled tools can remove tools without blocking the app.
- Global `builtin_browser` disable behavior may not currently match qualified `mcp__builtin_browser__*` tool names.
## Current Gaps
- Tool descriptions are duplicated across `FUNCTION_TOOL_SCHEMAS`, agent prompt sections, and `BUILTIN_TOOL_DESCRIPTIONS`.
- Agent prompts remain heavy for small local context windows.
- Some AI-control helpers are still globally wired from app startup rather than a narrower service layer.
- Tool registry consistency is manual across handler maps, tags, aliases, schemas, retrieval descriptions, execution dispatch, settings/model routes, and frontend toggles.
- MCP disabled-tool changes can stale-cache tool retrieval because disabled maps are not always an index generation input.
- External MCP output still needs a single central size cap before model re-entry; untrusted-result metadata and the post-external-context action gate now cover the prompt-injection/authorization boundary.
- Auth-disabled/no-login owner propagation is inconsistent between route dependencies and chat/agent execution, so tool-security and native tool storage behavior need dedicated regression coverage.
- Agent tests mostly cover helpers and targeted regressions, including round-cap
and disconnect cancellation paths, but not an end-to-end fake-LLM
`stream_agent_loop` path with retrieval, native schemas, prompted blocks,
disabled/admin hiding, MCP tools, plan/workspace state, user-time context, and
tool-result SSE.

View file

@ -1,412 +0,0 @@
# Architecture Runtime Inventory
> **Purpose**: Phase 0 planning baseline for codebase readability improvements (#4071).
> **Parent issue**: [#4082](https://github.com/odysseus-dev/odysseus/issues/4082)
> **Last updated**: dev@b58af42 | 2026-06-16
> **Status**: Draft — to be reviewed before follow-up slices open.
> **Snapshot basis**: Importer / file / import-line counts are refreshed to `dev@b58af42` (2026-06-16) and are recomputable via the commands in §3.4. **Line counts** in §2.1 / §2.2 are a snapshot from an earlier baseline and drift as `dev` moves — recompute any of them with `wc -l <file>`. This inventory tracks structure and risk, not live metrics.
This document maps the current runtime module structure, identifies high-risk boundaries, and recommends safe first refactor slices. It does **not** move files, change imports, or alter runtime behavior.
---
## 1. Current Structure Overview
### 1.1 Top-Level Layout
```
odysseus/
├── app.py # FastAPI app entrypoint (1,145 lines)
├── conf/ # Configuration (config.py, settings.py, settings_scrub.py)
├── src/ # 95 flat .py files + 2 subdirectories
│ ├── agent_tools/ # Tool helpers: document, filesystem, subprocess, web
│ └── search/ # Search subsystem
├── routes/ # 54 flat .py files — HTTP route handlers
├── core/ # 10 files — database models, auth, middleware, session
├── mcp_servers/ # 5 files — MCP server implementations
├── scripts/ # CLI tools and one-shot scripts
├── static/ # Frontend HTML/CSS/JS
├── tests/ # 583 test files (~54,800 lines)
└── services/ # (exists as needed)
```
### 1.2 Directory Flatness Metric
| Directory | Flat `.py` Files | Subdirectories | Concern |
|-----------|-----------------|----------------|---------|
| `src/` | **95** | 2 (`agent_tools/`, `search/`) | No domain grouping; 95 files in one directory |
| `routes/` | **54** | 0 | All route handlers in one flat directory |
| `core/` | 10 | 0 | Manageable, but `database.py` is oversized |
---
## 2. Largest Runtime Modules
### 2.1 Python Backend
| Rank | File | Lines | Classes | Functions | Risk |
|------|------|-------|---------|-----------|------|
| 1 | `src/tool_implementations.py` | **4,032** | 0 | ~48 | **HIGH** |
| 2 | `routes/email_routes.py` | **3,245** | — | — | **MEDIUM** |
| 3 | `routes/cookbook_routes.py` | **2,969** | — | — | **MEDIUM** |
| 4 | `src/agent_loop.py` | **2,961** | 0 | ~24 | **HIGH** |
| 5 | `src/task_scheduler.py` | **2,330** | — | 5 | MEDIUM |
| 6 | `routes/model_routes.py` | **2,266** | — | — | MEDIUM |
| 7 | `core/database.py` | **2,265** | 28 | ~59 helpers | **HIGH** |
| 8 | `src/builtin_actions.py` | **2,262** | 2 | ~24 | MEDIUM |
| 9 | `src/llm_core.py` | **2,164** | — | — | MEDIUM |
| 10 | `mcp_servers/email_server.py` | 2,197 | — | — | LOW (separate process) |
| 11 | `src/visual_report.py` | 1,918 | — | — | LOW |
| 12 | `routes/gallery_routes.py` | 1,896 | — | — | LOW |
| 13 | `src/ai_interaction.py` | 1,846 | — | — | MEDIUM |
| 14 | `routes/document_routes.py` | 1,717 | — | — | LOW |
| 15 | `routes/skills_routes.py` | 1,648 | — | — | LOW |
**Heuristic**: Files > 2,000 lines with 20+ public symbols and many importers are the highest-risk splits. Files 1,0002,000 lines are medium-risk if tightly coupled.
### 2.2 Frontend
| File | Lines | Concern |
|------|-------|---------|
| `static/style.css` | **36,653** | Entire app CSS in one file (tracked separately in #2617) |
| `static/js/document.js` | **9,776** | Single JS file for document functionality |
| `static/js/slashCommands.js` | 6,498 | |
| `static/js/settings.js` | 5,266 | |
| `static/js/emailLibrary.js` | 5,217 | |
| `static/js/notes.js` | 5,124 | |
| `static/js/chat.js` | 4,985 | |
| `static/app.js` | 4,090 | |
**Note**: Frontend modularization is tracked separately in #2617 (CSS) and is not the focus of this Phase 0 inventory. Frontend is listed here for completeness but follow-up slices should target Python backend boundaries first.
---
## 3. Import Dependency Graph
### 3.1 Who Depends on `core/database.py`
**102 files** import from `core.database` — this is the most depended-upon module:
- All route handlers (`routes/*.py`)
- Most `src/*.py` files
- `core/session_manager.py`, `core/auth.py`
- Multiple test files
**Implication**: Any split of `core/database.py` is the highest-risk refactor. It should be tackled **last**, never first.
### 3.2 Who Depends on `src/tool_implementations.py`
**17 files** import from `src.tool_implementations`:
- `src/agent_loop.py`, `src/builtin_actions.py`, `src/tool_index.py`
- `src/task_scheduler.py`, `src/tool_policy.py`
- Various tests
### 3.3 Who Depends on `src/agent_loop.py`
**22 files** import from `src.agent_loop`:
- `src/tool_policy.py`, `src/teacher_escalation.py`, `src/bg_monitor.py`
- `src/task_scheduler.py`
- Multiple test files
### 3.4 Cross-Layer Import Violations
**`src/` importing from `routes/`** (backwards dependency — domain logic depending on HTTP layer):
```
src/tool_implementations.py ──→ routes/calendar_routes.py
src/tool_implementations.py ──→ routes/cookbook_helpers.py
src/tool_implementations.py ──→ routes/email_helpers.py
src/tool_implementations.py ──→ routes/email_pollers.py
src/tool_implementations.py ──→ routes/email_routes.py
src/tool_implementations.py ──→ routes/model_routes.py
src/tool_implementations.py ──→ routes/note_routes.py
src/tool_implementations.py ──→ routes/prefs_routes.py
```
> These are **runtime imports** (inside function bodies, not at module top), which mitigates circular import risk but indicates fuzzy layer boundaries. Function-level inline imports from the HTTP layer into business logic are a code smell.
**Import counts (top-level)**:
| Direction | Count | Notes |
|-----------|-------|-------|
| `routes/``src/` | **374** | Expected: HTTP handlers call domain logic |
| `routes/``core/` | **126** | Expected: handlers access DB models |
| `src/``routes/` | **31** | **Unexpected**: domain logic reaching into HTTP layer (direct grep of import lines referencing `routes/`) |
| `src/``core/` | **106** | Acceptable but could be reduced with a data-access layer |
> **How the metrics in this document are computed** — recompute against current `dev` before treating any count as authoritative (the tree drifts; these numbers are a snapshot, not a live value):
> - `src/` flat `.py` files: `find src -maxdepth 1 -name '*.py' | wc -l`
> - `tests/` test files: `find tests -name 'test_*.py' | wc -l`
> - `core.database` importers: `grep -rlE '(from|import) +core\.database' --include='*.py' . | grep -v core/database.py | wc -l`
> - `src.agent_loop` importers: `grep -rlE '(from|import) +src\.agent_loop' --include='*.py' . | grep -v src/agent_loop.py | wc -l`
> - Cross-layer import lines: `grep -rhE '(from|import) +<pkg>' --include='*.py' <dir>/ | wc -l` (e.g. `(from|import) +routes` over `src/`)
---
## 4. Route Ownership Map
Routes can be grouped into logical feature domains. Current flat structure obscures these boundaries:
| Domain | Route Files | Total Lines | Review Complexity |
|--------|-------------|-------------|-------------------|
| **Email** | `email_routes.py`, `email_helpers.py`, `email_pollers.py` | 5,936 | HIGH — most complex domain |
| **Chat / Agent** | `chat_routes.py`, `chat_helpers.py`, `shell_routes.py`, `codex_routes.py`, `skills_routes.py` | 6,365 | HIGH — core interaction surface |
| **Cookbook** | `cookbook_routes.py`, `cookbook_helpers.py`, `cookbook_output.py` | 4,110 | MEDIUM |
| **Model / LLM** | `model_routes.py`, `assistant_routes.py`, `copilot_routes.py` | 2,764 | MEDIUM |
| **Calendar / Contacts** | `calendar_routes.py`, `contacts_routes.py` | 2,336 | MEDIUM |
| **Documents** | `document_routes.py`, `document_helpers.py` | 1,954 | LOW |
| **Auth** | `auth_routes.py`, `api_token_routes.py`, `device_flow.py` | 1,171 | LOW |
| **Tasks** | `task_routes.py` (standalone) | 1,157 | LOW |
| **Session** | `session_routes.py` (standalone) | 1,287 | LOW |
| **Gallery** | `gallery_routes.py`, `gallery_helpers.py` | 1,896 | LOW |
| **Memory** | `memory_routes.py` | — | LOW |
| **Research** | `research_routes.py` | — | LOW |
| **MCP** | `mcp_routes.py` | — | LOW |
| **Notes** | `note_routes.py` | — | LOW |
| **Other** | `prefs_routes.py`, `upload_routes.py`, `vault_routes.py`, `webhook_routes.py`, `workspace_routes.py`, `search_routes.py`, `history_routes.py`, `hwfit_routes.py`, `preset_routes.py`, `signature_routes.py`, `backup_routes.py`, `cleanup_routes.py`, `diagnostics_routes.py`, `embedding_routes.py`, `emoji_routes.py`, `font_routes.py`, `stt_routes.py`, `tts_routes.py`, `compare_routes.py`, `personal_routes.py`, `editor_draft_routes.py`, `admin_wipe_routes.py`, `chatgpt_subscription_routes.py` | 2,000+ | LOW individual, HIGH cumulative |
---
## 5. Tool Registry & Implementation Boundaries
### 5.1 Current Tool Architecture
| Component | File | Lines | Role |
|-----------|------|-------|------|
| Tool schemas | `src/tool_schemas.py` | 1,392 | JSON Schema tool definitions (Duck-TypedDict) |
| Tool index | `src/tool_index.py` | 542 | RAG-based tool retrieval from ChromaDB |
| Tool implementations | `src/tool_implementations.py` | 4,032 | 33 `do_*` functions — all tool execution logic |
| Tool security | `src/tool_security.py` | — | Owner-scoped tool blocking |
| Tool policy | `src/tool_policy.py` | — | Guide-only directive, plan-mode disabled tools |
| Tool utils | `src/tool_utils.py` | — | Shared tool helpers |
### 5.2 Tool Implementation Categories
The 33 `do_*` functions in `tool_implementations.py` fall into natural domain groups — the basis for slice 1's split in §6.2:
| Category | `do_*` functions | Count |
|----------|------------------|-------|
| **System / config** | `do_manage_skills`, `do_manage_tasks`, `do_manage_endpoints`, `do_manage_mcp`, `do_manage_webhooks`, `do_manage_tokens`, `do_manage_settings`, `do_api_call`, `do_app_api` | 9 |
| **Cookbook / model serving** | `do_download_model`, `do_serve_model`, `do_list_served_models`, `do_stop_served_model`, `do_tail_serve_output`, `do_list_downloads`, `do_cancel_download`, `do_search_hf_models`, `do_adopt_served_model`, `do_list_cookbook_servers`, `do_list_serve_presets`, `do_serve_preset`, `do_list_cached_models` | 13 |
| **Notes** | `do_manage_notes` | 1 |
| **Calendar** | `do_manage_calendar` | 1 |
| **Search** | `do_search_chats` | 1 |
| **Research** | `do_manage_research`, `do_trigger_research` | 2 |
| **Contacts** | `do_resolve_contact`, `do_manage_contact` | 2 |
| **Vault** | `do_vault_search`, `do_vault_get`, `do_vault_unlock` | 3 |
| **Image** | `do_edit_image` | 1 |
| | **Total** | **33** |
> Low-level tools (filesystem, subprocess, web fetch, document parsing) live in `src/agent_tools/`, **not** in `tool_implementations.py` — out of scope for this split.
---
## 6. Risk Assessment & Candidate Slice Ranking
> **Candidate proposals, not a committed plan.** The rankings, package shapes (e.g. `src/pkg/`, `src/domain/`, `src/infra/`, `src/api/`), split ordering, and route-grouping strategy below are **options for maintainer discussion**. Per #4082/#4071, slice ownership and order are settled by maintainers before any follow-up PR. §1§3 above are the factual current-state inventory.
### 6.1 Risk Scale
| Level | Criteria |
|-------|----------|
| **LOW** | File has ≤3 importers AND ≤500 lines, OR is a pure refactor with clear boundaries |
| **MEDIUM** | File has 415 importers OR 5001,500 lines |
| **HIGH** | File has 16+ importers OR >2,000 lines, OR has cross-layer import violations |
### 6.2 Ranked Split Candidates
| Priority | Target | Risk | Rationale |
|----------|--------|------|-----------|
| **1** | `src/tool_implementations.py``src/tools/*.py` | **MEDIUM** | 4,032 lines → ~10 files by tool category. Already has natural boundaries. 17 importers, tracked in #3629. Use `__init__.py` shim to keep existing imports working. |
| **2** | `routes/` → domain subdirectories (one domain per PR) | **MEDIUM** | 54 flat files. Done **one domain at a time** (e.g. a standalone PR for the email domain, then chat, …), not a broad reorganization — route modules carry helper imports, registration assumptions, and test import paths. |
| **3** | `src/agent_loop.py``src/agent/loop.py` + submodules | **MEDIUM-HIGH** | 2,961 lines, 24 functions. Can extract prompt building, classification, verification, and runaway detection. Tracked in #3266. |
| **4** | `src/``src/pkg/`, `src/domain/`, `src/infra/`, `src/api/` | **MEDIUM** | Structural reorganization. Split flat `src/` into layered packages. Must come after routes and tools are stable. |
| **5** | `routes/email_*.py` consolidation | **LOW** | Already grouped by filename prefix. Low-risk cleanup within the email domain. |
| **6** | `core/database.py``src/infra/database/models/*.py` | **HIGH** | 28 classes, 102 importers. Highest-risk split. Must be **last** in any sequence. Requires careful import shim strategy. |
| **7** | Frontend CSS modularization | **MEDIUM** | 36,653 lines. Tracked in #2617. Separate timeline from backend work. |
| **8** | Frontend JS modularization | **MEDIUM** | 9,776 lines in `document.js`. Introduce ES modules at minimum. |
### 6.3 Candidate First 3 Behavior-Preserving Slices
**Slice 1: Split `tool_implementations.py`** (Lowest-risk high-impact)
- Create `src/tools/` package with one file per tool category
- Add `src/tools/__init__.py` re-exporting all symbols with current names
- Update 17 importers to use new paths (can be deferred via shim)
- Validation: `python -m pytest tests/ -x -q` + manual smoke test of tool execution
- Reference: #3629
**Slice 2: Group `routes/` by domain** (one domain per PR, not a broad sweep)
Route modules carry helper imports, router registration assumptions, and test import paths, so this must be done **one domain at a time** rather than as a single reorganization PR. Example sequence (each its own PR):
- PR 2a: move the **email** domain (`email_routes.py`, `email_helpers.py`, `email_pollers.py`) → `routes/email/` + shim
- PR 2b: move the **chat/agent** domain → `routes/chat/` + shim
- PR 2c: move the **cookbook** domain → `routes/cookbook/` + shim
- …and so on per domain from §4
Each PR: add `__init__.py` re-exporting old names, update `app.py` router imports, validation `python app.py` starts clean. **No behavior change** — pure file reorganization.
**Slice 3: Extract `agent_loop.py` submodules** (Improve reviewability)
- Move prompt assembly → `src/agent/prompt.py`
- Move request classification → `src/agent/classifier.py`
- Move sub-agent verification → `src/agent/verifier.py`
- Move runaway detection → `src/agent/runaway.py`
- Move context management → `src/agent/context.py`
- Keep `src/agent/loop.py` as the main orchestration module
- Validation: `python -m pytest tests/test_agent_loop.py tests/test_loop_breaker_runaway.py -v`
---
## 7. Safety Guardrails for Follow-Up Work
Per maintainer guidance in #4082 and #4071:
- [ ] **One domain/slice per PR** — never mix multiple reorganizations
- [ ] **No behavior changes** mixed with file moves — pure reorganization only
- [ ] **Keep compatibility shims**`__init__.py` re-exports for all existing import paths
- [ ] **Add or identify focused tests** before risky splits
- [ ] **Do not start with `core/database.py`** or broad route movement unless this inventory shows a safe boundary
- [ ] **Prefer small, reviewable slices** over large restructures
- [ ] **No packaging/runtime/tooling migration** mixed into file moves
- [ ] **No frontend framework migration** inside this stabilization lane
- [ ] **Validate with `python -m compileall`** — every PR must pass CI checks
- [ ] **Validate with `pytest`** — run the full test suite before opening each PR
---
## 8. Validation Commands
Each follow-up PR should be verifiable with these commands before submission:
```bash
# Syntax check — must pass with zero errors
python -m compileall src/ routes/ core/ conf/
# Full test suite — must match baseline pass rate
python -m pytest tests/ -x -q
# Import shim verification — existing import paths must still work
python -c "from src.tool_implementations import do_search_chats; print('OK')"
# App startup smoke test (if backend touched)
timeout 5 python app.py 2>&1 | head -5 || true
```
---
## 9. Open Questions
1. Is `#2538` (specs ground truth) the canonical behavior map baseline, and should this inventory be kept in sync with those specs once merged?
2. Should route grouping follow the domain map proposed here, or is there a different taxonomy preferred by maintainers?
3. For the `tool_implementations.py` split (#3629), is the tool categorization in §5.2 acceptable, or should it follow a different grouping?
4. Should compatibility shims (`__init__.py`) be temporary (removed in a follow-up wave) or permanent?
5. Should an ADR (Architecture Decision Record) document be started to track decisions made during this process?
---
## 10. Future Direction (NOT current state)
The following are **future refactor targets** (candidate directions **pending maintainer agreement**, not committed), recorded here so this inventory does not imply they exist today. None of them are present in the current `dev` tree:
- `main.py` — proposed rename of the `app.py` entrypoint. Today the app boots via `app.py`.
- `src/agent/` — proposed package to hold `agent_loop.py` submodules (prompt/classifier/verifier/runaway/context). Today `agent_loop.py` is a single flat file in `src/`.
- `src/infra/`, `src/domain/`, `src/pkg/`, `src/api/` — proposed layered reorganization of the flat `src/` directory (slice 4 in §6).
These become real only when the corresponding slices land.
---
## Appendix A: File Listing
### `src/` (95 files — 61 shown; run `ls src/*.py` for the full list)
```
agent_loop.py tool_implementations.py tool_schemas.py
tool_index.py tool_security.py tool_policy.py
tool_utils.py builtin_actions.py task_scheduler.py
llm_core.py model_context.py model_discovery.py
session_search.py context_budget.py context_compactor.py
ai_interaction.py action_intents.py agent_runs.py
app_helpers.py app_initializer.py config.py
database.py memory.py memory_provider.py
secret_storage.py prompt_security.py url_security.py
url_safety.py rate_limiter.py cleanup_service.py
readiness.py service_health.py exceptions.py
request_models.py assistant_log.py bg_monitor.py
builtin_mcp.py chat_helpers.py chroma_client.py
document_processor.py embedding_lanes.py deep_research.py
research_handler.py research_utils.py personal_docs.py
rag_manager.py rag_singleton.py topic_analyzer.py
visual_report.py youtube_handler.py pdf_forms.py
pdf_form_doc.py pdf_runtime.py caldav_writeback.py
email_thread_parser.py text_helpers.py user_time.py
teacher_escalation.py cookbook_serve_lifecycle.py
chatgpt_subscription.py mcp_manager.py
```
### `routes/` (54 files)
```
__init__.py _validators.py
auth_routes.py api_token_routes.py device_flow.py
chat_routes.py chat_helpers.py shell_routes.py
codex_routes.py skills_routes.py
email_routes.py email_helpers.py email_pollers.py
cookbook_routes.py cookbook_helpers.py cookbook_output.py
model_routes.py assistant_routes.py copilot_routes.py
calendar_routes.py contacts_routes.py
document_routes.py document_helpers.py
gallery_routes.py gallery_helpers.py
task_routes.py session_routes.py
note_routes.py memory_routes.py research_routes.py
mcp_routes.py search_routes.py history_routes.py
webhook_routes.py workspace_routes.py upload_routes.py
vault_routes.py prefs_routes.py preset_routes.py
signature_routes.py personal_routes.py hwfit_routes.py
backup_routes.py cleanup_routes.py diagnostics_routes.py
embedding_routes.py emoji_routes.py font_routes.py
stt_routes.py tts_routes.py compare_routes.py
editor_draft_routes.py chatgpt_subscription_routes.py admin_wipe_routes.py
```
### `core/` (10 files)
```
__init__.py constants.py database.py models.py
auth.py middleware.py session_manager.py exceptions.py
atomic_io.py platform_compat.py
```
---
## Appendix B: Key Import Relationships
```
core/database.py ←── 102 importers (routes/*, src/*, core/*, tests/*)
├── routes/auth_routes.py
├── routes/email_routes.py
├── src/builtin_actions.py
├── src/task_scheduler.py
├── src/tool_implementations.py (inline)
└── ...97 more
src/tool_implementations.py ←── 17 importers
├── src/agent_loop.py
├── src/builtin_actions.py
├── src/tool_index.py
├── src/task_scheduler.py
├── src/tool_policy.py
└── ...12 more (mostly tests)
src/agent_loop.py ←── 22 importers
├── src/tool_policy.py
├── src/teacher_escalation.py
├── src/bg_monitor.py
├── src/task_scheduler.py
└── 18 more (incl. tests)
```

169
specs/auth-security.md Normal file
View file

@ -0,0 +1,169 @@
# Auth And Security
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers current security and trust-boundary behavior in:
- `core/auth.py`;
- `core/middleware.py`;
- `core/log_safety.py`;
- `core/database.py`;
- `app.py` auth middleware and token cache;
- `src/auth_helpers.py`;
- `src/owner_identity.py`;
- `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`;
- `src/tool_security.py`;
- `src/tool_execution.py`;
- `src/task_action_policy.py`;
- `src/prompt_security.py`;
- `src/url_safety.py` and `src/url_security.py`;
- `src/host_docker_access.py`;
- `src/attachment_refs.py` and upload lifecycle enforcement in
`src/upload_handler.py` / `routes/upload_routes.py`;
- `src/secret_storage.py`;
- `src/api_key_manager.py`;
- `src/integrations.py`;
- `src/webhook_manager.py`;
- `src/generated_images.py`;
- `scripts/diffusion_server.py`;
- `scripts/mlx_image_server.py`;
- `companion/routes.py` and `companion/pairing.py`;
- `routes/auth_routes.py`, `routes/api_token_routes.py`, and canonical `routes/vault/vault_routes.py` plus its top-level compatibility shim;
- admin-gated call sites in route files;
- `THREAT_MODEL.md` and `SECURITY.md`.
## Trust Boundary
Odysseus is a trusted-user private-network app. Admins intentionally have powerful local capabilities: shell, files, email, calendar, MCP, model serving, vault, settings, and API token management. The security model prevents unauthenticated access, non-admin escalation, prompt-injection through untrusted content, and accidental exposure of internal services.
`THREAT_MODEL.md` owns high-level security framing, but implementation claims here should be verified against current code when the threat model is stale. This spec records the implementation map that contributors should check before changing auth or untrusted-context flows. Security-header runtime details live in `runtime.md`.
## Auth Ownership
- `core.auth.AuthManager` owns users, password hashing, TOTP/backup codes, reserved usernames, privilege defaults, admin promote/demote state, and auth settings stored in `data/auth.json`. Auth config/setup mutations are lock-guarded, and session tokens are persisted separately in `data/sessions.json` behind their own lock.
- `app.py` owns request-time auth middleware, token-cache rebuild/invalidation, auth exemptions, API-token verification, and internal-tool identity stamping.
- `routes/auth_routes.py` owns HTTP endpoints for setup, signup/login/logout, 2FA, users, privileges, auth features, and integration settings.
- `core.middleware.require_admin()` owns the normal admin gate. Local wrappers must document and test any intentional divergence from that boundary.
- `src.auth_helpers.effective_user()` owns cookie/API-token owner attribution for selected route code. `require_user()` owns route-level degraded user resolution, `require_privilege()` owns privilege checks, and `owner_filter()` owns shared/null-owner query compatibility.
Reserved usernames include request-only sentinels `internal-tool`, `api`, `demo`, and `system`, plus the storage-only Default/Local owner `__odysseus_local__`. Loaded auth data drops reserved user records, and create/rename flows must reject real users with those names. `src.owner_identity` is the canonical owner vocabulary and `auth_disabled()` parser.
## Auth Runtime Flow
`AuthMiddleware` is the outer request gate because FastAPI middleware executes in reverse add order. It can return API `401` JSON or browser `/login` redirects before timeout/security-header middleware reaches the route.
Public/auth-exempt surfaces are limited to setup, signup/login/logout/status, feature/settings/integration preset reads, health/version/login, `/static/*`, and task webhook trigger paths. `routes/task/task_routes.py` owns validation of `POST /api/tasks/{task_id}/webhook/{token}` path credentials.
Login issues an `HttpOnly`, `SameSite=Lax` cookie with a seven-day max age when "remember" is enabled. `_secure_cookie()` (`routes/auth_routes.py:89`) decides the `Secure` attribute: an explicit `SECURE_COOKIES` of `true` or `false` is authoritative, and any other value, including unset and the present-but-empty value docker-compose injects, derives it from the request, marking the cookie `Secure` when the connection scheme or the first `X-Forwarded-Proto` hop is https. TOTP is checked before session issuance. Logout, password changes, user deletion, rename flows, expired sessions, and deleted-user sessions must keep revocation/migration behavior intact.
Deleting a user revokes that user's browser sessions and API-token rows, then the admin delete route invalidates the in-memory bearer-token cache so already-cached tokens stop authenticating.
Rename first changes the auth username, then migrates owner-bearing DB rows and disk-backed stores. Current rename coverage includes user preferences, active/disk research state, `memory.json`, upload metadata and owner-qualified upload index keys, skills frontmatter/usage state, cached browser sessions, and API-token cache invalidation. If owner migration fails after the auth rename, the route attempts to roll auth back to the old username instead of leaving a split identity.
Admin promotion/demotion is a live auth flag change through `AuthManager.set_admin()` and `PUT /api/auth/users/{username}/admin`. Demotion refuses to remove the last admin, permits self-demotion when another admin remains, restores the pre-admin privilege map when available, and does not revoke sessions or API tokens because later admin checks read the current `is_admin` flag.
## Owner Attribution
Cookie requests use the real username. Bearer-token requests are stamped as `request.state.current_user = "api"` plus `api_token_owner`, `api_token_scopes`, and token id. Routes that support API-token access must explicitly use `effective_user()` or route-local scope helpers instead of treating `"api"` as an owner.
Internal loopback calls may stamp `current_user = "internal-tool"` or a validated `X-Odysseus-Owner` username. Network/proxy validation for that bypass lives in `app.py`; `require_admin()` trusts the stamped sentinel or raw internal header and should be used behind equivalent middleware control.
Missing-owner values remain state-dependent at legacy call sites, but new storage-facing code has one normalization contract:
- Auth-enabled, configured auth with no `current_user` is unauthenticated and should fail closed at route dependencies.
- `AUTH_ENABLED=false` is an explicit local single-user/no-login mode. Existing route dependencies can still return `""`, and admin gates allow the local operator. `effective_storage_owner()` and `storage_owner_for_request()` normalize an absent owner to `__odysseus_local__` only in this mode.
- Chat/agent code that reads `get_current_user(request)` directly gets `None` when auth middleware is disabled, because no middleware stamps request state.
- SQL `NULL`/JSON missing owners remain legacy/shared compatibility data, not the same thing as a logged-out authenticated caller.
- `"api"` and `"internal-tool"` are request sentinels. They must not be persisted as normal storage owners unless a route explicitly defines that behavior.
- `__odysseus_local__` is a valid storage owner but never a login or request sentinel. Adoption is incremental: callers that do not use the storage-owner helper can still expose older `None`/empty/null compatibility behavior.
Authenticated `manage_tasks` mutations require an exact stored task-owner
match and reject both cross-owner and legacy null-owner rows. The `owner=None`
agent path keeps deliberate auth-disabled single-user compatibility, including
unscoped list/create/mutation behavior.
Owner-scoped route code should use `require_user()` or equivalent policy before querying per-owner data. Current note CRUD/reorder/reminder routes do this so an auth-enabled request that reaches the route without identity returns `401` instead of falling into single-user/null-owner compatibility behavior.
Scheduled task actions attribute differently again. `_execute_action` (`src/task_scheduler.py:1231`) invokes the action with `owner=task.owner` read from the stored `ScheduledTask` row, so no request and no resolved principal are in flight. These trigger paths converge there: schedule, event bus, manual run (`routes/task/task_routes.py:865`), the `manage_tasks` agent tool (`src/tools/system.py:469`), webhook triggers (`routes/task/task_routes.py:1045`), which are unauthenticated by design with the token as the only credential and execute under the stored `task.owner`, and success-chained tasks (`src/task_scheduler.py:1063-1074`), which additionally require the chained target to share `task.owner` and reject cycles. Trigger-side ownership checks use the `if user and task.owner != user` shape, so a falsy caller skips them. Action bodies that reach owner-scoped storage must treat `task.owner` as the authority; route-level `require_user()` never runs on this path.
## API Tokens And Scoped Integrations
`routes/api_token_routes.py` owns token CRUD and scope normalization. Partial updates preserve existing scopes unless new scopes are supplied, write scopes imply the matching read scopes where applicable, and Cookbook scopes are part of the normalized scope set. `app.py` caches active token prefix rows and verifies bearer tokens with bcrypt. API-token requests set `request.state.current_user = "api"` plus token owner/scopes.
Current call sites include Codex/Claude scoped APIs, `/api/v1/chat`, webhooks, selected session routes, companion pairing, and external integrations. `/api/codex/*` and `/api/v1/chat` enforce route-local scopes; companion and selected session routes use owner attribution. `companion/pairing.py` can mint chat-scoped tokens outside normal token CRUD.
Admin token CRUD is cookie/admin gated. Update/delete operations check token ownership, and cache rebuild ignores active tokens whose owner no longer maps to a known auth user. Scoped route code must use the token owner and declared scopes instead of falling back to cookie-user assumptions.
## Internal Tool Loopback
Agent tools call admin-gated HTTP routes through an in-process loopback. `core.middleware.INTERNAL_TOOL_TOKEN` owns the random per-process secret. `app.py` only accepts this bypass from direct loopback clients without proxy-forwarding headers.
`src.tool_security` owns non-admin tool blocking. Non-admin users must not reach admin tools through agent mode, MCP tools, or loopback calls.
`src.tool_security.owner_is_admin_or_single_user()` treats explicit `AUTH_ENABLED=false` as intentional single-user mode even when an auth store already exists, while keeping pre-setup auth-enabled callers non-admin.
Current admin gates include `require_admin()` call sites across admin wipe, backup, contacts, Cookbook, diagnostics, embeddings, MCP, model, personal docs, presets, skills, uploads, vault, webhook, and companion routes. Local wrappers also exist in auth routes, shell routes, and task action policy; changes to those wrappers need the same trust-boundary review as `require_admin()`. Scheduled task action policy treats `run_local`, `run_script`, `ssh_command`, and `cookbook_serve` as admin-only action tasks across create/update/manual-run/webhook/scheduler execution.
`tidy_research` can remove only empty or unparseable research JSON. Because a broken file has no trustworthy owner stamp, the action checks `owner_is_admin_or_single_user()` before enumerating files; regular users and the pre-setup window cannot run that global unattributable-file sweep.
## Untrusted Context Policy
`src.prompt_security` owns the model-facing untrusted data contract:
- `UNTRUSTED_CONTEXT_POLICY` states the policy in system prompt text.
- `untrusted_context_message(label, content)` wraps external content as user-role data with `metadata.trusted = False`, provenance metadata, and a default `tool_gate_untrusted` marker. Guard-like labels/content are escaped so source text cannot counterfeit the wrapper boundary.
Current untrusted surfaces include fetched URLs, web results, emails, memories, skills, notes, documents, active editor content, and tool output sourced from outside the server. Injecting those as trusted system instructions is a security bug.
`src.tool_capabilities` classifies native and MCP tools by effects and result integrity. After external/workspace-untrusted context becomes model-visible, `ToolRunSecurityContext` keeps a server-owned taint for the session turn: only explicitly low-impact tools can run immediately, while write, execute, network-egress, UI/external-side-effect, admin, destructive, unknown, and arbitrary MCP actions require exact approval. Failed tools can still arm the gate when their result carries remote or stored payload; content-free failures and server-generated blocked/approval placeholders do not.
`src.tool_approvals` owns opaque approvals sealed to the owner, session, origin run, exact first tool name/content, workspace, capability effects/result integrity, selected continuation tool set/query, and expiry. Document actions additionally seal document id, version, content digest, and workspace. Chat cards offer task scope, chat-session scope, or deny: both allow choices consume and execute the exact sealed first action after current-policy/freshness checks, task scope bypasses the gate only for the resumed task, and chat-session scope persists a resolved session-bound grant for later turns in that same chat. The browser submits only the opaque decision and cannot replace the sealed action, selected tools, query, composer text, or attachments. Non-chat callers retain single-action scope. A new ordinary turn or superseding action retires an unresolved approval without clearing taint.
## URL, Path, And Secret Policy
- `src/url_security.py` owns public HTTP(S) validation for integration/API-token supplied URLs. It should fail closed for private IP, loopback, invalid scheme, and unsafe redirect targets.
- `src/url_safety.py` owns local-first outbound URL safety for model endpoints and similar local services. Loopback/LAN can be allowed by default, and private-IP blocking is an explicit caller policy. Strict `block_private=True` also rejects RFC 6598 shared/CGNAT space (`100.64.0.0/10`) explicitly because Python does not classify that range as private.
- `core.log_safety.redact_url()` strips URL userinfo, query strings, and fragments before endpoint URLs enter logs. Model, chat/research endpoint, contact/CardDAV, and similar diagnostics should use this helper instead of logging raw admin-configured URLs.
- `src.webhook_manager` validates webhook URLs at create and delivery time,
rejects private/internal targets, disables redirects, and pins delivery to
the public IP set that passed validation immediately before the request.
- `src.integrations` owns admin-configured integration base URLs and secret
masking. `api_call` accepts only relative paths, rejects link-local/metadata destinations through `src.url_safety`, can additionally block RFC1918/loopback/private targets with `INTEGRATION_API_BLOCK_PRIVATE_IPS=true`, and pins requests to the IP set that passed SSRF validation while preserving the intended Host/TLS identity.
- `src.outbound_fetch` owns reusable public-URL classification, validates every redirect hop, rejects private/local resolved addresses, and pins the HTTP connection to the validated public IP while preserving original URL/SNI/Host semantics. `services.search.content` adapts that transport for extraction and caching.
- Path-based tools, upload/document/gallery/signature/generated-image routes, embedding cache paths, and research JSON helpers must stay confined to allowed roots and owner-scoped files. Native file/code-navigation tools also apply a case-insensitive sensitive-path denylist so `grep`, `glob`, `ls`, direct reads, and writes cannot reveal `.env`, SSH/GPG material, private-key filenames, or similar secret paths.
- Durable upload references are owner-reserved before chat/session, document,
note, or calendar writes. Cleanup scans every current durable reference
surface and fails closed on incomplete discovery or inconsistent upload-index
state rather than deleting a possibly live upload.
- File-backed SQLite startup restricts `app.db` and existing rollback/WAL/SHM
sidecars to `0600` on POSIX after resolving the real path from the parsed
engine URL. Windows, in-memory, and non-SQLite databases are excluded, and
failed POSIX restriction is logged as a secret-file warning.
- Secret-like DB columns use `EncryptedText` or `src.secret_storage`. Email passwords and Google OAuth mail tokens are encrypted manually in `EmailAccount` string columns; Google OAuth state is HMAC-signed and callback writes are owner-checked before token storage. `src.api_key_manager` keeps provider API keys encrypted in `data/api_keys.json`, writes by loading the raw encrypted dict so saving one provider does not rewrite other providers' keys as plaintext, and restricts local key-file permissions where the platform supports chmod. Vault state in `data/vault.json` is a chmod-restricted JSON secret store, not Fernet-encrypted DB storage. Do not log or return decrypted secrets except for intentional admin vault retrieval flows with audit/reason checks.
- `.env` files are secrets-only inputs and should not be read or printed during agent work.
`scripts/diffusion_server.py` is a local model-serving helper with its own web surface. It defaults CORS to deny, installs a trusted-host allowlist for loopback/bind addresses, and only extends Host/CORS through explicit CLI flags.
`scripts/mlx_image_server.py` serves exactly the model selected when the process starts. OpenAI-compatible request `model` fields are accepted but ignored for generation and edits, so an unauthenticated caller cannot select another local directory or Hugging Face repository and drive model-specific script/bridge execution.
Host Docker socket access is a high-trust admin/deployment choice, not a normal container capability. Default Docker Compose does not mount `/var/run/docker.sock`; `src.host_docker_access` only reports local Docker available inside a container when `ODYSSEUS_ENABLE_HOST_DOCKER=true` and the socket exists. Remote SSH Docker/Cookbook workflows remain the safer default.
## Degraded And Compatibility Behavior
- `AUTH_ENABLED=false` skips `AuthMiddleware` and `src.auth_helpers.require_user()` returns `""` from any host. This preserves local single-user/no-login operation; it is not permission for auth-enabled logged-out callers. Storage code that adopts `storage_owner_for_request()` receives the reserved Default/Local owner; direct `get_current_user()` readers still receive `None`. Owner-scoped routes that tolerate no-login mode should call the appropriate route or storage helper so auth-enabled anonymous requests fail closed.
- First-run setup mode redirects browser requests to `/login`, returns API `401 Setup required`, and keeps setup/status/login surfaces auth-exempt. Setup/signup/login are rate-limited; status is exempt but not rate-limited. Route helper fallbacks only tolerate unconfigured anonymous access from loopback.
- User privilege checks distinguish legacy empty `allowed_models=[]` from explicit no-model access through `allowed_models_restricted=True`.
- `LOCALHOST_BYPASS` in `app.py` only applies to direct loopback clients and excludes proxy/tunnel headers. Helper fallback code is weaker and should not be treated as the primary bypass boundary.
- Legacy migrations claim null-owner SQL/JSON data for the primary admin when possible, and startup repeats a null-owner sweep hourly. Remaining null-owner rows are surface-specific compatibility data that must be deliberately included, no-oped for single-user mode, or rejected for strict ownership gates.
- `.env` is loaded with `utf-8-sig`, so Windows BOM auth flags still parse.
## Current Gaps
- There is no shell/filesystem sandbox for admin tools.
- Token scopes remain coarse for some surfaces.
- `app.py` AuthMiddleware lacks direct regression coverage for bearer-token state/cache behavior, trusted-loopback proxy-header rejection, and internal-tool owner stamping.
- Codex/Claude scoped route enforcement still needs stronger regression coverage.
- `THREAT_MODEL.md` still has stale token-scope and `/api/v1/chat` SSRF gap text that should be reconciled with current route validation.
- The Default/Local owner contract is canonical but only incrementally adopted; route helper `""`, chat/agent `None`, SQL/JSON null-owner compatibility, and calendar fallback owner behavior still need domain-by-domain migration decisions.

View file

@ -0,0 +1,186 @@
# Calendar, Tasks, And Notes
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers calendar, reminders, tasks, assistant runs, and notes in:
- app route wiring, auth exemptions, and scheduler startup in `app.py`;
- canonical database models in `core/database.py`, with `src/database.py` as a compatibility re-export;
- `routes/calendar_routes.py`, `src/caldav_sync.py`, and `src/caldav_writeback.py`;
- canonical `routes/task/task_routes.py`, compatibility shim `routes/task_routes.py`, `src/task_scheduler.py`, `src/task_endpoint.py`, `src/event_bus.py`, and `src/interactive_gate.py`;
- shared privileged task-action policy in `src/task_action_policy.py`;
- `routes/assistant_routes.py`;
- canonical `routes/note/note_routes.py`, compatibility shim
`routes/note_routes.py`, `src/builtin_actions.py`, and `src/action_intents.py`;
- agent/tool call sites in `src/tool_index.py` and `src/tool_implementations.py`;
- scoped Codex wrappers in `routes/codex_routes.py`;
- database models `CalendarCal`, `CalendarEvent`, `ScheduledTask`, `TaskRun`, `Note`, and `CrewMember`;
- direct DB CLIs `scripts/odysseus-calendar`, `scripts/odysseus-notes`, and `scripts/odysseus-tasks`;
- frontend modules `static/js/calendar.js`, `static/js/calendar/*`, `static/js/tasks.js`, `static/js/notes.js`, and `static/js/assistant.js`;
- tests covering calendar routes/utilities, CalDAV, recurrence, timezone handling, scheduler behavior, task webhooks, notes CLI/tool behavior, and task CLI behavior.
## Calendar
`routes/calendar_routes.py` owns `/api/calendar` behavior: config, multi-account CalDAV CRUD, connection test, sync, local calendar CRUD, event CRUD, recurrence expansion, ICS import/export, quick parse, and user timezone offset handling.
`src.caldav_sync` owns CalDAV fetch/sync. `src.caldav_writeback` owns pushing local changes back to remote calendars. Calendar routes request those behaviors; they do not own CalDAV protocol details.
Runtime behavior:
- local default calendars are created lazily per owner with stable UUID5 candidates. Default creation remains inside the caller's transaction so a failed event write cannot leave an orphaned calendar; SQLite serializes the absent-row check with `BEGIN IMMEDIATE`, other backends recover insert races inside a savepoint, and renamed-owner ID collisions advance through deterministic slots. List-only callers explicitly commit the lazy default.
- route-level no-login calendar access normalizes empty owner values to `ODYSSEUS_FALLBACK_OWNER` or `owner@localhost`, so route-created calendar rows do not use the empty string as their storage owner;
- CalDAV account config lives in per-user prefs as `caldav_accounts`, with the legacy `/api/calendar/config` route reading/upserting the first account;
- recurring rules are expanded server-side, including compound recurrence IDs;
- RRULE expansion is capped and marks truncated responses;
- event datetimes preserve UTC/local metadata through `CalendarEvent.is_utc` where supported;
- CalDAV pull uses a bounded sync window, scopes existing UID lookups to the synced calendar, stamps account ids and remote metadata on local calendars, maps Google principal URLs to event collections, preserves locally-created or writeback-pending events that are not yet remote-owned, and deletes stale in-window remote events only when remote object parsing did not fail;
- CalDAV writeback stores `remote_href`/`remote_etag`, clears `caldav_sync_pending` only after successful remote writes, and leaves create/update/delete pending markers for retry on failure;
- pull and writeback paths always close their `DAVClient`, including discovery,
database, and remote-write failure paths;
- sync direction can be pull, push, or both, and pending local writeback rows are included even before remote href metadata exists;
- ICS import is per-owner, capped, creates fresh local IDs in the target import calendar, and preserves zero-duration events as visible imported rows rather than dropping them as empty ranges;
- writeback is best-effort and local SQLite remains source of truth when remote writes fail.
Calendar credentials are encrypted at rest and are not returned to clients. CalDAV URL validation rejects unsafe schemes, credentials, fragments, localhost names, bad ports, unsafe IP literals, and hostnames resolving to disallowed addresses, with `ODYSSEUS_ALLOW_PRIVATE_CALDAV=1` as the explicit private-IP escape hatch. CalDAV sync/writeback clients disable redirects so credentials are not followed to another origin. The connection-test client keeps proxy/environment trust disabled but explicitly loads an operator `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` when the file exists so private/self-signed deployments use the same CA trust intent as real sync.
## Tasks And Assistant Runs
`src.task_scheduler.TaskScheduler` owns scheduled task execution, next-run computation, strict single-slot execution, queued/running cleanup at startup, overdue next-run advancement, webhook-triggered tasks, notifications, run records, chained tasks, and event-triggered actions.
Cookbook serve scheduling crosses this domain. The Cookbook UI creates `cookbook_serve` scheduled tasks, can mirror them as Cookbook calendar events with `cookbook_event_uid`, and task deletion cleans up the linked event when present, falling back to exact-summary matching for legacy events without a stored UID. Cookbook command execution/lifecycle details stay in `cookbook-hwfit.md`.
`routes.task.task_routes` owns task CRUD, status, manual run/stop/cancel, pause/resume, owner-scoped run/activity history, metadata, onboarding defaults, cache clearing, parse endpoints, and webhook-token regeneration. `app.py` imports the canonical package path; `routes/task_routes.py` replaces its module entry with the canonical module for legacy import and monkeypatch compatibility. Chained-task `then_task_id` values are validated as same-owner relationships on create/update, and scheduler execution also rejects cross-owner or cyclic chains.
Task webhook paths are auth-exempt at the app middleware layer only for `/api/tasks/{task_id}/webhook/{token}`. The route still validates active task state plus task-specific webhook token before dispatch.
Task runtime behavior:
- task runs move through queued/running/success/error/skipped/aborted states;
- scheduler/background execution can wait for `src.interactive_gate` to report a quiet foreground window, and running background work can use browser heartbeat/chat-stream activity as a cancellation/defer signal where implemented;
- output targets include chat sessions, notifications, email, and MCP delivery paths;
- LLM and research tasks can carry a built-in `character_id` persona prompt that the scheduler prepends at execution time;
- task-created chat sessions can be foldered under `Tasks`, and startup migration backfills task/research folders for legacy sessions;
- event-bus triggers persist counters and `next_run` before scheduler handoff;
- the in-process scheduler is gated by `ODYSSEUS_INPROCESS_TASKS`, and multiple enabled app processes can double-run work.
- action tasks with `run_local`, `run_script`, `ssh_command`, or
`cookbook_serve` are admin-only. `routes.task_routes` enforces this on
create/update/manual run and hides those actions from `/meta/actions` for
non-admin owners; webhook and scheduler execution pause the task and clear
`next_run` if an admin-only action belongs to a non-admin owner.
- background LLM task execution uses the background workload path, and the
scheduler can abort/cancel active in-process task runs when foreground browser
activity appears.
- `tidy_research` scans all persisted research files because broken JSON has no trustworthy owner stamp, so it runs only for admins or the explicit auth-disabled single-user operator and refuses regular/pre-setup callers before enumeration.
`routes.assistant_routes.py` owns crew/assistant settings and run-status surfaces that use the scheduler. `TaskScheduler.ensure_assistant_defaults()` currently seeds the personal assistant crew member and pinned assistant session, but no longer auto-creates Morning/Midday/Evening check-in tasks. Existing crew-linked check-in tasks are still rendered and managed when present.
## Notes And Reminders
`routes.note.note_routes` owns notes/todos/reminders, and `app.py` imports that
canonical path. `routes.note_routes` replaces its module entry with the
canonical module for legacy import and monkeypatch compatibility. Notes are
SQLAlchemy `Note` rows and can include due dates, ordering, images, repeat
state, AI classification, source/session provenance, and agent session
linkage.
Notes CRUD/reorder/reminder routes resolve the acting owner through `require_user()`: auth-enabled anonymous requests fail closed before hitting owner-scoped queries, while documented no-login/single-user modes still resolve to the compatibility owner path.
Reminder policy:
- "remind me at 5pm" should become a todo/note with a due date;
- calendar event alarm/reminder UI writes reminder Notes;
- calendar events are for scheduled time blocks, meetings, appointments, or explicit calendar requests;
- creating a calendar event named "Reminder" does not create notification behavior.
Built-in reminder/persona prompt text is mirrored server-side for reminder synthesis and scheduled task execution; frontend persona selectors are UI over that server-owned id map, not the authority.
Reminder dispatch is Note-owned:
- `dispatch_reminder()` owns browser, email, ntfy, generic webhook, in-app notification, optional LLM reminder text, and dedupe behavior;
- the scheduler note scanner calls note-ping actions for backend due-note delivery with per-owner notification state, and calendar-event reminders are treated as Note-owned reminders rather than separate scheduler event pings;
- the notes frontend has a browser-tab fallback for visible sessions;
- calendar frontend reminder UI stores reminder records as Notes, not calendar-event notification jobs.
Email/ntfy failures degrade into channel result fields rather than blocking every reminder path. ntfy and generic webhook reminder URLs run through outbound URL safety checks, with `REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS` controlling whether private/LAN targets are allowed. ntfy notification titles are converted to ASCII with replacement and capped at 200 characters before entering HTTP headers. Reminder dedupe uses owner-scoped cache files under `data/`.
## Agent, Codex, And CLI Surfaces
`do_manage_tasks`, `do_manage_notes`, and `do_manage_calendar` own agent-side writes. `do_manage_calendar` supports batch event creation plus list range aliases (`start`, `start_time`, `start_date`, `range_start`, `from`, `dtstart`, `since`, and matching end aliases), calendar name/short-id lookup, importance/tag aliases, and reminder offsets expressed as numbers, minute/hour words, or common abbreviations such as `min`/`mins`/`hr`/`hrs`. If a model supplies a loose `query`, `date_range`, or `range` without explicit start/end datetimes, `list_events` returns an error asking the caller to resolve the range and call again instead of guessing. Event classification reads `Memory.text` for personal context before LLM classification. `src.tool_index` encodes the reminder policy that notes/todos own reminders while calendar events own time blocks.
Agent native tool owner handling is not uniform today. `do_manage_tasks()` filters lists only when `owner` is truthy and creates tasks with the passed owner, so `owner=None` can create legacy/null-owner tasks. For authenticated/non-empty owners, edit/delete/pause/resume/run require an exact stored owner match and reject both cross-owner and null-owner rows; `owner=None` retains single-user compatibility. `do_manage_notes()` list/query behavior distinguishes `None` from `""`, with `None` acting as broader single-user compatibility while `""` filters to empty-owner rows in some paths. `do_manage_calendar()` query helpers filter only when owner is not `None`, while calendar creation routes through the calendar fallback owner for default calendars. These are compatibility behaviors, not a cross-user sharing model.
Note and calendar route/tool writers owner-reserve any canonical internal upload
references in content, checklist/color/image fields, descriptions, and
locations before their database writes. Missing or wrong-owner uploads fail the
write instead of creating a dangling durable reference; reservations serialize
with upload cleanup.
Chat forwards browser timezone offset and IANA timezone name so natural-language note/calendar tools can anchor dates to the user clock. A valid IANA zone wins over the fixed offset for current-time/DST reasoning; invalid or absent names fall back to the offset and then server-local/UTC compatibility behavior. Chat can auto-promote note/calendar/reminder intents to agent mode.
Codex todo/calendar wrappers enforce bearer-token owner and `todos:*` or `calendar:*` scopes, then delegate to note/calendar behavior as the token owner. Normal calendar/task/note routes are current-user/cookie routes and should not be treated as scoped bearer-token APIs unless they explicitly use token owner/scope policy.
Direct DB CLIs are local compatibility tools. They bypass HTTP route behavior, CalDAV writeback, and some owner/timezone parsing policy.
## Event Bus
`src.event_bus` owns event-triggered task counters and scheduler handoff. Current emitters include chat/session/document/memory/research/email/skill paths. Ownerless events resolve to a primary configured user instead of broadcasting to every owner.
The current event bus is not a calendar-event emitter despite the adjacent calendar/task/reminder domain.
## Timezone And Date Semantics
- calendar events store offset-aware input as UTC/naive fields plus `is_utc`;
- note `due_date` uses ISO-like strings interpreted through note/tool parsers;
- chat forwards browser UTC offset into `routes.calendar_routes` request-local state for natural-language date anchoring in calendar/note tool parsing;
- generic scheduled task clock times are stored as UTC values after local conversion;
- assistant check-ins can use an IANA timezone on `CrewMember`, with UTC fallback.
Dateutil fallbacks strip timezone-aware parser results back to the naive-UTC contract before recurrence/window comparisons. Calendar agent list tools accept current range aliases implemented by `src.tool_implementations`, and equal/same-day start/end ranges are normalized to a one-day window instead of silently returning no rows.
Natural-language parsers prefer time-first interpretations for short reminder/event phrases where the user supplies a clock time before a date phrase.
Calendar frontend week-start preference is browser-local (`cal-week-start`) with Monday/Sunday controls; it is not persisted as a server preference.
Natural-language date parsing and timezone behavior are compatibility-sensitive and need route/tool/frontend regression coverage when changed. Request-local timezone context is ephemeral and must not be persisted as user state. A valid browser IANA timezone is authoritative over a possibly stale or wrong-sign fixed offset because it carries daylight-saving rules.
## Degraded And Optional Behavior
- CalDAV sync no-ops with shaped errors when unconfigured, invalid, offline, or missing the optional `caldav` dependency.
- CalDAV writeback failures are non-fatal to local calendar writes and are mostly visible through logs.
- Missing or invalid `croniter` rejects cron schedules or yields no next run.
- Missing timezone support falls back to UTC or legacy behavior.
- ICS import depends on `icalendar`; missing dependency can fail before route-shaped error handling today.
- Notes reminders can still use local browser fallback when backend email/ntfy channels fail.
- App backup import/export does not currently include calendar events, scheduled tasks, task runs, or notes; calendar ICS import/export is separate and calendar-only.
## Security And Provenance
Calendar, task, note, and assistant routes are owner-scoped for normal users. Legacy null-owner behavior is compatibility-sensitive and should not silently grant authenticated owners broad mutation rights.
Because auth-disabled chat owners can arrive as `None`, tool-created rows may not use the same owner value as route-created rows. Multi-user or owner-model changes must audit both route and agent paths.
Task creation/update/manual run/webhook/scheduler execution blocks shell-like and Cookbook serve action types for non-admin users through `src.task_action_policy`, and tool security blocks privileged task/calendar tools for non-admin use. Assistant defaults reject synthetic owners such as `api` and `internal-tool`.
Note routes store caller-provided `source`, `session_id`, `image_url`, and agent-session provenance. Canonical internal upload references in persisted note/calendar fields are owner-reserved before writes, and upload-backed bytes remain protected when fetched through upload routes. Arbitrary non-upload image/provenance URLs are not otherwise normalized or validated by note storage.
## Testing Coverage
Existing coverage is strongest around CalDAV URL hardening/writeback, client cleanup and operator CA handling, bidirectional/pending CalDAV sync markers, CalDAV UID calendar scoping, calendar recurrence/timezone helpers, owner-scoped calendar basics, exact-owner task-tool mutations, scheduler restart/cancel/next-run behavior, webhook auth-exemption source shape, canonical/legacy note-module identity, note-route unauthenticated fail-closed behavior, note/calendar attachment reservations, notes CLI/tool due-date behavior, calendar reminder abbreviation parsing, task CLI preview, task persona fields, and same-owner chained task validation.
Route-level coverage is thinner for full calendar route behavior, task CRUD/security/run controls, live webhook token dispatch, notes owner CRUD/reminder delivery, assistant defaults/run status, event-bus triggers, Codex todo/calendar scopes, and frontend panel wiring.
## Current Gaps
- CardDAV still needs URL hardening parity with CalDAV; CalDAV now resolves hostnames during validation and revalidates writeback URLs.
- `do_manage_notes()` should match HTTP note-route owner behavior for legacy null-owner notes.
- Auth-disabled agent tools can produce or read broader owner scopes than route handlers because they receive `owner=None`; tasks, notes, and calendar need aligned policy/tests.
- Task webhook tests should keep exercising live route token behavior and
admin-only action blocking, not only middleware/source strings.
- Reminder delivery needs tests across frontend `/fire-reminder`, backend `dispatch_reminder()`, scheduler note pings, channel degradation, and dedupe.
- Codex todo/calendar scope and owner mapping needs dedicated regression coverage.
- Direct DB CLIs need either documented route-bypassing support status or shared helpers to avoid owner/timezone/writeback drift.
- `scripts/odysseus-webhook` builds the live `/api/tasks/{task_id}/webhook/{token}` path with percent-encoded path segments; its direct DB token rotation/revocation behavior remains a local compatibility surface.
- Assistant default documentation/code comments still mention check-ins that are no longer auto-seeded.
- App backup import/export does not cover the calendar/task/note rows described by this spec.

154
specs/chat.md Normal file
View file

@ -0,0 +1,154 @@
# Chat
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers current chat behavior in:
- `routes/chat_routes.py` and `routes/chat_helpers.py`;
- `routes/session_routes.py` and canonical `routes/history/history_routes.py`,
with `routes/history_routes.py` as a compatibility shim;
- `src/chat_helpers.py`;
- `src/agent_runs.py`;
- `src/chat_handler.py` and `src/chat_processor.py`;
- `core/session_manager.py` and `core/models.py`;
- `src/attachment_refs.py` and `src/upload_handler.py` for durable attachment
references and write reservations;
- `src/context_budget.py`, `src/context_compactor.py`, and `src/topic_analyzer.py`;
- `src/foreground_model_routing.py`, `src/tool_approval_scopes.py`, `src/tool_approvals.py`, and `src/tool_capabilities.py`;
- `routes/workspace_routes.py` for workspace selection support;
- frontend modules `static/js/chat.js`, `static/js/chatStream.js`, `static/js/chatRenderer.js`, `static/js/sessions.js`, `static/js/search-chat.js`, `static/js/compare/stream.js`, `static/js/workspace.js`, `static/js/composerArrowUpRecall.js`, `static/js/streamingSegmenter.js`, `static/js/group.js`, and `static/js/notes.js`;
- integration points with uploads, documents, compare, research, agent tools, memory, RAG, search, and model endpoints.
## Session Ownership
`core.session_manager.SessionManager` owns session persistence and message writes. `routes/session_routes.py` owns session list/create/update/archive/delete/folder/importance behavior for the sidebar. `routes.history.history_routes` owns history/topic surfaces, with `routes/history_routes.py` kept as a compatibility shim.
`core.models.Session` and `ChatMessage` are pure data containers. They do not own persistence; `Session.add_message()` delegates to the configured session manager when present.
Startup session discovery selects non-archived sessions by the existence of persisted `ChatMessage` rows rather than trusting the denormalized `Session.message_count`. It computes authoritative counts only for the bounded discovery set, then keeps full message hydration lazy.
## Streaming
`routes/chat_routes.py` owns `/api/chat`, `/api/chat_stream`, detached stream resume/stop/status, injected context, chat-message search, and rewrite routes. Streaming is the main UI path.
`static/js/chat.js` owns send/abort/continue UI state, the main fetch/read loop, SSE parsing, rendering dispatch, workspace form wiring, and background/resumable stream tracking. `static/js/chatStream.js` owns UI-control event handling and stream/research notification helpers. `static/js/sessions.js` polls server stream status after refresh or session switch. `static/js/composerArrowUpRecall.js` owns prompt recall from the composer when the caret is at the top of an empty input.
Runtime behavior:
- the `/api/chat*` prefix is exempt from the global request hard timeout;
- browser chat sends `X-Tz-Offset` and an IANA timezone name; request-local helpers prefer a valid IANA zone for DST-aware current-time reasoning, then fall back to the fixed offset;
- browser chat can send a selected workspace path; route code only resolves it for admin/single-user flows, validates it as an existing directory, and forwards it so agent file/shell tools are confined by `src.tool_execution`;
- stream callbacks can outlive a deleted session, so persistence must fail closed instead of recreating orphan messages;
- message metadata carries timestamps, metrics, tool events, sources, hidden
thinking/reasoning text when providers expose it separately, context-trim
metrics, structured attachment references, and related UI state;
- metadata preserves requested and actual reply models and endpoints, per-round route transitions, and answering-route cost attribution; stable session ids remain available so prompt/sequence-memory and KV-cache paths can address the same conversation consistently;
- multimodal content can be a list of content blocks for the live provider call,
while persistence collapses raw media into readable text and stable
attachment-reference lines;
- agent streams forward explicit round-cap, tool-budget, repeated-tool-loop,
and intent-without-action guard events so the frontend can distinguish a
controlled stop from a stalled response.
`src.agent_runs` owns detached in-memory stream runs, replay buffers, replacement cancellation, resume subscribers, explicit stop, and terminal-buffer eviction. Closing the SSE connection does not necessarily stop generation. `static/js/chat.js` can live-resume a still-running detached stream through `/api/chat/resume/{session_id}`; rich responses reload from DB for canonical rendering. Detached runs are process-local and do not survive server restart.
Provider adapters live below chat in `src.llm_core`. Chat consumes normalized SSE output, fallback/error events, reasoning/tool deltas, and metrics. Foreground chat is strict to the selected route by default. Only the selected owner can opt in through `foreground_fallback_enabled` plus ordered `foreground_model_fallbacks`; the retired `default_model_fallbacks` key is ignored. Eligible pre-content availability failures can advance through at most ten owner-visible exact model candidates, while missing configuration/endpoints, provider/schema errors, clean empty completions, and post-content failures remain on the selected route and surface an error. Once a route produces substantive text/reasoning or a tool call it is pinned as the answering route.
Fallback candidates receive route-neutral context shaping. Only compaction performed for the answering route is persisted. Chat and agent metadata record requested/actual model and endpoint identity, round-by-round route transitions, and costs against the route that actually answered; the browser renders same-model endpoint changes as well as model changes.
## Context Preface
`routes.chat_helpers.build_chat_context()` owns the shared route pipeline: preset extraction, preprocessing, user-message persistence, incognito/no-memory/RAG/skills flags, prefetched compare search, YouTube transcript context, research-spinoff grounding, model normalization, and compaction.
`src.chat_processor.ChatProcessor.build_context_preface()` owns source preface construction. It can add memory, RAG, web search, URL page content, and skills index context before the model call.
Chat preface enhances the model's context. It must not rewrite the user message or force literal-vs-fetch interpretation before the model sees the request. See [context-building.md](context-building.md).
Chat-owned external context must enter the model through `untrusted_context_message()` unless a different treatment is explicitly documented. This includes memory, RAG, web search, URL fetches, prefetched search context, YouTube transcripts, research injection, and manual context injection.
## Modes And Handoffs
Chat can dispatch to normal LLM calls, agent mode, research mode, or compare-related flows. Session mode is stored on `sessions.mode`.
Legacy plan-mode backend plumbing still exists below chat, but `routes/chat_routes.py` currently forces browser/form `plan_mode` input off and the old visible plan window frontend module is not part of the current SPA. Treat plan-mode changes as compatibility work unless the UI contract is intentionally reintroduced.
Current call sites include:
- chat/research dispatch in `routes/chat_routes.py`;
- agent execution in `src/agent_loop.py`;
- deep research orchestration in `src/research_handler.py`;
- compare entry points in canonical `routes/compare/compare_routes.py` and frontend compare modules.
Agent-mode tool access is gated in layers. Chat route toggles and privileges
build a disabled-tool set; incognito and compare mode remove persistence-heavy
or UI-breaking tools; `src.action_intents.message_needs_tools()` provides
conservative regex auto-escalation hints; `src.agent_loop`,
`src.tool_security`, `src.tool_execution`, and internal loopback validation
remain server-side enforcement owners.
`allow_bash` and `allow_web_search` can be read from the JSON request body for browser chat posts that do not submit traditional form fields.
Web search tools are per-turn explicit opt-in. Either `allow_web_search=true`
or `use_web=true` can enable `web_search`/`web_fetch`, but an explicit
`allow_web_search=false` wins over `use_web=true` and keeps those tools
disabled. Explicit latest-turn web-search intent can still auto-escalate into
agent mode and narrows the available tool set toward `web_search`/`web_fetch`,
but it no longer re-enables web tools after an explicit denial or global
disable.
Guide-only/no-tools requests build an effective tool policy before preprocessing and agent dispatch. That policy suppresses tool-backed preprocessing/background extraction/research, disables schemas and MCP for the turn, and is still enforced by `src.tool_execution` if a model emits a tool call anyway.
When route context is trimmed without full compaction, chat emits a
`context_trimmed` SSE event and carries before/after message/token counts into
metrics. Provider reasoning/thinking deltas are streamed for live UI handling
but kept out of the visible saved assistant content and stored in metadata when
available.
## Attachments
`src.chat_handler.ChatHandler.preprocess_message()` owns owner-scoped upload-id resolution, attachment metadata, YouTube transcript/comment preprocessing, image/VL behavior, and enhanced text used by chat. `src.document_processor.build_user_content()` owns conversion of uploaded/chat-attached files into model-ready text or multimodal blocks. `src.attachment_refs` owns persisted text/reference normalization, and `SessionManager` owner-reserves attachment ids before appending or replacing durable message rows. `static/js/fileHandler.js` owns frontend pending-file state.
Attachment-only sends are valid. Missing or unauthorized ids are skipped during preprocessing, while a missing/wrong-owner durable reference aborts a message/history replacement before existing transcript rows are removed. Upload failures keep pending files for retry, unsupported media can degrade to text markers, optional Office/PDF/VL dependencies can emit extraction banners, Office attachments can create markdown documents when extracted server-side, and fillable-PDF auto-document failures fall back to normal PDF extraction. `chat_messages.content` and FTS do not retain provider data URLs; structured references stay in metadata for reloads. Chat does not own upload bytes or durable document storage; it requests document/upload behavior from those subsystems.
Frontend chat distinguishes normal resend from regenerate-from-here: normal resend appends a fresh user copy and carries upload IDs where available, while regeneration truncates from the selected point. AI-message delete prompts before removing the AI response plus preceding user turn. Desktop Enter submits; mobile Enter inserts a newline unless another platform-specific send control is used.
Native document tool outputs can open or refresh the document editor from
tool-result metadata, so the UI can recover if a later `doc_update` stream event
is missed. The chat renderer also hides raw/incomplete leaked tool JSON and
document fences from normal transcript text.
When untrusted external/workspace content has entered the agent context, high-impact tool calls pause as exact approval cards instead of executing. The browser can allow the rest of the interrupted task, allow this chat session, or deny; it submits only the opaque id/decision with an empty control-plane message and does not mutate the composer. The server restores the sealed first action plus private selected tools/query, revalidates policy and document freshness, consumes the first action, and resumes without persisting a synthetic user message. Task scope ends with that resumed run. Chat scope persists the resolved card and marks later context only for that exact session; forks do not inherit it. A normal message retires an unresolved card while preserving taint.
## Security And Provenance
`/api/chat` and `/api/chat_stream` verify session ownership before loading the session. Chat privilege gates enforce allowed models and daily message caps before LLM work. Active document injection, session auth/header recovery, endpoint repair, upload-id resolution and reservation, memory/RAG retrieval, and post-response work must stay owner-scoped.
The scoped API-token chat surface is `/api/v1/chat`. Browser chat routes can receive bearer-auth state from middleware, but route code must not assume `"api"` is a durable owner; API-token support requires explicit scope checks and token-owner attribution.
Incognito disables memory, skill, and chat-history tools and skips assistant DB persistence, but current user-message persistence and later cleanup are not a strict no-write guarantee. Treat incognito changes as security-sensitive until that contract is clarified.
## Search Boundary
`GET /api/search` in `routes/chat_routes.py` is chat-message search for the UI and slash commands. Web search routes are owned by canonical `routes/search/search_routes.py`; chat and agent web context call through `src.search`, compatibility shims, and search content fetchers. Do not confuse chat-history search with external web retrieval.
## Degraded And Compatibility Behavior
- Missing ChromaDB, embeddings, memory vectors, RAG managers, or skills indexes should remove injected context or fall back to keyword/text behavior without failing chat.
- Direct URL prefetch failures become compact untrusted context stating that the page was not read, with only transport-owned HTTP/size/rate-limit status where recognized; raw URLs, exception text, and response-controlled diagnostics are not echoed into logs or model context.
- Sessions hydrate legacy string headers and multimodal JSON-array content, export text/HTML/Markdown after flattening non-string blocks, can lazy-load from DB when cached state is empty, and preserve old history/index delete behavior where needed.
- Initial shell/session loading is non-blocking: the sidebar can render before a selected transcript is hydrated, and full transcript hydration is deferred until display or a model send requires it.
- Chat repairs empty selected models and orphaned endpoint references before provider calls when possible.
- Deleted-session stream writes fail closed.
- Docker/native endpoint differences are owned by runtime/model setup, but chat sessions depend on the saved endpoint URLs and headers.
- Copying a response from the UI copies the displayed answer text and omits hidden reasoning/thinking segments.
## Current Gaps
- Chat, agent, research, and compare orchestration still meet in a large route file.
- Context preface behavior is spread across `routes/chat_helpers.py`, `src/chat_processor.py`, route injections, and agent/tool paths.
- Detached stream lifecycle spans `routes/chat_routes.py`, `src/agent_runs.py`, `static/js/chat.js`, `static/js/sessions.js`, and non-chat callers.
- Some frontend stream state is still global/module-level in `static/js/chat.js` and needs careful session isolation when adding background or resumable flows.
- Chat lacks route-level SSE regression tests for `/api/chat_stream`, live resume/stop/status, mode handoff, persistence metadata, partial-save behavior, attachment/doc-update events, browser timezone offset/workspace handling, and literal URL context intent.
- Bearer-token behavior on browser chat routes and incognito persistence need explicit contract decisions and regression coverage.

79
specs/compare.md Normal file
View file

@ -0,0 +1,79 @@
# Compare
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model A/B comparison behavior in:
- canonical `routes/compare/compare_routes.py`, with `routes/compare_routes.py` as a compatibility shim;
- `routes/session_routes.py`;
- `routes/chat_routes.py` and `routes/chat_helpers.py`;
- `routes/model_routes.py`;
- canonical `routes/search/search_routes.py`, with `routes/search_routes.py` as a compatibility shim;
- `core/database.py` model `Comparison`;
- `src/llm_core.py` and `src/endpoint_resolver.py`;
- frontend modules under `static/js/compare/`;
- `static/js/chat.js`, `static/js/sessions.js`, `static/js/models.js`, and `static/js/slashCommands.js`;
- `tests/test_compare_*` and focused blind-compare redaction tests.
## Runtime Behavior
The active text compare UI creates ordinary `[CMP]` sessions through `/api/session`, then streams each pane through `/api/chat_stream` with `compare_mode=true`. Search compare is a separate branch: it can query `/api/search/query` directly and its synthesis sessions use ordinary chat streaming without `compare_mode=true`. `static/js/compare/index.js` owns compare orchestration, session creation, execution order, search-mode branching, and export actions. `static/js/compare/panes.js` owns pane add/remove/swap/reroll lifecycle. `static/js/compare/stream.js` owns pane streaming and event rendering.
`routes/compare/compare_routes.py` owns the `/api/compare` HTTP surface for alternate/legacy start/vote/history/delete behavior and the active `/api/compare/record` vote-summary endpoint. The top-level module is a compatibility alias. Legacy `/api/compare/start` uses neutral helper-session names and withholds model identities/mapping from the start response while blind mode is active. It does not own provider-specific payload behavior.
Current call sites include:
- `/api/session` compare session creation and cleanup in compare frontend modules;
- `/api/chat_stream` pane execution through chat routes and detached stream infrastructure, streamed directly into panes so upstream generation stops promptly when panes are stopped;
- `/api/models` and probe routes for model/endpoint selection;
- search-provider compare mode through `routes/search/search_routes.py`;
- `/api/compare/record` as a fire-and-forget backend vote summary, while active scoreboard state is localStorage-backed.
`Comparison` rows currently persist vote/history metadata: prompt, first model identifiers, winner, blind flag, optional N-model JSON in `blind_mapping`, vote timestamp, and owner. Response and metric columns exist in the schema but are not populated by the active compare UI flow. Compare history must be owner-scoped.
Frontend compare behavior is split by responsibility:
- `state.js` owns local compare state;
- `selector.js`, `models.js`, and `probe.js` own endpoint/model selection and probe UI;
- `panes.js` and `stream.js` own paired response rendering;
- `vote.js` and `scoreboard.js` own voting and history display.
Compare panes can receive `ask_user` or tool-approval controls from the shared chat stream. `static/js/compare/stream.js` routes those controls into the main chat renderer/control plane, pauses pane completion/autograding while a choice is pending, and can resume the pane after the user decision; compare orchestration keeps its busy state until those continuations settle.
Mobile compare layout collapses multi-pane grids to a single column so panes
remain readable on narrow screens while the desktop grid still uses the
selected column count.
## Ownership Boundaries
Compare owns paired evaluation flow and pane state. Chat routes own the actual stream execution path for compare panes. LLM provider code owns model-call mechanics. Session/model routes own endpoint-id resolution, owner-filtered endpoint/model visibility, header copying, and deleted-endpoint failures.
`compare_mode` in chat strips compare-breaking tools, disables document tools for `[CMP]` sessions, skips some research clarification, and suppresses memory, skill, and webhook side effects after pane responses.
Compare frontend code is part of the app DOM security surface. Current stream/search rendering sanitizes probe labels and tool labels, constrains search-result links to HTTP(S), uses safe generated-image display sources, and opens compare export/image popups with opener isolation.
## Policy Notes
- Current blind compare is UI/API masking until vote/reveal, not a full confidentiality boundary. `[CMP]` session names and session-list model fields are redacted for helper sessions, and legacy `/api/compare/start` withholds model identity/mapping while blind. Client-side selected model state and privileged/local inspection can still expose identity.
- Compare endpoint lists and secondary endpoint lookups use owner filtering so users see and resolve only shared or owned endpoints.
- Non-admin compare session creation must use registered owner-visible endpoints; compare must not allow arbitrary raw endpoint URLs to bypass session-route endpoint policy.
- Prefetched search, URL, RAG, and research context entering compare panes must use the untrusted-context wrapper.
- Compare panes use chat's foreground routing contract: selected routes are strict unless that owner explicitly enabled ordered foreground fallbacks. Verify each pane still reaches its intended route and that any opt-in route transition or error is visible.
## Degraded And Compatibility Behavior
- Missing/offline endpoints are surfaced by model/session routes; chat can clear orphaned endpoint references and recover empty models when possible.
- Compare streams inherit chat's opt-in, eligible-pre-output-only foreground fallback and provider-normalized SSE events, but compare frontend handling for errors and model/endpoint route transitions is thinner than chat's stream path.
- Shared legacy `ModelEndpoint.owner == NULL` rows remain visible through owner filters. Legacy `Comparison.owner == NULL` rows are not treated as shared for authenticated vote/delete/history flows.
- `/api/compare/start` and `/{comp_id}/vote` remain implemented but are not the active frontend path.
## Current Gaps
- Blind mode is not a confidentiality boundary; client/local state can still expose model identity before vote.
- `/api/compare/start` accepts raw endpoint URLs and can diverge from `/api/session` endpoint-owner/raw-endpoint policy.
- `src/agent_loop.py` advertises stale compare app API endpoints.
- Compare streaming and chat streaming are separate frontend paths but share model/provider infrastructure; regressions can happen when provider event shape changes.
- Compare frontend needs explicit fallback/error event handling parity with chat streaming.
- Compare tests cover endpoint owner helper behavior, blind compare redaction, ask-user/tool-approval routing, and portable JS helpers, but not full active `/api/session` pane creation, frontend pane lifecycle, or complete SSE fallback/error handling.

113
specs/context-building.md Normal file
View file

@ -0,0 +1,113 @@
# Context Building
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model-context construction in:
- `src/chat_processor.py`;
- `src/chat_handler.py` and `src/youtube_handler.py`;
- `routes/chat_helpers.py` and context injection in `routes/chat_routes.py`;
- `src/agent_loop.py`;
- `src/tool_execution.py`;
- `src/attachment_refs.py` and uploaded-file manifest construction in
`routes/chat_helpers.py`;
- `src/tool_policy.py`;
- `src/prompt_security.py`;
- `src/tool_capabilities.py`, `src/tool_approval_scopes.py`, and `src/tool_approvals.py`;
- transport primitives in `src/outbound_fetch.py` plus fetch/extraction adapters in `src/search/content.py` and `services/search/content.py`;
- search orchestration in `services/search/core.py` and the compatibility wrapper in `src/search/core.py`;
- RAG and personal docs in `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, and `src/personal_docs.py`;
- research flows in `src/deep_research.py`, `src/research_handler.py`, and `services/research/research_handler.py`;
- memory and skills in `src/memory.py` and `services/memory/*`;
- related policy in `THREAT_MODEL.md`.
## Contract
Context-building tools gather evidence. They do not own user-intent routing.
Runtime rules:
- if external context is available, add it as compact untrusted source data;
- if an attempted source is unavailable and relevant, represent the unavailable state explicitly with source and reason when known;
- preserve the user's original message for the model;
- do not use regex preprocessing to force literal-vs-fetch intent;
- do not disable tools or force a reply style solely because preprocessing found a URL.
## Untrusted Data
`src.prompt_security` owns the untrusted wrapper:
- `UNTRUSTED_CONTEXT_POLICY` states global model policy;
- `untrusted_context_message(label, content)` wraps source content as user-role data with `metadata.trusted = False`, provenance origin, and an `arm_tool_gate`/`tool_gate_untrusted` signal that defaults to arming the server-owned tool gate.
Current untrusted context sources include:
- fetched URLs and web search results;
- webpage content passed into deep-research extraction;
- YouTube transcripts/comments;
- RAG/personal document chunks;
- memories and skills;
- notes and active editor documents;
- emails and attachments;
- tool output from external/user-controlled data.
Live multimodal provider blocks can contain data URLs, but persisted and
tool-facing context uses stable attachment references. Tool manifests carry an
`odysseus://attachment/<id>` URI and owner-checked read policy; local paths are
compatibility data added only after owner and root-confinement checks. Persisted
chat context keeps readable text/reference lines rather than reinserting raw
media bytes into later turns or search state.
## URL, Search, And Tool-Derived Context
Chat URL prefetch and agent `web_fetch` are different paths. Chat prefetch happens before the model call; `web_fetch` is a tool the model may choose later. Both should converge on the same intent: enrich context when content is available, represent unavailable content when it is not, and let the model interpret the user request.
Search results and fetched pages are evidence. `web_search` should not force a page fetch unless its explicit contract says it does. Failed fetches should not crash chat or silently imply content was read. Canonical search content fetchers can extract readable text from HTML, `text/*`, Markdown, `.txt`, `.json`, and `.jsonl` responses and should return shaped error results for HTTP status failures. URL fetches validate every redirect hop and pin the outbound connection to a public IP resolved during validation, so context-building callers do not need a second DNS-rebinding guard.
Current behavior is not yet unified:
- successful chat URL prefetch is wrapped as untrusted context; failed prefetch now adds a compact untrusted statement that the page was not read, recognizes only transport-owned HTTP/size/rate-limit categories, and suppresses raw exception/response text;
- agent `web_fetch` returns explicit URL-specific tool errors for timeout, unsupported scheme, fetch failure, or no readable text;
- comprehensive search reports provider-chain failures, but individual page-fetch failures can be logged and omitted;
- YouTube fetching is owned by `ChatHandler`/`youtube_handler`, while `routes.chat_helpers` only wraps the resulting transcript/comment strings.
`src.outbound_fetch` owns reusable synchronous public-URL classification, per-hop DNS resolution/pinning, redirect handling, and body budgets. `services/search/core.py` owns `comprehensive_web_search()` orchestration. `services.search.content` owns content extraction and adapts the shared transport; `src/search/core.py` and `src/search/content.py` preserve compatibility imports without a second implementation.
## Tool Result Envelope
`src.tool_execution` executes and formats tools. Tool output caps live in `src.constants` and are re-exported through older facades; shared native-tool truncation lives in `src.tool_utils`. `src.agent_loop._append_tool_results()` owns model re-entry: native tool calls return as provider-style `role: "tool"` messages with untrusted metadata, while fenced-tool results use the untrusted wrapper. Classification considers both the requested tool and the result payload, so remote or stored model-visible content can arm the session gate even on a failed tool status.
Taint is server-owned continuation state, not a model instruction. After untrusted external/workspace context, low-impact reads can continue, but high-impact, unknown, and arbitrary MCP actions become proposals that produce an exact approval card. The server seals the exact first action plus private continuation tool/query state; document actions also bind the current document version and digest. A chat decision can allow the resumed task or persist a grant for later turns in that exact chat, while non-chat callers remain single-action. Blocked/approval placeholders and content-free failures do not recursively arm the gate.
Context budgeting uses known model context windows when available. `src.context_budget` treats the default 6000-token value as an automatic sentinel, scales to a capped fraction of known context length for non-explicit budgets, and leaves unknown windows on conservative defaults.
Side-effect enforcement lives outside context building. Chat route disabled-tool policy, `src.tool_security`, `src.tool_execution`, and `do_app_api()` block unsafe tool execution; prompt wording alone is not the authority.
Guide-only/no-tools policy can suppress context acquisition before the model call. `src.tool_policy` feeds chat route preprocessing and agent-loop assembly so tool-backed search/research/memory/RAG/skills/local-context paths are skipped when the latest user turn explicitly forbids tools.
## Degraded And Optional Dependencies
- ChromaDB, HTTP embeddings, and FastEmbed are installed/expected in normal setups but must degrade cleanly when a service, package, or embedding backend is unavailable.
- `src.rag_singleton.get_rag_manager()` owns RAG startup retry throttling; `src.rag_vector.VectorRAG` is the live owner-filtered path; `src.rag_manager.RAGManager` is compatibility/backward-compat behavior.
- Memory-vector and tool-index retrieval can fall back to keyword/text behavior when vector stores or embeddings fail.
- Docker compose and native installs use different Chroma host defaults; model endpoint loopback rewriting is owned by model/runtime specs.
## Current Call Sites Include
- `ChatProcessor.build_context_preface()` for memory, RAG, web search, URL content, and skills index;
- `ChatHandler.preprocess_message()` and the canonical `services.youtube.youtube_handler` import path for YouTube fetch/format, then `routes/chat_helpers.py` for wrapping prefetched search/Youtube context;
- `routes/chat_routes.py` research context injection;
- `src.agent_loop` for active editor document, skill context, and tool-result reinsertion;
- uploaded-file manifest/reference context for agent tools and later chat turns;
- `src.tool_execution` for `web_search`, `web_fetch`, file, shell, MCP, and other tool outputs;
- `src.deep_research` and research handlers for search/fetch/extract flows used by research jobs, with fetched webpage text wrapped before extraction and analyzed URLs tracked separately from source snippets.
## Current Gaps
- URL/search context result shape is not unified across chat prefetch, agent tools, and research.
- Failed fetch representation remains inconsistent outside direct chat URL prefetch, especially in comprehensive search and research aggregation.
- Tool/context wording is spread across schema, prompt, and retrieval surfaces.
- Source-specific wrapping and unavailable-state behavior still needs broader focused coverage for literal URL intent, research, RAG/memory/skills, and YouTube; external tool results and approval continuation now have dedicated gate/taint regressions.
- Compare pre-search context is computed but may not be submitted through the current compare stream form.

195
specs/cookbook-hwfit.md Normal file
View file

@ -0,0 +1,195 @@
# Cookbook And Hardware Fit
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model setup/serving and hardware fit in:
- app route registration in `app.py`;
- `routes/cookbook_routes.py`;
- `src/cookbook_serve_lifecycle.py`;
- `src/host_docker_access.py`;
- Cookbook package/rebuild/shell integration in `routes/shell_routes.py`;
- `routes/cookbook_helpers.py`;
- `routes/hwfit_routes.py`;
- `services/hwfit/*` and `services/hwfit/data/hf_models.json`;
- durable Cookbook state through `routes.cookbook_helpers.COOKBOOK_STATE_FILE`;
- helper/CLI scripts `scripts/odysseus-cookbook`, `scripts/add_hwfit_models.py`, `scripts/hf_download.py`, and `scripts/diffusion_server.py`;
- Docker overlays `docker-compose.gpu-*.yml`, `docker/gpu.*.yml`, `docker/host-docker.yml`, `scripts/check-docker-gpu.sh`, and `scripts/check-docker-amd-gpu.sh`;
- frontend modules `static/js/cookbook*.js`, including Cookbook running, serve, download, diagnosis, progress, and HW Fit modules;
- tests covering Cookbook helpers, routes, CLI state, package detection, frontend progress, HW Fit services, serve profiles, Docker GPU overlays, and GPU diagnostic scripts.
## Current Call Sites Include
- Cookbook modal and state modules in `static/js/cookbook*.js`;
- package readiness/install and rebuild flows through `routes/shell_routes.py`;
- direct shell exec/stream integration used by Cookbook task controls;
- model endpoint setup and serve flows;
- hardware-fit recommendations for model choices;
- image-model recommendations for diffusion serving;
- APFEL/local platform dependency paths where supported;
- Docker GPU helper scripts and compose overlays;
- the `odysseus-cookbook` CLI using the same Cookbook state file.
## Cookbook Runtime
`routes.cookbook_routes` owns model download, setup, SSH key, cached model scan, serve, GPU state, kill-pid, state sync, Hugging Face latest lookup, vLLM recipe lookup, serve diagnosis, and task-status endpoints. `src.cookbook_serve_lifecycle` bridges scheduled `cookbook_serve` tasks into serve/stop behavior; task/calendar scheduling ownership stays in `calendar-tasks-notes.md`.
Access policy is split by surface:
- download/setup/SSH key/cache scan/serve/GPU/kill/state/task-status are admin/internal-tool surfaces;
- `/api/cookbook/hf-latest` is authenticated-user gated;
- HW Fit routes are authenticated read/probe routes through normal middleware, not admin-only operations;
- bearer API tokens do not satisfy Cookbook admin gates.
Runtime behavior:
- POSIX and most remote flows run detached through tmux;
- local Windows uses detached process/log/pid behavior under `%TEMP%\\odysseus-tmux`; Python first publishes a valid Win32 fallback PID, then Git Bash may replace it with `/proc/$$/winpid` after a ready-file handoff, so PowerShell `Stop-Tree` can terminate the actual serving shell and children instead of receiving an MSYS PID. Frontend PowerShell venv activation is quoted safely and the local Git Bash runner converts a valid `Scripts\\Activate.ps1` prefix into `source <git-bash-path>/Scripts/activate` so the selected environment actually supplies the serve binary;
- remote Windows uses PowerShell runner scripts;
- missing `tmux`, `docker`, or serve-engine binaries return shaped errors where possible;
- local Docker inside the Odysseus container is available only when the Docker CLI exists, `ODYSSEUS_ENABLE_HOST_DOCKER=true`, and `/var/run/docker.sock` is actually mounted as a socket; otherwise Cookbook should show the host-Docker access hint and prefer remote SSH Docker workflows;
- model serve auto-registers LLM or image `ModelEndpoint` rows immediately, then frontend readiness probing can repair/create fallback endpoints;
- diffusion-server serves are registered as image endpoints;
- MLX image serves use `scripts/mlx_image_server.py`, which pins generation/edit dispatch to the model chosen at process start and ignores OpenAI-compatible per-request model selectors;
- vLLM recipe routes fetch and cache model recipe manifests/YAML from `vllm-project/recipes`, normalize base args/env/dependencies/tool-calling/reasoning variants, and expose compatible strategy metadata for serve setup;
- Hugging Face download/setup paths can detect and persist encrypted HF tokens for later Cookbook/agent use;
- local and remote model paths can contain spaces or non-ASCII characters when helper validation/quoting accepts them;
- task status handles tmux, remote Windows logs, local Windows PID/log files, HF cache completion checks, stale browser-state download guards, pip dependency-install success sentinels, exit-code wrappers, serve diagnosis snapshots, and scheduled serve lifecycle hooks;
- scheduled serve lifecycle stop attempts only persist `status=stopped`, clear `_scheduledStopAtMs`, and delete auto-registered endpoints for sessions whose tmux/remote stop command succeeded or were already gone; failed stop attempts are logged without marking unrelated expired serves as stopped.
`routes.cookbook_helpers` owns validation and command construction:
- repository and model IDs;
- local directories, SSH hosts/ports, GPU selectors, and tokens;
- shell quoting for Bash and PowerShell;
- pip/install fallback chains;
- safe environment prefixes;
- serve command validation;
- user-shell PATH bootstrap, Git-Bash drive-path conversion, preflight, and exit-code helpers.
Cookbook routes request shell/SSH behavior; they do not relax shell security.
## Shell Dependencies
`routes.shell_routes.py` owns Cookbook-adjacent package readiness/install, shell execution/streaming, and llama.cpp rebuild endpoints. The Cookbook UI calls these routes for dependency diagnosis, install/update actions, engine rebuilds, and tmux/reconnect/stop/kill flows. Windows uses detached log/PID wrappers where POSIX tmux is unavailable.
These are admin-only code-execution surfaces and should be reviewed with Cookbook changes even though they are implemented outside `routes.cookbook_routes.py`.
## State, Secrets, And Provenance
Cookbook state lives under the shared data dir through the `COOKBOOK_STATE_FILE` constant, normally `data/cookbook_state.json`. Routes and the `odysseus-cookbook` CLI use the same state path.
State behavior:
- browser-facing state masks secrets;
- server-side `env.hfToken` is encrypted before storage;
- task payloads strip raw HF tokens;
- browser local storage strips HF token values;
- state POST has anti-wipe guards for server lists;
- state POST rejects stale `done` download state when the latest shard/cache markers still show an incomplete download;
- recent server-side tasks are preserved against stale browser overwrites;
- task-status validates saved shell-bound fields before SSH/tmux commands.
Cookbook auto-registered endpoints are currently shared/null-owner rows with no API key when created by backend serve registration. Browser fallback registration goes through the normal model-endpoint route. The desired ownership policy for Cookbook-created endpoints should remain explicit.
HW Fit is an MIT-licensed llmfit adaptation; attribution lives in project acknowledgments/licenses.
## Hardware Fit
`services/hwfit/hardware.py` owns hardware detection across NVIDIA, AMD, Apple Silicon, Windows, CPU, RAM, available RAM, remote SSH, container/native probe context, and cached host detections.
`services/hwfit/models.py`, `fit.py`, `profiles.py`, `image_models.py`, and
`hf_discovery.py` own model catalog loading, normalization, API-backed dynamic
catalog refresh, memory estimates, quantization labels, fit scoring, serve
profile computation, image model ranking, and backend/format servability
filtering.
`routes/hwfit_routes.py` owns the HTTP surface and manual hardware override application.
Runtime behavior:
- hardware detection uses a cache with `fresh=true` bypass;
- probe results include scope/container visibility metadata, and containerized no-GPU/low-RAM states can return user-facing visibility warnings with rescan/manual/copy-diagnostics actions;
- manual hardware replacement is a what-if simulator, not additive hardware;
- manual hardware accepts `cuda`, `rocm`, `metal`, `cpu_x86`, and `cpu_arm`
backends and must stay in lock-step with backend support in `fit.py`. Metal
simulation marks unified memory and filters toward locally servable GGUF/MLX
choices instead of CUDA/vLLM-only formats.
- ignore switches can drop detected GPU/RAM before ranking;
- homogeneous GPU grouping targets realistic multi-GPU pools;
- image model ranking normalizes to a single-GPU fit view;
- Metal/RDNA/backend restrictions can filter otherwise fit models.
- Apple Silicon bandwidth estimates use chip/core-specific tables for M-series Max/Pro/Ultra variants and avoid matching non-Apple GPU names.
- Windows and Apple/consumer-AMD paths filter toward GGUF/llama.cpp-compatible
choices. On multi-GPU systems, fixed GGUF target quantization that cannot be
served by the selected backend returns `no_fit` rather than `None`.
## Platform And Degraded Behavior
- Linux, Windows/PowerShell, macOS, Docker, NVIDIA, AMD, Apple Silicon, and CPU-only systems have different command paths.
- Remote hosts are accessed through SSH helpers; Cookbook host/port/path inputs must be validated before command construction.
- HW Fit remote host/port query values currently do not share all Cookbook route-level validation before SSH probing.
- Missing local tools or failed installs should surface command/output/error detail where possible.
- GPU overlays remain optional and do not break CPU-only deployments.
- Docker GPU overlays pass host devices/env; they do not install CUDA/ROCm engines by themselves.
- Default Docker Compose intentionally does not mount the host Docker socket. `docker/host-docker.yml` is an explicit high-trust overlay for operators who accept broad host-Docker control from inside the container.
- NVIDIA Docker diagnostics are read-only by default, and `.env` edits/install actions require explicit flags.
- AMD Docker diagnostics are read-only and do not mutate `.env`.
- vLLM is rejected on unsupported Windows/macOS paths.
- llama.cpp CPU-only and GPU fallback scripts should preserve usable CPU paths.
- SSH probe failures, GPU driver errors, and no-GPU states should be distinguishable.
- Remote SSH host/port validation is shared through route validators for Cookbook/HWFit paths.
- Windows launcher/runtime Git Bash discovery includes per-user installs under `%LocalAppData%\\Programs\\Git`, and WSL/Git Bash detection shapes PATH handling for NVIDIA/remote flows.
- macOS startup helpers start ChromaDB alongside the app path.
- Ollama serve can auto-pick an available port, and scheduled task stop paths
verify stop success before persisting a stopped state.
## Model Catalog And Latest Lookup
HW Fit model scoring depends on bundled `services/hwfit/data/hf_models.json`,
bundled `services/hwfit/data/mlx_community_models.json`, runtime dynamic caches
under `DATA_DIR/hwfit/`, catalog normalization, and assumptions about model
formats and quantization. `scripts/add_hwfit_models.py` updates the static HF
catalog.
Hugging Face latest lookup and HW Fit dynamic refresh use external Hub metadata
and can degrade to empty, unknown-size, partial, or malformed-result behavior.
`refresh_catalog=1` refreshes API-backed collection caches for MLX community
and selected HF organization collections, with a 24-hour freshness guard and
bundled JSON fallbacks when the network/cache is unavailable. HW Fit tolerates
non-numeric `gpu_count` values from callers. Model normalization also treats
non-string `parameter_count` and quantization fields as unknown rather than
calling string methods and aborting the ranking pass. Catalog drift and dynamic
latest-model metadata are separate sources of recommendation drift.
## Security Policy
Admin gates must stay in place for install, serve, kill, setup, state mutation, and shell-like actions. `/api/shell/exec` is an admin primitive used by Cookbook task control and must stay in this review boundary. Scheduled `cookbook_serve` tasks are admin-only action tasks; task create/update/manual run/webhook/scheduler execution must all reject or pause them for non-admin owners.
Kill-pid guardrails:
- admin-only;
- PID floor;
- signal allowlist;
- validated remote host/port;
- frontend confirmation for TERM/KILL cleanup.
Shell-bound Cookbook inputs must pass helper validation before command construction. HF tokens, Cookbook state secrets, and endpoint API keys must remain encrypted or masked and must not be written back to clients in raw form. Host Docker socket access must stay opt-in and clearly distinguished from merely having a Docker CLI in the container.
## Testing Coverage
Existing coverage is strongest for helper validation/quoting, SSH host validation, pip fallback and dependency-completion regressions, cached scan scripts, serve profile computation, scheduled serve lifecycle state persistence, hardware detection/ranking across AMD/NVIDIA/macOS/manual/container modes, MLX/Metal ranking and request-model pinning, manual backend simulation, Docker GPU compose overlays, Cookbook CLI state, package detection, Windows venv/path/task helpers, non-numeric GPU counts, non-string model catalog fields, and selected frontend progress regressions.
Route-level auth/security and degraded-return coverage is thinner for Cookbook admin routes, shell dependency routes, `/api/cookbook/hf-latest`, state/status edge cases, HW Fit routes, frontend JS behavior, and helper scripts such as `hf_download.py`, `add_hwfit_models.py`, and `diffusion_server.py`.
## Current Gaps
- Cookbook-created model endpoint ownership/shared/null-owner policy needs a deliberate decision.
- `/api/shell/exec` and Cookbook package/rebuild routes need to remain cross-referenced with shell/admin specs because they are Cookbook-critical code-execution surfaces.
- Cookbook route auth/security and degraded-return behavior need route-level tests.
- `/api/cookbook/hf-latest` needs tests locking its user-authenticated access policy and failure behavior.
- HW Fit routes need route-level tests around missing catalogs, manual overrides, `fit_only`, profiles, and image-model cases.
- Dependency install/serve diagnosis remains split across Cookbook routes, shell routes, frontend diagnosis, optional binaries, and platform-specific scripts, even though longer serve-output tails are centralized through `routes/cookbook_output.py`.
- Model catalog, quantization, backend, and Hugging Face metadata drift need ongoing maintenance.

View file

@ -0,0 +1,205 @@
# Documents, RAG, And Uploads
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers file/document context, document storage, and vector retrieval in:
- `app.py` and `src/app_initializer.py` route/manager wiring;
- `routes/upload_routes.py`, `routes/personal_routes.py`, `routes/embedding_routes.py`, canonical `routes/document/document_routes.py` and `routes/document/document_helpers.py`, plus their top-level compatibility shims;
- chat attachment paths in `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, and `src/chat_processor.py`;
- `core/session_manager.py`, `src/attachment_refs.py`, `src/upload_handler.py`,
`src/upload_limits.py`, and the public reference contract in
`docs/attachments.md`;
- `src/document_processor.py`, `src/document_actions.py`, `src/personal_docs.py`, and `src/markitdown_runtime.py`;
- `src/rag_singleton.py`, `src/rag_vector.py`, `src/rag_manager.py`, `src/chroma_client.py`, `src/embeddings.py`, and `src/embedding_lanes.py`;
- PDF/form helpers in `src/pdf_runtime.py`, `src/pdf_forms.py`, and `src/pdf_form_doc.py`;
- `services/docs/service.py`;
- document, upload, RAG, chat, email, and admin frontend callers in `static/app.js`, `static/js/chat.js`, `static/js/chatRenderer.js`, `static/js/fileHandler.js`, `static/js/document.js`, `static/js/documentLibrary.js`, `static/js/rag.js`, `static/js/admin.js`, `static/js/emailInbox.js`, and `static/js/slashCommands.js`;
- tests covering upload, document, attachment, PDF, RAG, Chroma, MarkItDown, and embedding behavior.
## Runtime Integration
`app.py` registers upload, personal-doc/RAG, embedding, document, diagnostics, and Codex document routes. `src.app_initializer.initialize_managers()` creates `UploadHandler` and `PersonalDocsManager`, installs the upload handler on `SessionManager` and the shared tool helper, and startup attempts to initialize the RAG singleton. App route wiring passes that same handler to session/history, document, note, and calendar writers that can persist upload references.
`src.rag_singleton.get_rag_manager()` returns the live `VectorRAG` instance when Chroma/embedding dependencies are reachable. Personal routes can retry the singleton and return explicit 503s when unavailable. Chat RAG uses the `PersonalDocsManager.rag_manager` captured during app initialization and can silently skip RAG if that manager is absent.
## Uploads And Attachments
`src.upload_handler.UploadHandler` owns upload IDs, safe filenames, upload metadata, owner rename rewrites, atomic `uploads.json` writes, content-type detection, and file storage under `data/uploads`. Upload IDs accept extensionless values or one sanitized alphanumeric extension.
Upload-index reads track the live and `.bak` files by device, inode, size, nanosecond mtime, and ctime, then verify the combined signature after parsing. This catches same-timestamp corruption/replacement and prevents stale parsed data from being cached under a newer file identity. Non-destructive reads can recover from the backup; destructive cleanup requires a valid live index and never treats an older backup as deletion authority. Lifecycle writes can synchronize the backup so intentionally removed metadata is not resurrected.
`src.upload_limits` owns central upload-size caps and environment overrides for chat attachments, gallery, transforms, memory import, personal uploads, email compose, STT audio, and ICS imports. Invalid configured limits fail fast at import so routes do not silently accept unsafe sizes. Docker installs `libmagic1` plus `python-magic` so `UploadHandler.detect_content_type()` can sniff bytes in the official image; native installs can fall back to extension/MIME guesses when `python-magic` is unavailable.
`routes/upload_routes.py` owns:
- `POST /api/upload`, returning uploaded file metadata;
- reference-aware admin upload cleanup and stats;
- `GET /api/upload/{file_id}`;
- `GET/PUT /api/upload/{file_id}/vision` for editable OCR/vision cache;
- thumbnail and masked owner/admin access behavior.
It does not currently expose a general upload list/delete route. Download/preview responses that serve uploaded content should include `X-Content-Type-Options: nosniff` where route code owns the response so browser MIME sniffing does not widen accepted upload types.
Readable/code-like upload handling includes common text/code extensions plus `.nix`; document processing renders recognized code-like text into fenced blocks with language metadata.
Chat does not own attachment extraction. Runtime flow:
- the frontend uploads files and submits attachment IDs;
- `ChatHandler.preprocess_message()` resolves IDs with the session owner through `UploadHandler.resolve_upload()`, which enforces owner/admin access and no longer treats missing owner context as permission to read owned uploads;
- vision/OCR cache and attachment metadata are prepared before model calls;
- text-only models receive stripped multimodal blocks;
- `src.document_processor.build_user_content()` produces model-ready text, PDF text, Office/EPUB text when MarkItDown or the DOCX fallback is available, image/multimodal blocks, truncation, and PDF/Office auto-document updates;
- chat streams attachment, PDF-created `doc_update`, and `rag_sources` events where applicable.
Extensionless image and audio attachments derive their data-URI subtype from
the detected MIME type, so `image/png` and `audio/mpeg` uploads do not become
invalid `data:image/;base64` or `data:audio/;base64` blocks when the filename
has no extension.
## Durable References And Cleanup
`src.attachment_refs` owns the stable `attachment_ref` shape used outside raw
upload storage: attachment id, name, MIME type, size, and optional checksum,
creation time, dimensions, vision text/model, and gallery id. Live provider
calls may still receive multimodal data URLs for the current turn, but durable
chat content is normalized to readable text plus compact reference lines.
Structured references remain in message attachment metadata, and chat FTS
triggers omit inline media while startup migration scrubs legacy indexed data
URLs.
Agent/tool manifests expose `odysseus://attachment/<id>` with
`read_policy: "owner_checked_upload"`. A compatibility filesystem path is
included only after owner-aware upload resolution, upload-root confinement, and
tool-readable-root checks; the stable contract for external tools is the URI
and attachment id, not host layout.
Writers reserve referenced uploads before committing durable state. This
includes session message append/replace and history rewrites, document
create/update and native document edits, note route/tool create/update,
calendar/event route/tool create/update, and attachment-bearing session
updates. A missing or wrong-owner reference aborts before destructive
replacement and surfaces a route conflict or tool error. Reservations serialize
with cleanup through the upload-index lock and refresh access time.
Admin cleanup first scans chat content and attachment metadata, current and
versioned documents including PDF markers, gallery filenames/hashes, note
image/color/content/checklist fields, and calendar color/description/location
fields. Reference discovery or index-integrity failure aborts cleanup; the
lower-level API removes nothing without both completed id and hash snapshots.
Only expired, unreferenced files with coherent id/path/owner/checksum/timestamp
metadata are candidates. Matching index rows are persisted away before byte
deletion and restored if deletion fails. This lock is process-local, so the
documented race protection assumes the current single-worker deployment.
## Living Documents And PDF
`routes/document/document_routes.py` owns the HTTP document API: create/read/update/archive/delete, library listing, import/export, version history, tidy/AI tidy, PDF rendering/export, PDF form helpers, and email-attachment reply preparation. The top-level document route/helper modules remain compatibility aliases.
`static/js/documentLibrary.js` owns local library state after archive/delete actions, including total counts and language chips. Server route truth still owns durable document state.
`static/js/document.js` owns the browser document editor and markdown preview. Preview rendering applies code highlighting when highlight.js is present, renders Mermaid diagrams when the Mermaid runtime is available, refreshes after AI edits, and discards pending AI diffs before switching the active document.
Document mutations also happen through agent tools, Codex document routes, email attachment import, and scripts. HTTP and native-agent document writers owner-reserve any internal upload/PDF references before persisting new current content or versions. Native document tool outputs include metadata that the browser can use to open/update the editor if a later stream update is missed. Those callers must preserve document owner, attachment, and version semantics.
After external/workspace-untrusted context, a proposed document mutation is sealed into an exact approval with document id, current version, content digest, tool content, owner/session, and workspace. Approval continuation re-reads and verifies those fields before consuming the one-use authorization, so an intervening edit cannot apply a stale approved patch to new content.
Email draft documents are a first-class document language. Create/update paths
detect the `To`/`Subject`/header shape, coerce language to `email`, and preserve
protected reply/forward headers such as `In-Reply-To`, `References`,
`X-Source-UID`, `X-Source-Folder`, attachment headers, and quoted/original
history when model or UI edits replace the draft body. Creating a draft for the
same source UID/folder in the same session updates the active draft instead of
creating a duplicate.
`Document` rows own current content and owner. `DocumentVersion` rows own immutable snapshots. Document access should be owner-filtered, not session-id-only; the session document listing path still needs regression coverage for per-document owner filtering after the session owner check.
PDF runtime behavior:
- direct PDF import stores the upload through `UploadHandler`;
- PDF library entries preserve metadata/preview behavior for source PDFs;
- pypdf text extraction remains core;
- PyMuPDF enables form detection, page rendering, page PNGs, annotation fill, render/export PDF, and form filling;
- PDF render routes should return a shaped 503 when PyMuPDF is absent and use same-origin framing/download behavior for rendered pages;
- imported PDFs become either plain `pdf_source` markdown or `pdf_form_source` markdown with sidecar field data;
- PDF markers must resolve back through an upload owned by the caller;
- signed-reply preparation uses document `source_email_*` provenance and verifies the document owner and signature owner. Source email account resolution still needs explicit owner-scoped coverage.
Office/EPUB attachment extraction is optional and MarkItDown-backed for `.docx`, `.pptx`, `.xlsx`, `.xls`, and `.epub`; a pure-Python DOCX fallback can extract `word/document.xml`. When a session id is present, full extraction can be saved as a markdown `Document` while the chat-inline copy remains capped.
## Personal Docs And RAG
`src.personal_docs.PersonalDocsManager` owns personal-directory indexing and keyword retrieval.
`src.rag_vector.VectorRAG` owns Chroma/embedding-backed indexing and owner-filtered retrieval. Chunk ids are owner-scoped so byte-identical chunks from different owners do not suppress each other. `src.rag_singleton` owns lazy initialization, retry throttling, and reset behavior.
`routes/personal_routes.py` owns personal-doc and direct RAG-upload routes. Directory list/index/delete routes are admin-gated, and directory indexing runs in a worker thread so traversal/extraction does not block the async event loop. Direct RAG upload is user-authenticated, requires document privilege, forwards owner into the manager wrapper, writes unique files under per-owner subdirectories of `data/personal_uploads`, and has looser file-type validation than normal uploads.
Current call sites include:
- admin RAG pages and slash commands;
- chat RAG preface building;
- AI interaction and MCP RAG management tools;
- CLI scripts for document/personal indexing.
Some non-route tool/script paths can index ownerless or arbitrary directories and should be treated as compatibility-sensitive management surfaces.
## Embedding Models
`routes/embedding_routes.py` owns admin-gated embedding model and custom endpoint management. It validates custom endpoints with outbound URL checks, can persist and process-expose `EMBEDDING_API_KEY`, resets embedding/RAG/tool-index/Chroma state, and does not own document extraction.
`src.embeddings` owns HTTP embedding fallback to FastEmbed and process-level endpoint state. `src.embedding_lanes` keeps custom HTTP embedding vectors separate from FastEmbed fallback vectors with lane-specific Chroma collections, migrates legacy unsuffixed collections into empty lanes, and dedupes query results across lanes. `src.chroma_client` owns native Chroma defaults and fast reachability checks.
## Compatibility State
`src.rag_manager.RAGManager` is a backward-compat wrapper. The live owner-aware vector path is `VectorRAG`.
`services/docs/service.py` is a separate facade. It accepts live `VectorRAG` query rows (`document`, `similarity`, nested metadata source), retains legacy `text`/`content` and `score` fallbacks, skips non-object rows, and maps live `indexed_count`/`failed_count` plus legacy `indexed`/`failed` index summaries into its dataclasses.
`src.database` re-exports `core.database`; document models and migrations live in `core.database`.
## Optional And Degraded Behavior
- ChromaDB/FastEmbed are default installed dependencies, but Chroma can be offline or unreachable.
- Native Chroma defaults to `localhost:8100`; Docker uses the `chromadb:8000` compose service and persistent Chroma storage.
- HTTP embeddings can fall back to FastEmbed; when both lanes exist, lane separation avoids Chroma dimension conflicts.
- MarkItDown is optional for Office/EPUB extraction; chat attachments and personal directory indexing have clear degraded behavior, while direct RAG upload does not share the same extraction path.
- PyMuPDF is optional, unlocks PDF form/render/fill paths, and carries AGPL implications when installed.
- PyMuPDF-dependent document routes should use the shared runtime helper/error text so missing-dependency and license policy stay visible.
- pypdf text extraction is core and should remain available without PyMuPDF.
## Security And Provenance
Uploaded files, documents, RAG chunks, extracted attachment text, OCR/vision text, PDF marker content, and source-email metadata are untrusted external or user-provided context when sent to an LLM.
Concrete enforcement points include:
- `UploadHandler.resolve_upload()` for upload ID validation, owner/admin access, and upload-dir confinement;
- owner-checked write reservations before durable attachment references are
stored, sharing the upload-index lock with reference-aware cleanup;
- PDF marker ownership checks before resolving source uploads;
- personal-directory and personal-upload confinement helpers, including symlink/realpath checks before deleting uploaded files or removing indexed directories;
- owner-filtered `VectorRAG.search(owner=...)`;
- shared untrusted-context wrappers for RAG preface insertion.
Extracted attachment text is currently appended into the user message rather than wrapped as a separate untrusted-context message. That is current behavior and a prompt-injection hardening gap.
Bearer-token callers are not a scoped document/upload API surface today. Routes that treat token-authenticated users as owners need explicit scope/effective-user policy before they are considered safe token APIs.
## Testing Coverage
Existing useful coverage includes upload owner scope, upload IDs, upload atomicity, durable attachment reference normalization, message/document/note/calendar write reservations, fail-closed reference-aware cleanup, attachment budgets, `.nix` text upload handling, upload/PDF security regressions, Docker `libmagic`/`python-magic` upload detection, RAG owner fallback, Chroma fast-fail, MarkItDown runtime, PDF runtime, document-library counter updates, and selected document helper behavior.
Route-level coverage is thinner for document CRUD, PDF import/render/export/fill, direct RAG upload, embedding admin/security behavior, and RAG unavailable states.
## Current Gaps
- Direct RAG upload still needs clearer file-type validation and MarkItDown/PDF extraction parity decisions.
- Document `session_id` relinking and session document listing need owner-scope regressions.
- Chat RAG can remain degraded after startup even if personal routes later initialize the RAG singleton.
- PyMuPDF-dependent routes do not all share the same optional-runtime helper/error behavior.
- Signed-reply preparation needs owner-scoped source email account/signature regression coverage.
- Document/upload routes need explicit bearer-token scope/effective-user policy.
- User-facing document/PDF/RAG route matrices need more regression coverage for owner denial, admin gates, unavailable services, and degraded optional dependencies.

209
specs/email-contacts.md Normal file
View file

@ -0,0 +1,209 @@
# Email And Contacts
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
This spec covers mail and contacts in:
- app wiring in `app.py`;
- `core.database.EmailAccount`;
- `routes/email_routes.py`, `routes/email_helpers.py`, and `routes/email_pollers.py`;
- email threading in `src/email_thread_parser.py`;
- email MCP tools in `mcp_servers/email_server.py`;
- canonical contact/CardDAV routes in `routes/contacts/contacts_routes.py`,
with `routes/contacts_routes.py` as a compatibility shim;
- Codex email bridge in `routes/codex_routes.py`;
- document signed-reply flows in canonical `routes/document/document_routes.py` and document `source_email_*` fields;
- reminder/task email senders in `routes/note_routes.py` and `src/task_scheduler.py`;
- email/contact agent surfaces in `src/tool_implementations.py`, `src/tool_schemas.py`, `src/tool_index.py`, and `src/agent_loop.py`;
- CLI wrappers `scripts/odysseus-mail` and `scripts/odysseus-contacts`;
- frontend modules `static/js/emailInbox.js`, `static/js/emailLibrary.js`, `static/js/emailLibrary/*`, `static/js/emailShared.js`, `static/js/chatStream.js`, `static/js/document.js`, and `static/js/settings.js`;
- tests under `tests/test_email_*`, `tests/test_contacts_*`, `tests/test_mail_cli_*`, `tests/test_mcp_email_*`, `tests/test_schedule_email_*`, email/contact JS tests, and email security regressions.
## Current Call Sites Include
- browser email inbox/library, compose, schedule, account, and attachment actions;
- document-editor compose, recipient autocomplete, compose uploads, and signed-reply handoff;
- Codex email read/draft/send routes using API-token scopes;
- note reminder and task-output email delivery;
- built-in email summary/reply/calendar/urgency actions;
- scheduled email pollers and CLI one-shot pollers;
- MCP email tools;
- contact manager settings, compose contact autocomplete, agent contact tools, and contacts CLI.
## Email Accounts And Transport
`EmailAccount` rows own IMAP/SMTP configuration. Password fields are string columns containing encrypted ciphertext written with `src.secret_storage`; startup migrations handle legacy plaintext rows. Google OAuth account rows also carry `oauth_provider`, encrypted access/refresh tokens, token expiry, and an optional outbound `display_name`. Do not return decrypted credentials or OAuth tokens, or write them to logs.
Exactly one default account per owner is enforced as a serialized database transition. Startup normalizes legacy duplicate defaults and installs a unique per-owner default constraint/index; first create, delete/promotion, set-default, demo teardown, and owner rename lock the relevant owner rows and commit atomically. Multi-owner rename acquires locks in canonical order so stale concurrent writers fail closed.
`routes.email_helpers` owns:
- account owner assertions and config fallback order;
- IMAP/SMTP connection helpers and related transport utilities;
- Google OAuth2 state signing/verification, token refresh, and XOAUTH2 framing;
- SMTP security modes (`ssl`, `starttls`, `none`);
- envelope recipients and Odysseus headers;
- attachment extraction helpers;
- email pre-retrieval context for AI reply drafting;
- scheduled email, summary, reply, tag, calendar extraction, urgency, and signature-boundary side databases.
Email config can fall back to legacy `data/settings.json` or environment variables when no scoped account is configured. Account discovery now owner-scopes the default/first-enabled fallback and can still match legacy account rows by IMAP username or from-address. That fallback remains compatibility-sensitive in multi-user contexts.
Email owner semantics are route-local and compatibility-sensitive:
- `routes.email_helpers._require_auth()` returns `""` in `AUTH_ENABLED=false` mode, rejects configured auth with no user, and only tolerates first-run anonymous loopback fallback.
- Empty owner is treated as single-user compatibility: account-ownership assertions no-op, default/first-enabled account fallback can be global, and email cache clauses include `owner = '' OR owner IS NULL`.
- Non-empty owners scope account/config/cache queries. Legacy ownerless account
rows are visible to an authenticated owner only when the row's IMAP username
or from-address matches that owner, so old unowned rows do not become global
cross-user accounts in configured multi-user deployments.
`routes.email_routes` owns the HTTP mail surface:
- account CRUD, test, default, and masked config reads;
- Google OAuth authorize/callback for Workspace and .edu Gmail-style accounts;
- list, search, read, folders, and contacts;
- folder role resolution and UID fetch/search helpers used by the route surface;
- owner-scoped route caches and IMAP pool behavior;
- attachments, bulk attachment ZIP downloads, and attachment-to-document flows;
- compose upload, draft/send, `wait_for_delivery`, Sent append, and source `\Answered` marking;
- schedule/list/delete scheduled emails;
- pending agent-draft approval/cancel flows;
- mark read/unread/answered, spam flags, move, archive, and delete. IMAP move/delete/archive operations use UID commands for message identity and fail safe when the requested UID no longer exists; they never reinterpret a missing UID as a sequence number, which could mutate or expunge an unrelated message.
Google OAuth behavior is account-owned:
- `/api/email/oauth/google/authorize` requires an authenticated owner, checks account ownership, HMAC-signs state with account id, owner, and nonce, and redirects to Google with mail/userinfo scopes;
- `/api/email/oauth/google/callback` verifies signed state before token exchange, re-checks the target account owner before writing tokens, stores access/refresh tokens encrypted, stores token expiry as a timestamp, and redirects with generic success/error codes rather than raw provider errors;
- token refresh uses `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`, stores refreshed access tokens encrypted, and logs only generic/account-id context on failures;
- SMTP and IMAP use XOAUTH2 when `oauth_provider == "google"`; OAuth accounts are send-capable without an SMTP password when host and user are configured;
- outbound mail formats the `From` header with `display_name` when present.
- authorize/callback redirect URIs derive their scheme and host from the mounted request unless `GOOGLE_OAUTH_REDIRECT_URI` explicitly pins a value; the browser preserves the selected SMTP security mode during connect and reopens Settings after the callback.
MCP full-message read/reply/attachment fetches use IMAP `BODY.PEEK[]` rather than bare `RFC822`, so iCloud-style servers return the full body without marking messages seen. Poller UID handling must tolerate both bytes and string UIDs. Built-in signature-learning and daily-brief actions also use UID SEARCH/FETCH rather than sequence-number commands.
IMAP helpers quote mailbox names, raise the Python IMAP line cap for large messages, close sockets after connect/login failures, and preserve Gmail FETCH attributes that follow header literals so unread flag state is not lost. Browser list routes offload blocking IMAP work from async handlers; browser search runs in FastAPI's threadpool, rejects CRLF query input, tokenizes quoted phrases/terms, searches FROM/TO/CC/SUBJECT/TEXT, can search Gmail All Mail when an INBOX query should include archived or labelled messages, and supports `scope=folder` when callers intentionally want the selected folder only. The local index fallback can return indexed results when IMAP returns empty or fails.
## Runtime And Pollers
Scheduled email rows live in `data/scheduled_emails.db` and are owner-scoped. Scheduled send times are normalized before storage.
`routes.email_pollers` owns the scheduled-send poller and single-shot/task/CLI automation passes. Before SMTP work, each poller atomically claims a due row with a conditional `pending` to `sending` update; concurrent in-process/CLI pollers that lose the claim skip the row instead of sending a duplicate. Only the scheduled-send poller starts in-process by default when `ODYSSEUS_INPROCESS_POLLERS` allows it; Docker forwards that gate. Background email automation can also consult the foreground activity gate so auto actions do not compete with active browser/model work. Native cron/systemd can drive one-shot pollers through `scripts/odysseus-mail`.
Manual and scheduled summaries use the shared LLM adapter and owner-scoped cache instead of constructing provider calls locally. Scheduled summaries use background fallback policy and yield to foreground work; provider exception text is shaped before it can reach the browser.
Urgency delivery publishes through a serialized atomic checkpoint transaction. Generation and membership fences prevent stale scans from overwriting newer state; authoritative scans retire deleted/disabled accounts, partial failures preserve the prior checkpoint, concurrent account-scoped actions merge disjoint facts, and cancellation rolls back without publishing.
Transport degraded behavior:
- IMAP timeouts are clamped by configuration;
- providers can use implicit SSL, STARTTLS, or plain connections;
- poisoned IMAP sockets are reconnected around known provider failures;
- SMTP-capable account fallback is used where supported;
- route helpers, MCP, and CLI do not all share identical SMTP/IMAP parsing and security behavior today.
## Caching And Staleness
Email list/read behavior uses short route caches, longer read caches, capped warm prefetch, and owner/account-aware pool/cache keys. The frontend email library has its own session SWR cache, cache-buster refreshes, scheduled/search cache exclusions, and stale-row behavior when refresh fails.
Opening an unread message is one authoritative backend IMAP operation. The read route fetches/parses the message and applies `\Seen` over the same connection; cached bodies still await one UID STORE, read-only mailboxes serve content without claiming a mark, and STORE failure returns the body with explicit failure state rather than caching a false read. Inbox/library clients deduplicate opens, carry immutable mailbox context, and ignore late responses after account, folder, or message changes.
Library prewarm runs only while genuinely idle, as one bounded single-flight request for the default or last-used enabled account and initial page. Visible foreground work, panel lifecycle, account changes, or explicit reads cancel or join it so delayed duplicate IMAP work cannot escape the idle gate.
List/read route caches are owner/account-aware. Helper-side summary, AI-reply, tag, calendar-extraction, urgency-alert, and learned sender-signature tables carry owner columns and owner clauses. Thread-boundary rows are still keyed by message shape rather than a full owner/account/mailbox key, so they remain cross-owner audit points when identical messages appear in multiple mailboxes.
## Attachments And Signed Replies
Compose uploads live under `ODYSSEUS_MAIL_ATTACHMENTS_DIR`; missing staged files are skipped with warnings. Attachment-to-document supports PDF, DOCX, TXT, and MD. DOCX depends on `python-docx`; PDF form/open-in-doc flows can depend on optional PyMuPDF.
Email attachment-as-document flows stamp `Document.source_email_*` provenance. `GET /api/email/attachments-download/{uid}` builds an owner-scoped ZIP of visible non-signature attachments using safe names. `compose-from-odysseus` and `compose-from-odysseus-zip` can stage owner-visible documents and gallery images as compose uploads, preserving legacy session fallback only where the source object remains visible to the owner. `prepare-signed-reply` verifies document ownership, reconstructs reply headers, flattens/stages signed PDFs as compose uploads, and leaves final send/draft review to the compose flow.
Email bodies and attachments are untrusted model context.
## Threading And Rendering
`src.email_thread_parser` owns splitting plaintext/HTML email threads into quoted conversation parts. Frontend email library modules own reply-recipient logic, signature folding, local state, and rendering behavior. Bulk selections are cleared when folder/account loads, search text, search pills, or result scope changes so actions cannot carry stale UIDs into a different visible context. `static/js/emailShared.js` owns shared email UI helpers used across inbox/library surfaces.
Remote inbound email HTML is sanitized by frontend email-library utilities before `innerHTML` insertion. Server-side email routes sanitize composed/generated outbound HTML with an allowlist before draft/send, dropping scripts/styles and unsafe attributes. Both sides are part of the rendering invariant.
When the email reader is active, browser chat sends selected-message metadata. `src.tool_implementations` stores that request-local active email reference, `src.agent_loop` injects it as protected untrusted context, and `static/js/chatStream.js` handles `ui_control open_email_reply` so default reply/draft behavior opens the selected message's compose flow instead of a generic new document.
## MCP Email
`mcp_servers/email_server.py` exposes email tools for MCP/agent use. It has its own account discovery, IMAP/SMTP, attachment, cache, and send paths, but account visibility now mirrors the HTTP owner policy. The active owner comes from a hidden `_odysseus_owner` argument when the caller provides one, or from `ODYSSEUS_MCP_EMAIL_OWNER` / `ODYSSEUS_EMAIL_OWNER`. If any enabled account is owner-scoped and no current/configured owner exists, email MCP returns an owner-scope error instead of listing global accounts.
MCP email account filtering includes owner-owned rows and legacy ownerless rows
whose mailbox/from-address matches the owner. Confirmation-first `send_email`
resolves the selected account before stashing an `agent_draft`, so drafts cannot
be staged against another owner's account. MCP-created draft documents use the
resolved hidden/configured owner when available, with `ODYSSEUS_DOCUMENT_OWNER`
and single-admin fallback only as document-visibility compatibility.
MCP email send behavior is confirmation-first by default: `send_email` and reply send paths stash a `scheduled_emails` row with `status='agent_draft'` when `agent_email_confirm` is true, and browser routes expose pending drafts for approval or cancellation. Separate MCP draft tools create Odysseus compose documents for user review without sending.
MCP email remains a separate local/admin trust boundary. Public and non-admin users must not see or execute email MCP tools. It still needs route-helper parity audits for attachment path containment, sanitization, transport behavior, and pending-draft result text, but global all-account behavior is no longer the current owner model.
## Contacts
`routes.contacts.contacts_routes` owns global/admin contacts and CardDAV behavior. The top-level `routes.contacts_routes` module is a compatibility shim. The canonical package supports local contacts, CardDAV config, list/search/add/update/delete, VCF/CSV import/export, and clear.
Contact runtime behavior:
- contacts routes are admin-gated;
- local `data/contacts.json` is used when CardDAV is unconfigured;
- import paths tolerate malformed or non-string contact bodies by skipping invalid rows instead of crashing the import;
- configured CardDAV uses REPORT with GET fallback and a short in-memory cache;
- configured-but-offline CardDAV can return cached reads but writes fail instead of falling back to local JSON;
- CardDAV config reads mask the password, settings-stored passwords are encrypted with `src.secret_storage`, omitted password updates preserve the existing secret, and an explicit empty password clears it;
- the native contacts CLI is CardDAV-oriented and does not fully match web JSON fallback behavior;
- agent contact tools reuse helper functions in-process because the HTTP routes require browser/admin auth.
Contacts are global admin-only data today. There is no per-user contact sharing model unless a future spec defines one.
## Security Policy
Email HTTP access is owner-scoped, including account selection, scheduled email rows, and attachment routes. Null-owner/single-user compatibility paths are security-sensitive and must not allow cross-user mailbox access.
Codex email routes are the scoped bearer-token email API. They enforce `email:read`, `email:draft`, and `email:send` scopes and use token-owner attribution before borrowing email route handlers.
Known security policy details:
- decrypted email credentials stay process-local;
- account/config reads mask passwords and expose only OAuth status fields, not access or refresh token values;
- SMTP/IMAP security mode behavior is part of the credential contract;
- Google OAuth state and callback owner checks are part of the account-boundary contract;
- scheduled emails must remain owner-scoped;
- email pre-retrieval contacts context is allowed only for admin/single-user situations;
- MCP attachment downloads need route-level path-containment parity; current MCP paths are separate from the HTTP compose/attachment helper path.
CardDAV credentials and URLs are security-sensitive. CardDAV URL setup and derived href writes/deletes pass through outbound URL validation; absolute hrefs from a CardDAV server are constrained back to the configured origin before credentials are reused. CardDAV passwords in settings are encrypted and masked on read; environment-sourced legacy password values are used as supplied.
## Degraded Behavior
- IMAP/SMTP providers can be slow or inconsistent; folder resolution, pooled connections, and reconnect behavior should fail with clear errors.
- Google OAuth requires external Google endpoints plus configured `GOOGLE_OAUTH_CLIENT_ID`/`GOOGLE_OAUTH_CLIENT_SECRET`; missing client credentials or refresh failures degrade to reconnect-required or generic OAuth error paths.
- Scheduled email delivery depends on `scheduled_emails.db`, poller runtime, and configured SMTP.
- Attachment handling must tolerate missing staged files, unsupported formats, and inaccessible remote messages.
- CardDAV local fallback applies only when CardDAV is unconfigured; configured CardDAV outages are not treated as local-write mode.
- Multi-account list/search behavior can be sequential and cache-sensitive.
## Testing Coverage
Existing coverage includes header/envelope/IMAP/SMTP behavior, serialized default accounts, Google OAuth state/callback/token-refresh/XOAUTH2/redirect/settings behavior, shared-adapter summaries, authoritative read/mark-seen and frontend dedup, idle prewarm, UID-only mutations, scheduled-email claims and urgency checkpoint transactions, MCP full-message/owner behavior, owner scope/caches/signatures, thread/sanitizer behavior, CardDAV password encryption, mail CLI behavior, contacts basics, and selected frontend/security regressions.
Route-level and duplicate-path coverage is still thin for email list/read/search/mutations, account CRUD/security outside the OAuth path, send/draft security, attachments, scheduled-poller failures, contacts admin/CardDAV routes, MCP account/scope behavior, CardDAV degraded mode, and executable frontend behavior.
## Current Gaps
- Owner-keyed cache policy still needs an explicit decision for thread boundaries, plus continued migration/query audits for every email side table.
- CardDAV still needs redirect/proxy policy and broader route-level tests for URL validation, private-address blocking configuration, and same-origin href enforcement.
- MCP email needs continued route-helper parity for attachment path containment,
sanitization, transport behavior, and pending-draft result text.
- Empty-owner route compatibility and ownerless email cache rows need
end-to-end owner-boundary tests.
- CLI send/contact paths need parity decisions for SMTP security, recipient parsing, local fallback, and normalized contact shapes.
- Email HTTP route coverage is concentrated in scheduling/account-test helpers rather than full list/read/search/mutation/send/draft/account/attachment flows.
- Contacts coverage lacks admin-gate, config masking, import/export, CardDAV fallback, and CardDAV write-failure tests.
- Multi-account performance and cache staleness remain known audit areas.

158
specs/frontend.md Normal file
View file

@ -0,0 +1,158 @@
# Frontend
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers the current browser app in:
- static serving and SPA routes in `app.py`;
- CSP/security headers in `core/middleware.py`;
- `static/index.html`;
- `static/login.html`;
- `static/app.js`;
- `static/style.css`;
- `static/js/*.js` and `static/js/*/*.js`;
- vendor libraries under `static/lib/*`;
- custom fonts and static assets under `static/fonts/*`;
- `static/sw.js` and `static/manifest.json`;
- frontend-oriented tests in `tests/*_js.py`, `tests/*.mjs`, `tests/bombadil-spec.ts`, static DOM/CSS/source-shape tests, and app/static tests such as `tests/test_app_static_mime.py`.
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
`static/manifest.json` and `static/index.html` reference PWA icon files under `static/icons/`; the current 192px, 512px, and maskable icon files exist and should stay aligned with those references.
## Current Call Sites Include
- `static/index.html` script tags and modulepreloads;
- `static/sw.js` `PRECACHE`;
- app-owned SPA deep links for notes, calendar, cookbook, email, memory, gallery, tasks, and library;
- `/login` and app-owned static/HTML routes;
- `/api/activity/heartbeat` browser visibility pings used by the foreground activity gate;
- `static/app.js` route opener/sidebar/tool-window wiring;
- frontend JS helper tests and static HTML/CSS/source-shape regressions;
- CDN dependencies, local vendor libraries, service worker, and PWA manifest.
## Runtime Shape
The frontend is a raw static SPA served by FastAPI. There is no Vite, React, TypeScript, bundler, or generated build output.
`app.py` owns:
- stable `.js`/`.mjs` MIME registration;
- the `/static` mount;
- no-cache headers for `.js`, `.css`, and `.html` static source files;
- nonce-injected SPA/login HTML serving;
- SPA deep-link routes.
`static/index.html` owns the DOM shell and script loading order. It loads browser ES modules directly. Current boot order includes nonce-bearing inline boot scripts, self-hosted highlight.js, modulepreloads, ordered module script tags, `static/app.js`, `static/js/init.js`, `static/js/a11y.js`, workspace/chat helpers, provider device-flow helpers, and service-worker registration. KaTeX and Mermaid are vendored under `static/lib` and injected only on first real math/diagram use rather than loading in the initial HTML.
The two first-paint Fira Code faces are preloaded so the shell does not wait for later CSS discovery. `static/js/startupShell.js` lets the visible shell initialize before session loading completes; session/transcript hydration is deferred and coordinated by `static/js/sessions.js` plus history/session routes rather than blocking first paint.
Exact script URL identity matters. Versioned script tags, unversioned imports, and service-worker precache entries must stay aligned. `static/sw.js` deliberately separates first-paint `PRECACHE` from lazy `PANEL_PRECACHE`; the latter currently contains the image-editor module graph so an editor never opened online can still open offline. KaTeX scripts/styles/fonts are also precached. Current service-worker coverage is not a generated full module-graph manifest, so changes still need direct verification.
## Security Policy
`core/middleware.py` owns CSP and security headers. `app.py` injects the per-request nonce into served HTML. New inline scripts or external scripts/styles/images/media must fit the CSP contract or explicitly update it.
`/static/*` is public/auth-exempt. Frontend privilege gates are display-only; backend routes enforce authorization.
XSS/DOM policy:
- prefer DOM construction, `textContent`, and shared escaping helpers;
- Markdown raw HTML preservation must remain constrained through sanitizer helpers;
- remote email `body_html` must pass through the email-library sanitizer before insertion;
- Mermaid, code-runner iframe `srcdoc`, visual reports, remote media, and scattered `innerHTML` templates require explicit review.
- Visual report Markdown HTML is server-rendered and should be treated as security-sensitive alongside frontend entry points and remote media.
Storage/secrets policy:
- localStorage/sessionStorage are for preferences, UI state, offline caches, and user-switch sentinels;
- `static/js/init.js` owns user-switch storage cleanup;
- raw API tokens, provider keys, HF tokens, and other credentials must not be persisted in browser storage unless a feature documents masking/stripping and backend storage ownership.
## Service Worker And PWA
`static/sw.js` owns PWA cache behavior:
- API and non-GET requests are bypassed;
- root navigation uses stale-while-revalidate;
- JS/CSS use network-first behavior;
- other static assets use cache-first with background refresh;
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
`static/manifest.json` owns default PWA metadata. Route-specific manifests can be generated as Blob URLs when supported. Current default icon references must match real files under `static/icons/`.
KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained.
## Module Ownership
Current major frontend areas include:
- chat, stream handling, rendering, sessions, markdown, uploads, voice recorder, TTS, and keyboard shortcuts;
- models, provider setup, pure model-key matching helpers, model picker, presets, search, RAG, settings, and admin;
- settings shell modules under `static/js/settings/`: registry metadata, navigation, finder search, lifecycle/docking, DOM helpers, and persisted sidebar collapse/resize behavior;
- compare modules under `static/js/compare/`, including sanitized popup/search/image handling;
- document editor/library in `static/js/document.js` and `static/js/documentLibrary.js`;
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
Coordinator ownership:
- `static/app.js` owns late orchestration, global fetch 401 redirects, sidebar/tool route wiring, and many `window.*` compatibility bridges;
- `static/js/init.js` owns post-load cleanup, user-switch storage wipe, and cosmetic privilege gates;
- `static/js/storage.js` owns shared key constants and safe JSON helpers;
- feature modules own feature state where possible.
`static/js/appConfig.js` owns one invalidatable promise cache for `GET /api/auth/settings` and `GET /api/tools`, including one-shot login-page settings prefetch, retry after rejected fetches, and explicit invalidation after settings/tool writes. Consumers treat resolved objects as read-only. `static/js/panels.js` owns memoized first-use panel imports; its current registry contains the image editor, shares in-flight imports, and evicts failed imports so a later online retry can succeed.
`static/js/MODULE_SUMMARY.md` is a refreshed ownership/navigation map for the no-build frontend. The current `static/js/` tree, `static/app.js`, `static/index.html`, and executable behavior remain the authority when the summary drifts.
Current small frontend helper contracts include `static/js/model/matchKey.js` for longest-substring model info/pricing matches, `static/js/models.js` for in-flight `/api/models` request sharing, `static/js/providerDeviceFlow.js` for Copilot/ChatGPT Subscription device-flow polling UI, `static/js/composerArrowUpRecall.js` for prompt recall from an empty composer, `static/js/fileHandler.js` for capped pending-file state and collapsed attachment-chip display, `static/js/streamingSegmenter.js` for incremental markdown/code-fence segmentation, `static/js/emojiShortcodes.js` for shortcode replacement, `static/js/documentLibrary.js` for keeping document counters/language chips in sync after archive/delete, `static/js/keyboard-shortcuts.js` for rejecting empty or non-string persisted keybinds before combo parsing, `static/js/modalSnap.js` for reusable desktop modal edge docking, `static/js/toolWindowZOrder.js` for shared portal/window z-index allocation, and `static/js/emailShared.js` for common email UI helpers.
Recent browser behavior contracts include mobile chat Enter inserting newlines while desktop Enter submits; ArrowUp recall only consuming a truly empty composer with the caret at the top, not an unsent multiline prompt; queued prompts preserving mobile behavior; regenerate-from-here versus resend; AI-message delete confirmation; native document tool results opening/updating the editor; and exact tool-approval cards that expose the sealed action/effects/workspace/document identity and submit only opaque task-scope/chat-session-scope/deny decisions without writing synthetic composer text. Chat rendering hides leaked tool JSON/document fences, no longer strips the ordinary word “assistant,” and batches live-thinking DOM updates with bounded timers. Markdown editing/restoration preserves extracted code/math blocks verbatim, including replacement-string `$&` and `$$` text and triple-backtick fences. Session URL hashes are restored, minimized sidebar icon state follows per-tab visibility, detached terminal dots remain centered, and spinner animation starts only when attached.
The Settings finder and navigation are registry-backed, hide admin-only destinations from non-admin users, lazy-load admin panels, and keep the registry synchronized with DOM panels. Email OAuth connect preserves SMTP security and reopens the settings surface; unread message opens use one authoritative backend read/mark-seen request with stale-response guards; email-library prewarm is idle-only, single-flight, bounded to the initial page, and cancelled around visible foreground work.
## UI Policy
- New code must run as browser ES modules without a build step.
- Reuse existing CSS variables, modal/window patterns, icon style, storage helpers, and route conventions.
- Custom font handling includes bundled OpenDyslexic assets plus user-supplied fonts exposed through `/api/fonts/custom`; font and text-size settings must stay coordinated between settings UI, theme helpers, and CSS variables.
- Avoid relying on stale module summaries.
- API shape changes must update the owning JS module and tests.
- Add behavior to large coordinators such as `static/app.js`, `static/js/chat.js`, `static/js/document.js`, or `static/js/settings.js` only when it matches their existing wiring ownership.
## Degraded And Platform Behavior
- Server no-cache applies to `.js`, `.css`, and `.html` source files, not every static asset.
- Service-worker cache changes can affect frontend behavior even when source files revalidate.
- Mobile behavior uses separate CSS/media/hover/safe-area/`100dvh` handling and JS layout code; check it directly.
- Browser APIs such as service workers, Blob route manifests, Web Speech, `getUserMedia`, visual viewport, and storage can be absent or restricted.
- Local libraries and CDN globals degrade differently; document, markdown, math, diagrams, and code runner flows should handle missing globals where possible.
- localStorage migrations and cross-user cleanup are part of compatibility.
## Testing Coverage
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
Missing coverage includes:
- SPA route/static auth and no-cache headers;
- CSP header contents and nonce injection for `/` and `/login`;
- service-worker API/non-GET bypass and cache strategy;
- service-worker precache versus `index.html` script/module tags, including query strings;
- ongoing manifest/icon reference drift;
- module graph/load-order validation;
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
## Current Gaps
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
- There is no build-time type checking, module graph validation, script-order validation, or service-worker precache validation.
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
- `window.*` compatibility bridges remain widespread.
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
- A static asset/route manifest regression should verify files referenced by `index.html`, `manifest.json`, `sw.js`, and app-owned HTML routes actually exist.

View file

@ -0,0 +1,165 @@
# Gallery, Editor, And Media
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers media surfaces in:
- app route registration and generated-file serving in `app.py`;
- canonical models in `core/database.py`, with `src.database` as a compatibility import path;
- canonical route package `routes/gallery/gallery_routes.py` and `routes/gallery/gallery_helpers.py`, with top-level `routes/gallery_routes.py` and `routes/gallery_helpers.py` compatibility shims;
- generated-image writers in `src/ai_interaction.py` and `mcp_servers/image_gen_server.py`;
- local MLX image compatibility server `scripts/mlx_image_server.py`;
- image tool schemas/dispatch/implementations in `src/tool_schemas.py`, `src/tool_execution.py`, and `src/tool_implementations.py`;
- `routes/editor_draft_routes.py`;
- `routes/signature_routes.py` and document signature consumers in canonical `routes/document/document_routes.py`;
- `routes/emoji_routes.py`;
- `routes/font_routes.py`;
- `src/generated_images.py`;
- `src/visual_report.py` plus research image hide/unhide routes;
- database models `GalleryImage`, `GalleryAlbum`, `EditorDraft`, and `Signature`;
- generated files under `data/generated_images`;
- frontend modules `static/js/gallery.js`, `static/js/galleryEditor.js`, `static/js/editor/*`, `static/js/signature.js`, `static/js/emojiPicker.js`, `static/js/chatRenderer.js`, `static/js/document.js`, `static/js/markdown.js`, and `static/js/theme.js`;
- CLI surfaces `scripts/odysseus-gallery` and `scripts/odysseus-signature`;
- tests covering gallery helpers/routes, generated-image serving, editor drafts, signatures, visual reports, fonts, upload limits, and image endpoint security.
## Current Call Sites Include
- gallery upload, library, album, tag, favorite, ZIP, delete, and saved-project views;
- chat-generated image rendering/edit/delete bubbles;
- agent `generate_image` and stale `edit_image` tool paths;
- MCP image-generation rows/files;
- image editor AI tools and model endpoint pickers;
- document PDF signing with stored signatures;
- visual-report hero/section image insertion and research hide/unhide controls;
- emoji picker/markdown emoji SVG proxy calls;
- theme custom-font loading;
- local gallery/signature CLI inspection.
## Gallery
`routes.gallery.gallery_routes` owns gallery upload/import/library/editor transform behavior: upload dedupe, image/video extension handling, EXIF extraction for images, albums, favorites, tags, generated media metadata, search/filter/sort, owner filtering, ZIP downloads, soft delete, disk cleanup, and chat-history cleanup after image delete. Top-level `routes.gallery_routes` is a `sys.modules` compatibility shim to the canonical module.
Frontend gallery behavior includes upload progress, folder-drop album import, stale-while-revalidate cards, saved editor projects, detail actions, bulk delete/download, and cache-busted image refreshes.
Album assignment and gallery image detail/update endpoints enforce owner scope and fail closed when no authenticated owner is available instead of falling back to broad access.
Generated media provenance:
- generated filenames are opaque hex-like media names, not trusted content hashes;
- upload `file_hash` is a separate metadata field;
- generated files live under `data/generated_images`;
- chat image generation writes files and inserts `GalleryImage` rows through `src.ai_interaction`;
- MCP image generation can create ownerless rows/files;
- generated-but-not-yet-imported images can have no gallery row;
- once a gallery row exists, owner checks decide visibility where the route enforces them.
`app.py` owns direct `/api/generated-image/{filename}` serving through `src.generated_images.resolve_generated_image_path()`. It validates hex-like image/video filenames, rejects path escape and missing files, serves rowless generated files, checks row owner when a row exists, allows null-owner compatibility rows, and uses immutable/nosniff cache headers. Gallery replace/rotate/save/delete/ZIP paths also resolve filenames through a shared generated-image path helper so database filenames cannot escape `data/generated_images`. Replace/rotate/save-over-original flows can mutate bytes under the same filename, so frontend cache busting matters.
## Image Tools And Providers
Gallery/editor image transforms are split across:
- `/api/gallery/ai-upscale` and `/api/gallery/style-transfer`;
- `/api/image/inpaint`;
- `/api/image/harmonize`;
- `/api/image/sharpen`;
- `/api/image/denoise`;
- `/api/image/upscale-local`;
- `/api/image/remove-bg`;
- `/api/image/enhance-face`.
AI image endpoints mostly require image-generation privilege in the gallery route layer. The sharpen route is explicitly auth-gated; utility routes that live outside gallery still need their own route-level gate checks rather than assuming a shared decorator. The chat image-generation session path calls `do_generate_image()` separately and has its own privilege/tool-listing behavior.
Provider behavior:
- OpenAI image edits use multipart `/images/edits`, mask conversion, size coercion, model restrictions, and source compositing where needed;
- diffusion/self-hosted paths use JSON APIs such as inpaint, img2img, variations, harmonize, or A1111-compatible fallbacks;
- client-supplied endpoint URLs on selected routes must pass outbound endpoint validation; DB-selected image endpoints should be resolved through owner-visible endpoint queries before decrypted headers/keys are used;
- provider-returned image result URLs are validated with `src.url_safety.check_outbound_url()` before server-side download, with private-IP blocking controlled by image-route settings;
- AI endpoint path suffixes are allowlisted before proxy/download use so arbitrary endpoint paths cannot be selected through gallery/editor requests;
- editor model pickers load `/api/model-endpoints` and classify image-capable endpoints.
Optional dependency behavior:
- Pillow-backed paths are effectively core for EXIF, rotate, sharpen, and image preparation;
- Real-ESRGAN powers denoise/upscale when installed and otherwise returns install guidance; import-time torchvision compatibility patches run before Real-ESRGAN imports;
- remove-bg tries `rembg`, then transformers-style fallback, then an error;
- face enhancement falls back from GFPGAN/OpenCV toward PIL behavior;
- video uploads intentionally skip EXIF/ffprobe metadata today.
- grounding and mask model inputs cast only `float64` tensors to `float32` before transfer to Apple's MPS backend, because MPS rejects float64; integer/other tensors and non-tensor processor values preserve their normal device-transfer behavior.
## Editor Drafts
`routes.editor_draft_routes` owns server-backed image editor project payloads. `EditorDraft` rows store title, payload JSON, thumbnail, source image, timestamps, and owner.
Frontend editor behavior is split across `static/js/editor/*` and `static/js/galleryEditor.js`: canvas state, layer panel, masks, history, snapping, stroke pipeline, inpaint/rembg/harmonize tools, AI tool runner, model pickers, an AI edit command box that routes natural-language edit requests into existing inpaint/remove/upscale/background/style actions where possible, import wiring, topbar controls, auto-save, resume by draft ID or source image, draft-only open, and cleanup after close. `static/js/panels.js` loads this module graph on first editor use, shares concurrent imports, retries failed loads, and `static/sw.js` keeps the lazy graph in a separate offline panel precache.
Draft compatibility behavior:
- v2 server drafts store payloads and thumbnails server-side;
- legacy/local raw payloads can still be restored by the frontend;
- PUT 404 can recreate a missing draft row;
- broken image drafts can fall back to the source image;
- final close persist is best-effort.
## Signatures, Emoji, Fonts
`routes.signature_routes` owns reusable signature/stamp rows. Signature image payloads are normalized to bounded PNG base64, encrypted at rest, and owner-filtered; SVG signature input is not preserved. Document PDF render/export paths owner-filter signature IDs before stamping.
`routes.emoji_routes` owns same-origin OpenMoji black SVG proxy/caching. It validates codepoint filenames, caches SVGs under `data/emoji_cache`, and returns transparent no-store SVGs for invalid, unknown, or unreachable codepoints. `static/js/emojiPicker.js` is a curated inline monochrome picker.
`routes.font_routes` owns deriving available custom font family names from static font files under `static/fonts/custom`.
## Visual Reports
`src.visual_report` owns generated research/report HTML image behavior: HTTPS Open Graph image filtering, hero images, section images, icon/logo filtering, hide/reroll client controls, and inline JSON escaping for scripts.
Research routes and handler code own hidden-image persistence. Visual reports render model/source-influenced Markdown to HTML, so raw HTML/link/image sanitization remains security-sensitive.
## Security Policy
Media routes are cookie/current-user surfaces unless they explicitly implement token owner/scope handling. Bearer-token callers that arrive as synthetic `api` users should not be treated as owner-scoped media API clients without explicit policy.
Known boundaries:
- image-generation routes require `can_generate_images`;
- image proxy/editor endpoints currently resolve client-selected, DB-selected, or fallback image model endpoints without full owner-scoped endpoint-key policy or uniform outbound revalidation;
- generated-file serving allows rowless files and null-owner compatibility rows;
- uploads are byte-limited and extension-gated, with content sniffing available through `UploadHandler.detect_content_type()` when `python-magic`/`libmagic` is installed;
- several base64 JSON editor routes accept large decoded image payloads and need route-level size discipline;
- gallery DB filenames should be joined through shared generated-media path helpers before filesystem operations;
- editor draft source image IDs, payloads, and thumbnails are owner-scoped by draft owner but do not fully validate source-gallery ownership or payload size;
- emoji proxy constrains codepoint filenames and degrades invalid, unknown, or unreachable SVGs to transparent no-store placeholders, but remote SVG content still deserves security review;
- visual report Markdown HTML/link/image output needs continued sanitization coverage.
- `scripts/mlx_image_server.py` pins generation/edit routing to the process-start model and ignores request-selected model names, preventing unauthenticated callers from selecting a local model directory/repository whose model-specific script or bridge would execute.
## Degraded And Compatibility Behavior
- Uploaded images record display dimensions with EXIF orientation when possible; EXIF failures warn/degrade.
- Video uploads skip EXIF and have no metadata extraction yet.
- Missing generated files are skipped in ZIP downloads; if all are missing, the route returns no files found.
- Soft delete commits the gallery row state before removing the disk file, so a failed DB write does not orphan a missing image row.
- AI tagging can fail when disk files are missing.
- Static JS/CSS/HTML assets revalidate because there is no frontend build/versioning.
- Gallery/editor frontend state includes stale-while-revalidate and listener cleanup to avoid stale handlers.
- `edit_image` tool schema/implementation currently appears stale against implemented `/api/image/*` and `/api/gallery/*` routes.
## Testing Coverage
Existing tests cover EXIF dimensions, owner-filter helper behavior, direct upload limits, image-generation privilege source shape, sharpen auth, gallery null-user denial, endpoint SSRF/source checks, editor draft payload validation, lazy editor loading/offline precache, MLX request-model pinning, font family derivation, visual-report helper behavior, gallery CLI previews, and selected security regressions.
Route-level coverage is thin for full gallery CRUD/album/tag/download/delete flows, generated-image serving, editor draft owner CRUD, signature owner CRUD, emoji proxy/cache behavior, image-tool degraded responses, optional dependency fallbacks, and frontend editor behavior.
## Current Gaps
- Owner-scoped endpoint-key resolution is needed for image proxy/editor routes.
- Media routes need a clear API-token policy: reject token callers, or implement owner/scope handling.
- Generated-image serving needs live route tests for invalid filenames, rowless files, owned rows, null-owner rows, MIME/cache headers, and cross-owner behavior.
- Mutable generated filenames plus immutable cache headers need cache-busting tests for replace/save-over-original flows.
- Base64 JSON editor payload size limits need hardening; upload content sniffing should keep native/Docker parity coverage as dependencies change.
- MCP image generation needs an owner attribution decision or explicit admin-only documentation.
- `edit_image` tool route mapping appears stale.
- Emoji SVG proxy/cache and visual-report raw HTML/link sanitization need stronger tests.
- Optional image dependency fallbacks are mostly untested.

197
specs/integrations.md Normal file
View file

@ -0,0 +1,197 @@
# Integrations
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers external integration surfaces in:
- `routes/codex_routes.py`;
- `integrations/codex/*` and `integrations/claude/*`;
- `routes/api_token_routes.py` and bearer-token handling in `app.py`;
- `routes/auth_routes.py` integration CRUD/test routes;
- `src/integrations.py` and `data/integrations.json`;
- canonical `routes/webhook/webhook_routes.py` plus its top-level compatibility shim, and `src/webhook_manager.py`;
- task webhook generation/triggering in canonical `routes/task/task_routes.py`, its top-level compatibility shim, `app.py`, `static/js/tasks.js`, and `scripts/odysseus-webhook`;
- companion/mobile pairing in `companion/routes.py` and `companion/pairing.py`;
- provider OAuth/device-flow endpoint links in `routes/copilot_routes.py`, `routes/chatgpt_subscription_routes.py`, `routes/device_flow.py`, and `ProviderAuthSession` rows;
- integration UI surfaces in `static/js/settings.js` and `static/js/admin.js`;
- database models `ApiToken` and `Webhook`.
The SQLAlchemy `Integration` model exists in `core/database.py`, but current Settings generic integration CRUD uses `src/integrations.py` and `data/integrations.json`.
## Scoped Agent Runtime
`/api/codex/*` is the canonical scoped HTTP surface for external coding agents. Claude Code uses the same runtime endpoints; `/api/claude/plugin.zip` only delivers the Claude skill bundle.
`routes.codex_routes` owns:
- `/api/codex/capabilities`;
- todos list/manage through `do_manage_notes()`;
- email list/read/draft/send;
- memory list/add/delete;
- calendar list/create/delete;
- document list/read/create/delete;
- Cookbook task/server/output/cached-model/preset/serve/adopt/stop controls.
`_scope_owner()` owns scope checks and token-owner resolution. `_as_owner()` temporarily runs borrowed route handlers as the scoped owner and restores request state afterward. Borrowed email, memory, calendar, and document route handlers own their domain behavior; Codex routes only adapt them behind scoped access.
Runtime behavior:
- missing scopes return 403;
- invalid payloads return 400;
- unavailable borrowed route surfaces return 503;
- capabilities expose scope-derived booleans and partial availability flags;
- email send and destructive actions remain described as confirmation-required behavior in bundled agent instructions.
- Cookbook adopt/stop paths validate stored remote SSH host and port before interpolating them into SSH commands.
The local integration skill/helper files require `ODYSSEUS_URL` and `ODYSSEUS_API_TOKEN`. They must use `/api/codex/*` and must not bypass Settings/token scopes through SSH, Docker, direct DB access, local files, MCP internals, or app imports. Helper scripts refuse non-`/api/codex/*` paths.
## Bundle Distribution
`/api/codex/plugin.zip` ships the Codex plugin tree from `integrations/codex/`. `/api/claude/plugin.zip` ships only the Claude `skills/` subtree from `integrations/claude/skills/`. These routes require an authenticated browser/user request and do not embed an API token.
Setup instructions are duplicated in integration READMEs and `static/js/settings.js`; they need to stay aligned with live route surfaces and `/api/codex/capabilities`.
## API Tokens
`routes.api_token_routes` owns token profiles, allowed scopes, scope normalization, token creation/update/revocation, and profile metadata shown in Settings. Partial updates preserve existing scopes unless new scopes are supplied, owner checks apply to update/delete, and write scopes auto-include their read scope where applicable.
`app.py` owns bearer-token validation. It accepts `Bearer ody_...`, checks a bcrypt hash through a prefix cache, updates `last_used_at` asynchronously, and stamps:
- `request.state.current_user = "api"`;
- `request.state.api_token = True`;
- `request.state.api_token_owner`;
- `request.state.api_token_scopes`.
The raw token is returned only on creation. Stored state is hash, prefix, owner, scopes, active flag, and timestamps. Token create/update/delete invalidates the auth middleware cache. Companion pairing also mints chat-scoped `ApiToken` rows and invalidates that cache.
Current API-token consumers include:
- `/api/codex/*` scoped agent routes;
- `/api/v1/chat` synchronous external chat;
- `/api/models` catalog reads for `chat`-scoped token owners;
- companion read endpoints;
- selected session and owner-attribution helpers described in `auth-security.md`.
The Cookbook scoped-agent surface currently exposes `cookbook:read` and `cookbook:launch` in Settings and checks them in Codex routes; those scope names must stay reconciled with `routes.api_token_routes.ALLOWED_SCOPES`.
## Generic API Integrations
`src.integrations` owns generic API integration presets, `data/integrations.json`, API-key encryption/decryption, secret masking, plaintext-key migration, enabled integration prompt text, and `execute_api_call()`.
`routes.auth_routes` owns admin-only HTTP CRUD/test routes for these integrations. Presets are public metadata. The ntfy test route is special: it publishes a real test notification to the configured reminder topic instead of only probing server health.
`api_call` is the agent/tool execution path for configured integrations. It is blocked for non-admin/public users by tool security, accepts only relative paths, uses the admin-configured base URL/auth settings, and returns truncated external responses to the model, including a sentinel when long JSON lists are shortened. Admin-authored integration descriptions are prompt context; external responses remain untrusted data.
`execute_api_call()` normalizes base URLs to HTTP(S) scheme, hostname, and
path-only values, rejects request paths that are not relative absolute paths
(`/...`) or that carry schemes/fragments, treats `/` as the base URL without
appending an extra slash, and checks the final URL through `src.url_safety`.
Link-local/metadata targets are always rejected; setting
`INTEGRATION_API_BLOCK_PRIVATE_IPS=true` also rejects loopback/RFC1918/private
addresses for operators who do not need LAN integrations.
After validation, `execute_api_call()` pins the outbound connection to the validated IP snapshot while preserving the configured URL, Host header, TLS server name, and redirect policy. DNS cannot select a different destination between SSRF validation and transport.
Current call sites include:
- `src.agent_loop` injecting enabled integration descriptions;
- `src.tool_implementations.do_api_call()`;
- task scheduler discovery/check-ins;
- note reminder delivery through ntfy integrations and the generic webhook reminder channel.
## Webhooks And External Chat
Outgoing webhooks are admin-managed `Webhook` rows. `routes.webhook_routes` owns CRUD/test/toggle/delete and `/api/v1/chat`. `src.webhook_manager` owns allowed event validation, public URL validation, delivery-time URL revalidation, DNS-rebinding-safe pinned-IP delivery, HMAC signing, fire-and-forget delivery, in-flight task references, and delivery status/error persistence. Sanitized delivery errors redact IPv6-style address details.
Allowed outgoing events are:
- `session.created`;
- `chat.message`;
- `chat.completed`;
- `webhook.test`.
Current webhook event emitters include session creation, chat message/completion paths, and `/api/v1/chat` completion.
`/api/v1/chat` is an inbound external chat endpoint. It requires a `chat` API token, checks session ownership before resume, can create a session from a direct API key, and otherwise falls back to the first owner-visible enabled model endpoint. Token-supplied direct `base_url` values use public-URL validation; configured endpoints remain admin-trusted. Logs and delivery/error text that include endpoint URLs should pass through URL redaction helpers before persistence or diagnostics.
## Task Webhooks And Event Triggers
Task webhook triggers are separate inbound webhooks. `app.py` exempts only `/api/tasks/{task_id}/webhook/{token}` from normal auth so external callers can trigger tasks without cookies. `routes.task.task_routes` owns token generation/regeneration and validates task id, token, and active status before queueing a run; the top-level route module is a compatibility alias.
`static/js/tasks.js` displays the live task webhook URL. `scripts/odysseus-webhook url` now emits the same route with percent-encoded task/token path segments; the CLI still reads and mutates task rows directly for list/show/rotate/revoke rather than delegating to HTTP route policy.
Event-triggered tasks use `src.event_bus`; task execution and scheduling ownership lives in `calendar-tasks-notes.md`.
## Companion Pairing
`companion.routes` owns companion/mobile HTTP routes:
- `/api/companion/ping`;
- `/api/companion/info`;
- `/api/companion/models`;
- `/api/companion/pair`.
Read endpoints accept session or bearer-token callers and resolve the effective owner for visible rows. Model responses omit API keys. Pairing `GET` renders the admin form; pairing `POST` is admin-cookie only, mints a normal chat-scoped API token, invalidates the auth token cache, and returns a host/port/token payload as HTML or JSON.
`companion.pairing` owns LAN host detection, pairing payload shape, token minting, and optional QR generation. QR rendering depends on optional `qrcode`; if unavailable or failing, pairing still returns the text payload.
When `COMPANION_BASE_URL` is set, pairing advertises that validated operator-selected v1 address instead of container/request auto-detection. The accepted form is a canonical ASCII `http://` LAN/Tailscale IPv4, single-label hostname, or `*.local` origin with optional valid port and no credentials/path/query/fragment; HTTPS, public/misleading numeric host spellings, percent/backslash/control characters, and unsupported hosts fail closed. Auth-disabled model inventory retains the normal single-user all-endpoints view instead of filtering every ownerless request to legacy-null rows.
## Unified Settings Surface
The Settings Integrations view aggregates several subsystem surfaces:
- generic API integrations;
- Codex/Claude agent token setup;
- CalDAV, CardDAV, email accounts including Google Workspace/.edu OAuth connect flows, MCP/OAuth links, provider device-flow links, and agent tokens.
- provider-auth backed model endpoints such as ChatGPT Subscription and Copilot, where device-flow credentials live in provider auth rows rather than endpoint API-key fields.
Vault and companion/mobile setup are separate settings/route surfaces today, not entries in the unified add-integration list.
This spec owns the cross-integration framing and agent/token/webhook surfaces. Domain internals stay with their subsystem specs: calendar, email/contacts, shell-MCP, vault/auth, and settings-admin.
## Degraded And Compatibility Behavior
- 403 from scoped APIs means a settings/scope restriction.
- 503 from Codex borrowed routes means the domain route surface is unavailable.
- Missing or corrupt `data/integrations.json` loads as an empty list; non-object rows are ignored.
- Plaintext generic integration API keys migrate to encrypted storage on load.
- Webhook delivery has no retry/backoff queue; the persisted state is last status or sanitized last error.
- Webhook URLs are validated at create and delivery time, redirects are disabled,
and delivery connects to the IP set validated immediately before the request.
- Companion LAN detection is best-effort and falls back to local host/port defaults unless a valid `COMPANION_BASE_URL` is configured.
- `ODYSSEUS_URL` must be reachable from the external coding agent; no Docker/native URL rewrite is performed.
## Security And Provenance
- API-token routes must either enforce a relevant scope or document an explicit exception.
- Codex/Claude plugin zips must not expose secrets beyond source instructions and helper files.
- Webhook list responses expose `has_secret`, not the secret value.
- Webhook secrets are encrypted when an API key manager is available; plaintext fallback is legacy/degraded behavior.
- Outgoing webhook signatures use `X-Odysseus-Signature`.
- Generic integration API keys are encrypted at rest and masked in API responses.
- Generic integration base URLs are admin-configured and not the same public-only policy as webhook URLs.
- `api_call` output and remote integration responses are untrusted model context.
- Pairing payloads expose the raw chat token once through HTML/JSON/QR; persisted token storage is hash/prefix only.
## Testing Notes
Current targeted coverage includes API-token CRUD basics, chat-scoped `/api/models` token access, companion pairing/read-only owner scoping, webhook SSRF validation, webhook auth-exempt source checks, webhook CLI token masking, integration-store shape/encryption migration, Google email OAuth route/helper behavior, Cookbook API-token scopes, Cookbook adopt SSH host validation, and `/api/v1/chat` base-url/fallback owner scoping.
The integration audit also ran the targeted venv subset covering those areas with 52 passing tests and one warning.
## Current Gaps
- Codex/Claude scoped routes, owner restoration, degraded 503 behavior, plugin zip contents, and helper-script path refusal need focused regression tests.
- Token profile/update behavior and Settings agent-token scope toggles need direct coverage.
- Codex Cookbook scopes need continued Settings, route-check, and `ALLOWED_SCOPES` regression coverage.
- Generic integration HTTP CRUD/test routes, `execute_api_call()` auth modes, response shaping, and frontend Settings/Admin flows need direct coverage.
- `do_manage_tokens()` does not match `/api/tokens` semantics for `ody_` prefix, owner, scopes, and cache invalidation.
- `do_manage_webhooks()` bypasses route behavior and does not cover signing-secret parity.
- Companion read endpoints should either require `chat` scope or be documented as an explicit scope-policy exception.
- Decide whether webhook secret plaintext fallback should remain accepted when the API key manager is unavailable.
- Decide whether generic integration base URLs should stay LAN-capable by default or make `INTEGRATION_API_BLOCK_PRIVATE_IPS=true` the default.
- Admin-authored integration descriptions and `api_call` results enter the untrusted-result/gated-action pipeline, but their product-level trust presentation still needs continued review.
- The dormant SQLAlchemy `Integration` model should be removed, migrated into use, or documented as legacy.

153
specs/llm-models.md Normal file
View file

@ -0,0 +1,153 @@
# LLM Models And Endpoints
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers model/provider behavior in:
- `src/llm_core.py`;
- `src/endpoint_resolver.py`;
- `src/foreground_model_routing.py`;
- `src/model_discovery.py`;
- `src/model_context.py`;
- `src/model_capabilities.py`;
- `src/model_capability_readers/`;
- `src/task_endpoint.py`;
- `src/tls_overrides.py`;
- `src/copilot.py`;
- `routes/copilot_routes.py`;
- `routes/chatgpt_subscription_routes.py` and `routes/device_flow.py`;
- `routes/model_routes.py`;
- `routes/session_routes.py`;
- `routes/cookbook_routes.py`, `routes/hwfit_routes.py`, and `services/hwfit/`;
- `src/settings.py`;
- `core/database.py` model `ModelEndpoint`;
- frontend modules `static/js/models.js`, `static/js/modelPicker.js`, `static/js/model/matchKey.js`, `static/js/providers.js`, `static/js/settings.js`, `static/js/admin.js`, `static/js/compare/`, and Cookbook model-serving modules;
- chat, compare, research, STT/TTS, and utility-model call sites.
## Provider Calls
`src.llm_core` owns provider-call mechanics. It handles OpenAI-compatible calls, Ollama normalization, Anthropic payload conversion, GitHub Copilot and ChatGPT Subscription provider detection/header injection, NVIDIA provider routing, streaming, fallback calls, upstream error formatting, async/streaming host liveness caching, configured model-list cache reads, tool-call sanitization, reasoning/thinking stream routing, and provider-specific parameter rules. GitHub Copilot OAuth/device-flow orchestration lives in `routes/copilot_routes.py` and `src/copilot.py`; ChatGPT Subscription device flow uses `routes/chatgpt_subscription_routes.py`, shared device-flow helpers, and `ProviderAuthSession` rows.
`llm_core` owns payload shape. Route files and chat/agent code should request a call; they should not duplicate provider-specific payload quirks.
Kimi Code User-Agent discovery has both sync and async implementations. Async
post and stream paths probe `/models` through their existing async client and
await each candidate, so header negotiation does not block the event loop; both
paths share the accepted-value cache and 403 fallback policy.
Provider-specific behavior is part of this layer: `LLM_CONNECT_TIMEOUT` controls the connect budget for sync and streaming calls, Kimi Code endpoints retry a small whitelisted User-Agent set on 403 and cache the accepted value, official Moonshot/Kimi Code and Anthropic Opus 4.7+ payloads omit sampling controls where required, and major-only Opus IDs such as `claude-opus-5` also omit temperature instead of falling through numeric minor-version parsing. Reasoning models omit or clamp unsupported temperature values, while self-hosted compatible endpoints keep normal parameters unless detected otherwise. Mistral structured content is normalized in async utility calls as well as stream/chat paths, and Mistral/Moonshot/Kimi reasoning content, `gpt-oss` harmony output, DeepSeek V4 thinking identifiers, and native/OpenAI-compatible Ollama thinking formats keep hidden reasoning separate from visible text. Tool names that collide with GPT-OSS built-ins are aliased on the provider boundary and mapped back before execution. Copilot request metadata remains defensive against malformed `request_flags`.
## Canonical Provider And Model Shape
`src.model_capabilities` owns canonical model family, task, modality,
capability, limit, evidence, assertion, deterministic-control, probe-result,
reasoning-control token, and display-query values.
`src.model_capability_readers` owns endpoint-scoped stable identity, lightweight
provider detection, record serialization, and normalization of already-fetched
provider payloads. Readers do no network I/O. Model-specific observations are
kept in `model-quirks.md`, not a runtime registry without a consumer.
Provider support and model support are different facts. A provider may expose
tools, reasoning, vision, or multiple APIs while individual models differ.
Provider-native readers describe where model evidence can appear. Current
concrete readers cover generic OpenAI-compatible identity, OpenAI, OpenRouter,
Google, Ollama, LM Studio, and llama.cpp. Identity-only model lists remain
unknown.
Reader dispatch uses an explicit vendor first, then endpoint kind, label-bounded hostname suffix, and common local-port hints. Generic payload handling accepts `data[]`
or `models[]` items with `id`, `name`, or `model`; it does not accept a bare
list and never promotes capability-looking fields. Unknown fields remain in
the in-memory raw record. See [model-capability-canonical.md](model-capability-canonical.md),
[model-quirks.md](model-quirks.md), and the
[provider map](model-providers/_readme.md).
This canonical layer is currently exercised by focused unit tests but is not
wired into runtime discovery, endpoint resolution, model context, request
shaping, or frontend pickers. `routes/model_routes.py` model probes continue to
return model IDs through their existing runtime path.
Route-level probe helpers in `routes/model_routes.py` are the current exception: they build minimal provider-specific probe payloads using `llm_core` detection helpers. Keep probe behavior aligned with `llm_core` provider adapters. LLM provider HTTP clients and endpoint probes share `src.tls_overrides.llm_verify()`, which can add an operator-provided `LLM_CA_BUNDLE` on top of normal certificate verification without turning verification off or widening that trust to arbitrary URL fetches.
## Endpoint Resolution
`src.endpoint_resolver` owns endpoint normalization and URL construction:
- base URL normalization;
- chat and model-list URL construction;
- endpoint ID resolution;
- chat, utility, and vision fallback candidate selection;
- Tailscale hostname resolution where available.
OpenAI-compatible model-list URL construction preserves `/v1` bases and inserts `/v1/models` for bare local bases such as LM Studio `http://localhost:1234`.
`routes/model_routes.py` owns model endpoint CRUD, admin provider discovery/probing, visible/hidden/pinned model lists, endpoint kind and refresh policy, curated/extra model partitioning, `/api/models` catalog caching, Docker loopback rewriting, tool-support probing, provider-auth linkage, endpoint-dependent settings cleanup, and owner filtering. Endpoint dedupe allows the same base URL under different API keys and surfaces API-key fingerprints/key presence without returning secrets.
`routes/session_routes.py` owns binding sessions to endpoint IDs, owner-scoped header construction, raw-endpoint rejection for non-admin users, model validation, and persisted session headers. Compare panes and normal chat session creation use this path.
`ModelEndpoint` rows own API keys, base URLs, cached/hidden/pinned models, model type, endpoint kind, refresh mode/interval/timeout, supports-tools state, nullable owner, optional provider-auth linkage, and provider metadata. `owner = NULL` means legacy/shared; non-null rows are private to that owner, while admins can see all. Secret fields must remain encrypted and scrubbed in responses.
Decrypted endpoint headers can be copied into session metadata for chat use. Endpoint deletion must clear dependent settings and copied session headers.
## Model Discovery And Lists
`src.model_discovery` owns host/env/Tailscale/local-port scanning for model servers. Admin `/api/providers` and `/api/discover` use that scanner; endpoint CRUD, test, refresh, and hidden-model controls are frontend-owned by `static/js/admin.js`.
`/api/models` is the normal picker/catalog surface. It is auth/owner scoped, per-user/admin-flag cached briefly, can trigger background refresh, preserves offline endpoint rows, filters hidden models, and preserves pinned model IDs for UI selection. API-token callers must carry `chat` scope and a token owner before they can list models. API/proxy endpoint inventory is visible by default until an explicit `pinned_models` allow-list is saved; an explicit empty list means show none, and legacy hidden-list state is upgraded to the equivalent pins so endpoint settings, picker checkboxes, and chat agree. Proxy/API endpoints can be marked cached-first/manual so large upstream catalogs are not repeatedly probed, while explicit refresh paths use longer manual timeouts. Local endpoints get cheap reachability probes before expensive refreshes where possible, and endpoint responses can include explicit `supports_tools` state for schema-emission heuristics. Google Gemini API endpoints use the native paginated `generativelanguage.googleapis.com/v1beta/models` catalog, send API keys in `x-goog-api-key`, retain only content-generation model IDs, and default to manual refresh unless the caller explicitly chooses another mode. Probe failure returns no curated Google fallback. `static/js/models.js` and `static/js/modelPicker.js` own the sidebar/picker catalog; `static/js/model/matchKey.js` owns longest-substring model-info/pricing key matching; `static/js/settings.js` owns default, utility, vision, image, TTS, STT, and fallback selectors.
`src.task_endpoint` owns background-task endpoint/model resolution for task routes and scheduler callers. It resolves `task_endpoint_id`/`task_model` through the normal endpoint resolver with owner context.
Cookbook and HWFit own local model download, serve, ranking, and auto-registration flows. They can create LLM or image `ModelEndpoint` rows, but provider dispatch remains owned by `llm_core`/endpoint resolution.
## Context Length
`src.model_context` owns model context-length lookup/query and token estimation. Cache keys include endpoint plus model so identical model names on different endpoints do not bleed context-window data. Unknown proxy/API models can pick up real context windows from endpoint catalog metadata such as `context_length`; otherwise unknown lengths stay explicit unknowns rather than default values. Known lengths feed chat/agent token-budget scaling through `src.context_budget`. Token estimation counts assistant `tool_calls` arguments so compaction sees tool-only turns instead of underestimating them. Chat/agent context budgeting should call this layer instead of hardcoding model windows.
## Runtime Fallback And Routing
`src.foreground_model_routing` owns foreground Chat/Agent fallback policy. Selected models are strict by default. Fallback requires owner-scoped `foreground_fallback_enabled=true` and an ordered `foreground_model_fallbacks` list; the old `default_model_fallbacks` setting is retired, ignored, and not migrated into consent. Named users never inherit a legacy flat/single-user fallback choice, candidate lists are capped at ten exact owner-visible models, and caller-provided allowed-model restrictions remain authoritative.
Only eligible availability failures before substantive output can fall through. Default eligible statuses are 408, 425, 429, 500, 502, 503, 504, 507, 508, and 529. Missing endpoint/configuration, provider/schema/request errors, empty completions, and post-content failures do not silently change routes. A candidate commits after non-empty visible/reasoning text or a tool call; the answering route is then pinned. Foreground routing carries model and endpoint descriptors together, shapes context/compaction route-neutrally across candidates, persists only answering-route compaction, and records requested/actual/per-round route provenance plus cost attribution. Utility/background and vision fallbacks remain separate policies.
Model selection has three layers: endpoint resolver hidden-model and first-chat-model selection, `/api/default-chat` per-user default/fallback resolution, and frontend picker auto-selection for empty sessions.
Image routing uses model-name prefixes and `ModelEndpoint.model_type == "image"` to bypass text chat and generate media. Vision analysis uses configured vision models and `vision_model_fallbacks`; image and vision endpoint lifecycle changes should update chat, document processing, Cookbook, and settings UI together.
Provider tool calls are untrusted requests, not authorization. `supports_tools` controls schema emission only; `llm_core` normalizes provider tool-call payloads, while execution authority remains in `src.tool_execution`, `src.tool_security`, and agent-tool policy.
## Degraded And Platform Behavior
- Provider offline or probe failures should surface actionable errors without crashing the app. Async calls retry transient 429/502/503/504 responses before failing.
- Docker deployments may need loopback URL rewriting from `127.0.0.1` to host-accessible addresses.
- Foreground fallback selection must preserve endpoint identity, explicit owner consent, allowed-model policy, and owner scope. User/API-token LLM dispatch that can carry configured endpoint keys must pass the effective owner into resolver calls.
- Async and streaming calls use dead-host cooldown; sync utility/vision calls do not have identical cooldown coverage.
- llama.cpp slot-affinity routing is local-endpoint behavior only and must not be applied to cloud/provider endpoints.
- Hidden, pinned, cached, endpoint-kind, refresh-policy, and offline model state are UI/runtime compatibility data. Pinned models may not participate in every resolver auto-pick path unless code explicitly includes them.
- SSE/stream parsers tolerate null choice/usage/tool-call entries and null streaming tool-call arguments; provider events should degrade to empty text or shaped stream errors instead of crashing the chat loop.
- Provider adapters carry small model-specific quirks: Opus 4.7+ and official Kimi/Moonshot code payloads omit `temperature`, Kimi/Moonshot/Mistral reasoning content is preserved separately, ChatGPT Subscription refreshes bearer credentials, native Ollama can handle multimodal content, and Ollama `/v1` responses for Qwen3/Gemma4-style thinking can suppress thinking text when requested.
## Security Policy
- Endpoint API keys are encrypted in `ModelEndpoint.api_key` and never returned by endpoint APIs; admin surfaces return key presence only.
- Endpoint CRUD, probes, provider discovery, and most endpoint configuration are admin-cookie or internal-tool gated.
- `/api/models` is auth/owner scoped for configured deployments; API-token access requires `chat` scope and token-owner attribution.
- Admin-created model endpoints may target local/LAN servers. Non-admin chat session creation must use registered endpoint IDs. API-token `/api/v1/chat` requires `chat` scope and validates direct `base_url` with public-only URL checks.
## Current Call Sites Include
- chat streaming and non-streaming calls;
- agent loop calls with optional tool schemas;
- compare pane calls;
- research synthesis/probe calls;
- utility model fallbacks for summarization/extraction;
- frontend Settings and model picker endpoint management.
## Current Gaps
- Runtime provider detection, model curation, and frontend logos are still split across `llm_core`, `model_routes`, and `providers.js`; the canonical reader package has no production consumer yet.
- Provider-specific behavior is concentrated in `llm_core.py`, which is large and easy to regress.
- Several runtime request builders still use model-name heuristics. They should migrate only after endpoint/provider code supplies structured identity and a real consumer contract; the canonical catalog does not add a parallel quirk matcher.
- Endpoint identity and fallback behavior need careful review when new OAuth/subscription providers are added.
- Owner must continue to be threaded through new utility/research/default endpoint-resolution call sites so provider keys stay isolated.
- `/api/models` owner-scoped listing/cache behavior, shared/private endpoint dedupe, endpoint-kind refresh policy, fallback-chain owner scope, and image endpoint create/list/update lifecycle need stronger route-level regression coverage.

118
specs/memory-skills.md Normal file
View file

@ -0,0 +1,118 @@
# Memory And Skills
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
This spec covers persistent memory and user skills in:
- app wiring in `app.py` and `src/app_initializer.py`;
- active legacy memory managers `src/memory.py` and `src/memory_vector.py`;
- canonical memory routes in `routes/memory/memory_routes.py`, with `routes/memory_routes.py` as a compatibility shim;
- chat memory/skill gating in `routes/chat_helpers.py`;
- memory compatibility modules in `services/memory/memory.py`, `services/memory/memory_vector.py`, and `services/memory/service.py`;
- provider abstractions in `src/memory_provider.py`;
- LLM extraction/audit in `services/memory/memory_extractor.py`;
- skill storage, format, import, and extraction in `services/memory/skills.py`, `services/memory/skill_format.py`, `services/memory/skill_importer.py`, and `services/memory/skill_extractor.py`;
- skill routes in `routes/skills_routes.py`;
- prompt/tool call sites in `src/chat_processor.py`, `src/agent_loop.py`, `src/ai_interaction.py`, `src/tool_implementations.py`, `src/tool_execution.py`, `src/tool_schemas.py`, and `src/tool_security.py`;
- MCP and Codex surfaces in `mcp_servers/memory_server.py` and `routes/codex_routes.py`;
- backup/admin/CLI surfaces in `routes/backup_routes.py`, canonical `routes/admin_wipe/admin_wipe_routes.py` plus its shim, `scripts/odysseus-memory`, `scripts/odysseus-skills`, and `scripts/odysseus-backup`;
- frontend modules `static/js/memory.js` and `static/js/skills.js`;
- tests under `tests/test_memory_*`, `tests/test_builtin_memory_consolidation.py`, `tests/test_skill_*`, and `tests/test_skills_*`.
## Memory Runtime
`src.app_initializer.initialize_managers()` creates the active `src.memory.MemoryManager` and `src.memory_vector.MemoryVectorStore` used by app startup. `routes.memory.memory_routes` imports through `services.memory` but is passed the startup manager instances; top-level `routes.memory_routes` is a `sys.modules` compatibility shim.
`MemoryManager` owns JSON-backed memory storage in `data/memory.json`, validation, owner fields, pinned state, use counts, and text/keyword similarity. Read-only `load_all()` remains lenient and can degrade an unreadable store to no memories. Mutating read-modify-write paths use `load_all_for_update()`, which raises `MemoryStoreUnreadable` rather than letting a corrupt or unreadable file be overwritten with an empty list. Agent/MCP/native-provider adds, extraction, backup import, and owner migration preserve that distinction; legacy `memory.txt` migration remains allowed. `MemoryVectorStore` owns semantic lookup when Chroma and embeddings are reachable.
Chat memory behavior:
- chat preferences and incognito state gate memory preface use;
- pinned memories are loaded for the owner;
- retrieved memories use keyword matching plus optional vector scoring;
- inserted memory is wrapped as untrusted context;
- memory use counts are incremented after insertion.
`services/memory/memory_extractor.py` owns LLM-assisted extraction, audit, and validation flows. It requests model behavior and writes through the memory manager; it does not own chat session persistence.
Extraction handles reasoning-model response shapes and records explicit dislike/drop preferences as `dislikes` rather than losing them to generic fact handling.
## Skills Runtime
`services/memory/skills.py` owns disk-backed skill storage under `data/skills/<category>/<name>/SKILL.md`, plus `_usage.json` usage/audit sidecars. Legacy `data/skills.json` is a read-only fallback/import source, not the current write shape.
`services/memory/skill_format.py` owns frontmatter/body parsing and emission. Quoted scalar parsing/emission is symmetric: JSON escapes decode once, UTF-8/non-ASCII stays intact, emitted values escape line separators safely, and invalid JSON-style escapes fall back to literal text instead of compounding backslashes on every save. `services/memory/skill_importer.py` resolves public GitHub/skills URLs, fetches bundle files with strict public-network URL safety, and chooses/imports `SKILL.md`. Import disables automatic redirects, follows at most five hops, validates and resolves each hop, then connects only to the validated IP snapshot through a pinned transport while preserving URL, Host, and TLS identity; GitHub final-host checks and file/size limits still apply. `routes/skills_routes.py` owns CRUD/search/index/import, owner filtering, skill test/audit jobs, and admin-gated built-in tool instruction overrides.
Skill extraction is owned by `services/memory/skill_extractor.py`. It can suggest or save skills from conversations, tries valid brace-delimited JSON candidates with `JSONDecoder.raw_decode()`, rejects ambiguous multiple top-level JSON objects instead of guessing, and saved skills remain user-editable data.
Agent skill behavior:
- matched skills are owner-scoped, confidence-gated, usage-counted, and wrapped as untrusted context;
- `index_for()` exposes published skills plus teacher-escalation drafts gated by platform and toolsets; `active_toolsets=None` means the caller has no explicit toolset knowledge and does not hide `requires_toolsets` skills, while an explicit list applies the gate;
- user prefs such as skills enabled, auto-approve, and max injected skills shape runtime insertion;
- the level-0 base skill index currently calls `index_for(owner=None)`, so it is not fully owner-scoped.
- skill tests use the configured utility model rather than the chat default and wrap user-editable skill text as untrusted context; approval continuation for a test or teacher-generated skill uses the same exact-action gate as the normal agent loop.
## Tools, MCP, And Backup
Native `manage_memory` and `manage_skills` tool paths pass owner context and use in-process policy gates. `manage_skills` requires an explicit action instead of silently defaulting a malformed call. Manual memory add can choose a category, and route-side manual add validates the source session owner before attaching session-derived memories. `mcp_servers/memory_server.py` lazy-initializes `src` managers and exposes list/add/edit/delete/search. It can scope to `ODYSSEUS_MCP_MEMORY_OWNER` or `ODYSSEUS_MEMORY_OWNER`; if the JSON store contains owner-bearing entries and no owner env is configured, it returns an owner-scope error instead of listing or mutating across owners. Ownerless stores remain ownerless compatibility mode.
The direct `odysseus-memory add` CLI tolerates non-object legacy/corrupt rows
when checking whether its newly added entry is already present; it ignores
those rows instead of calling mapping methods on them and crashing the add.
`/api/export` owner-filters memories and skills. `/api/import` imports skills through current disk-backed `SkillsManager` APIs, stamping missing owners to the importer and preserving supported skill metadata. Full data snapshots through `scripts/odysseus-backup` preserve on-disk skill trees, memory JSON, and caches differently from JSON import/export.
## Compatibility State
Memory and skills are partially migrated:
- app startup, MCP, and some tools still use `src.memory*`;
- services memory modules remain relevant for imports/tests, with memory and vector modules re-exporting canonical `src` implementations;
- `services/memory/service.py` is a compatibility facade around the canonical managers, but it remains ownerless and should not be assumed equivalent to route/tool owner policy;
- skills are service-owned and disk-backed, while backup import and some compatibility paths still expect older JSON/list shapes.
## Degraded Vector Memory
Chroma is an external HTTP service. Native defaults use `localhost:8100`; Docker uses `chromadb:8000`. Embeddings prefer configured HTTP endpoints and can fall back to local FastEmbed.
Startup can degrade to keyword-only memory when vector initialization fails. Extraction/audit paths catch vector failures and continue with text/JSON behavior. Vector dedup is checked against the current owner before suppressing a candidate, and audit rebuilds preserve other owners' vector rows. Chat retrieval assumes a healthy startup vector store remains usable, so post-start vector failures can still break memory retrieval unless handled by the caller.
Admin wipe currently has a vector cleanup compatibility gap because it imports a nonexistent helper before attempting vector clearing.
## Policy
Saved memories and skills are untrusted source data when shown to the model. A stored skill may contain useful instructions, but it is still user-editable content and must be framed consistently with prompt-injection policy.
Owner isolation is surface-specific:
- HTTP memory and skills routes are expected to owner-filter normal user data;
- native memory/skill tools are expected to pass owner context;
- Codex exposes scoped token memory behavior separately;
- normal memory/skills routes are cookie/current-user surfaces, not scoped token APIs;
- MCP memory uses an environment-configured owner for owner-scoped stores, while the agent level-0 skill index currently has ownerless/global behavior;
- vector dedup during memory extraction suppresses only same-owner or legacy-ownerless vector matches.
Skill test/audit flows intentionally run user-editable `SKILL.md` content as instructions inside controlled jobs. Those jobs rely on route owner checks, admin gates where applicable, and tool execution policy.
Skill import is admin-gated defense-in-depth, but imported URLs are still untrusted network input. Initial and redirected targets must remain public, automatic redirects stay disabled, and the connection must use only the IP set validated for that hop so DNS rebinding cannot change the destination between validation and transport.
User rename flows update skill frontmatter owner fields and `_usage.json` owner keys alongside memory/upload/research ownership migrations.
## Testing Coverage
Existing tests cover memory extraction/degraded vectors, owner isolation, unreadable-store mutation refusal, MCP memory shape/scope, skill owner update/delete, prompt-injection wrapping and approval continuation, utility-model selection, toolset gating, frontmatter escape round trips, skill-import redirect and DNS-rebinding/SSRF defenses, CLI non-object rows, and selected route owner checks.
Route-level memory CRUD/security, skills route security, MCP memory behavior, vector degraded writes, compatibility facade owner behavior, backup skill import, admin vector cleanup, and frontend endpoint wiring need broader coverage.
## Current Gaps
- `services/memory/service.py` needs an explicit owner-scope/support decision before it is treated as a public memory API.
- The agent level-0 skill index should thread owner or be documented as an intentional local/global index.
- MCP memory still needs a deliberate multi-user UX/config decision, but current behavior avoids cross-owner access when owner-bearing rows exist without an explicit MCP owner env.
- Memory JSON import does not rebuild vector indexes.
- Admin wipe vector clearing is currently ineffective.
- Chat memory retrieval needs a graceful path for vector failures after startup.
- Route-level memory and skills security coverage is incomplete.

View file

@ -0,0 +1,178 @@
# Canonical Provider And Model Capability Layer
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This spec covers the implementation introduced on current `dev` in:
- canonical model values and query helpers in `src/model_capabilities.py`;
- record, identity, and provider-detection helpers in
`src/model_capability_readers/base.py`;
- reader dispatch in `src/model_capability_readers/__init__.py`;
- concrete readers for generic OpenAI-compatible, OpenAI, OpenRouter, Google,
Ollama, LM Studio, and llama.cpp payloads;
- regression coverage in `tests/test_model_capabilities.py` and
`tests/test_model_capability_readers.py`.
The layer normalizes already-fetched JSON-compatible values. It performs no
network I/O, does not shape provider requests, does not persist its output, and
does not authorize model or tool use. No production caller currently consumes
the canonical records outside this package; runtime integration remains later
work.
There is no `src/provider_capability_schemas.py`, capability-specific
diagnostics module, or runtime model-quirk registry on current `dev`.
## Layer Boundaries
- `src.model_capabilities` defines normalized families, tasks, modalities,
capabilities, evidence sources/confidence, assertion states, deterministic
controls, probe results, reasoning-control tokens, and display-surface
queries.
- `ModelCapability` owns family, primary task, input/output modalities,
capability tokens, limits, source, and confidence.
- `CapabilityAssertion` records claimed, verified, unsupported, or unknown
status for one capability. Missing evidence is not an unsupported claim.
- `DeterministicControl` records support evidence for controls such as
temperature, top-p, seed, tool choice, or prompt caching. A supported
request control is not itself a model capability.
- `CapabilityProbeResult` is an in-memory evidence shape that converts pass,
fail, or partial probe state into an assertion. No current runtime probe
stores or merges these objects.
- `CapabilityQuery` and `display_surfaces_for()` map a normalized capability
into candidate surfaces such as chat, vision chat, image generation,
embeddings, or reranking. They are not wired into current pickers.
- Reader `ModelCapabilityRecord` binds a vendor/model identity to the nested
capability object, assertions, deterministic controls, and optional raw
provider evidence.
Provider transport support and per-model support are separate facts. Request
and response adapters remain in `src.llm_core` and related provider modules.
Model-specific observations remain in [model-quirks.md](model-quirks.md).
## Current Serialized Shapes
`ModelCapability.to_dict()` emits the nested capability shape:
```json
{
"family": "chat",
"primary_task": "chat.completions",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"capabilities": ["tool_call", "vision"],
"limits": {"context_tokens": 131072},
"source": "provider_reader",
"confidence": "provider_reported"
}
```
`ModelCapabilityRecord.to_dict()` wraps that value with `vendor`, `model_id`,
`stable_model_id`, `display_name`, `capability_assertions`, and
`deterministic_controls`. It does not currently emit a schema version or the
flat `provider`/`model`/`features`/`controls` shape. Raw provider fields are
included only when the caller passes `include_raw=True`.
Endpoint configuration can explicitly map `model_type=llm` to chat and
`model_type=image` to image generation. Missing or unrecognized endpoint types
stay unknown rather than silently becoming chat-capable in this schema layer.
## Identity And Reader Dispatch
`records_from_payload()` selects a reader from an explicit `vendor`, or from
`detect_vendor(base_url, endpoint_kind)` when no vendor is supplied.
Current detection order and behavior are:
1. a recognized explicit endpoint kind;
2. label-bounded hostname checks for OpenRouter, OpenAI, Anthropic, Google APIs, and Ollama Cloud;
3. common local ports: `11434` for Ollama, `1234` for LM Studio, `8000` for vLLM, and `30000` for SGLang;
4. generic OpenAI-compatible for any other parsed host, otherwise unknown.
These are normalization hints, not authorization. Hostname checks accept an exact domain or its dot-delimited subdomains after lowercasing and removing a trailing dot, so names such as `notopenai.com` do not match `openai.com`; local-port mappings remain intentionally covered by tests. Callers must not treat any result as proof of endpoint trust.
Implemented reader modules are `generic_openai`, `openai`, `openrouter`,
`google`, `llamacpp`, `ollama`, and `lmstudio`. Anthropic, Hugging Face,
SGLang, and vLLM have placeholder vendor IDs but currently dispatch through the
generic identity-only reader. Other explicitly supplied vendor strings are
also preserved while using that generic reader.
Stable model identity is scoped in this order:
- explicit endpoint ID;
- a short hash of normalized base URL when an endpoint ID is absent;
- `global` when neither endpoint identity is supplied.
## Generic Identity-Only Contract
The generic reader accepts mapping payloads containing `data[]` or `models[]`.
Each item must itself be a mapping and provide `id`, `name`, or `model`.
Bare-list payloads and `key`/`slug`-only items are not accepted by the current
implementation.
The reader deliberately returns unknown family, modalities, capabilities, and
controls. It preserves the raw item on the in-memory record but does not parse
type/task fields, descriptions, ownership, supported-parameter lists,
capability-looking booleans, or token limits.
## Provider-Native Readers
- OpenAI keeps the official Models API identity-only.
- OpenRouter maps explicit architecture modalities, supported parameters,
limits, voices, and default parameters into family/capability/control state.
- Google maps the native Models resource. Embedding-only methods map to the
embedding family; content-generation methods do not prove modality or chat
family. Explicit thinking, limits, sampling fields, caching, and batch
methods are retained without parsing product names.
- Ollama treats `/api/tags` as identity-only and maps selected-model
`/api/show` capability tokens. Context can come from structured fields or a
parsed `num_ctx` line in the serialized `parameters` value.
- LM Studio maps native v1 `models[]` and v0-style `data[]` fields. A plain
OpenAI-compatible list without native type/capability fields stays unknown.
- llama.cpp can merge `/v1/models`, `/props`, and `/slots` evidence for one
served model. It records tool/streaming claims, explicit unsupported
vision/audio assertions, controls, and runtime/training/size limits.
Readers tolerate non-object entries and unknown fields where their helpers
permit it. They do not infer authoritative capability from model IDs or display
names.
## Evidence Semantics
The canonical vocabulary includes admin override, endpoint configuration,
provider reader, Cookbook/Hugging Face, maintained registries, heuristic,
probe, and unknown sources. It also defines explicit, provider-reported,
registry, heuristic, and unknown confidence values.
Those tokens make evidence representable; current `dev` does not implement a
global precedence, merge, expiry, or conflict-resolution engine. Assertions
generated by readers are usually `claimed`; a `CapabilityProbeResult` maps pass
to verified, fail to unsupported, and partial to claimed at the scope carried
by that object.
## Tests
Focused tests pin:
- endpoint-kind, host, and common-port vendor detection;
- endpoint/base-URL-scoped stable IDs;
- unknown behavior for generic and official OpenAI lists;
- canonical normalization and display-surface matching;
- assertion, deterministic-control, and probe-result shapes;
- OpenRouter, Google, Ollama, LM Studio, and llama.cpp mappings;
- negative cases that avoid name-based media/capability inference.
## Current Gaps
- Canonical records are not yet used by runtime discovery, endpoint resolution, model context, request shaping, or frontend pickers.
- Reader output is not persisted, refreshed, merged, or expired.
- Provider detection still uses common-port hints; consumers must not promote normalization hints into trust decisions.
- Only seven concrete readers exist; placeholder and other providers use the
identity-only generic reader.
- Generic fallback does not accept bare-list or `key`/`slug`-only payloads.
- There is no capability-specific diagnostic/logging path.
- Runtime request builders still contain model-name heuristics outside this
canonical layer.

View file

@ -0,0 +1,100 @@
# Provider Capability Specs
Last updated: dev@e71f8ce | 2026-08-25
## Scope
This directory maps serving-provider observations and current model-catalog
normalization into the canonical layer defined by
[model-capability-canonical.md](../model-capability-canonical.md). It records
current Odysseus implementation evidence, merged fixes, reproducible user
observations, and provider documentation without treating any single source as
global model truth.
## General To Specific Resolution
Read specs in this order:
1. [openai-compatible.md](openai-compatible.md) for the conservative general
identity-only reader;
2. the serving-provider file for native endpoints, headers, request/response
observations, and catalog fields;
3. [model-quirks.md](../model-quirks.md) for model-specific observations.
Provider files document transport; runtime adapters still own it. Model quirks
record only deviations and are not a second runtime matcher. Shared model facts
must not be copied into every provider file. An OpenAI-compatible provider is
not OpenAI: an explicitly supplied vendor string is preserved even when it uses
the generic reader.
Current reader dispatch does not infer a provider from payload shape. It uses an explicit vendor, then endpoint kind, label-bounded hostname matches, and common local-port hints. The port hints map 11434 to Ollama, 1234 to LM Studio, 8000 to vLLM, and 30000 to SGLang. Those hints are normalization behavior, not endpoint trust.
## Provider Map
### Implemented canonical readers
- [openai.md](openai.md): identity-only Models API plus Chat/Responses dialects.
- [openai-compatible.md](openai-compatible.md): generic compatible catalog and runtime dialect boundaries.
- [openrouter.md](openrouter.md): rich architecture, modalities, parameters, and limits.
- [google.md](google.md): native paginated Gemini Models API and GenerateContent.
- [ollama.md](ollama.md): `/api/tags`, `/api/show`, native chat, and OpenAI compatibility.
- [lm-studio.md](lm-studio.md): native v1 catalog/chat, explicit v0 compatibility, and OpenAI compatibility.
- [llama-cpp.md](llama-cpp.md): `/props`, `/slots`, OpenAI/Responses/Anthropic surfaces.
### Placeholder identities using the generic reader
- [anthropic.md](anthropic.md): identity-only Models API and native Messages runtime adapter.
- [vllm.md](vllm.md): common-port identity hint; deployment capability remains unknown.
- [sglang.md](sglang.md): common-port identity hint; parser/config-dependent capability remains unknown.
- [hugging-face.md](hugging-face.md): Hub observations and download/fit metadata without a canonical reader.
### Provider observations without a dedicated canonical reader
- [mistral.md](mistral.md): rich model cards, reasoning controls, and structured runtime content.
- [github-copilot.md](github-copilot.md): account model-list observations and required runtime headers.
- [chatgpt-subscription.md](chatgpt-subscription.md): Codex model identity and Responses event shape.
- [cohere.md](cohere.md): native endpoint/catalog observations; not currently normalized.
### Other provider identity and general/identity-only observations
- [moonshot-kimi.md](moonshot-kimi.md)
- [deepseek.md](deepseek.md)
- [groq.md](groq.md)
- [nvidia-nim.md](nvidia-nim.md)
- [cerebras.md](cerebras.md)
- [together.md](together.md)
- [fireworks.md](fireworks.md)
- [xai.md](xai.md)
- [zai.md](zai.md)
- [opencode.md](opencode.md)
- [perplexity.md](perplexity.md)
- [github-models.md](github-models.md)
- [venice.md](venice.md)
- [azure-openai.md](azure-openai.md)
- [bedrock.md](bedrock.md)
- [cloudflare-workers-ai.md](cloudflare-workers-ai.md)
- [atlas-cloud.md](atlas-cloud.md)
- [siliconflow.md](siliconflow.md)
- [minimax.md](minimax.md)
### Other local/proxy serving identities
- [local-compatible-engines.md](local-compatible-engines.md): MLX LM, TGI,
LMDeploy, LiteLLM, and unknown compatible deployments.
## Provider Spec Template
Each provider file records:
- provider identity and API dialects;
- latest observed native catalog endpoint/envelope and capability-bearing fields;
- whether current source has a dedicated reader or only generic fallback;
- observed request, tool, text, reasoning, and control paths owned by runtime
adapters rather than the catalog reader;
- what remains per-model/unknown;
- Odysseus evidence and regressions;
- fallback/safety behavior and current gaps.
Marketing capability lists and curated picker lists may guide research but do
not automatically become model claims. Provider-returned false values can be
negative evidence only at the same provider/endpoint/model scope.

View file

@ -0,0 +1,39 @@
# Anthropic Provider Shape
Last updated: dev@e71f8ce | 2026-08-25
## Scope
Canonical placeholder vendor ID `anthropic`; Anthropic Messages runtime
adapter in `src/llm_core.py`. There is no dedicated Anthropic capability-reader
module; explicit/auto-detected Anthropic payloads use the generic identity-only
reader.
## Catalog Shape
`GET /v1/models` returns `data[]` model resources with `id`, `type: model`,
`display_name`, and `created_at`, plus pagination metadata. These fields prove
identity/availability only. Do not assume all listed Claude models share
vision, tools, reasoning, sampling, or context limits.
## Request And Response Shape
Native Messages uses a top-level `system`, alternating `messages`, content
blocks, `tools[].input_schema`, `tool_use` assistant blocks, and `tool_result`
user blocks. Text, thinking, signatures, server-tool blocks, and tool calls are
typed content rather than OpenAI roles/fields. Preserve block IDs/signatures
needed for continuation.
Sampling and thinking support can be version/model specific. The Opus 4.7+ sampling omission is a model-scoped runtime observation, not an Anthropic-wide rule. Runtime version parsing accepts explicit major/minor IDs and later major-only IDs such as `claude-opus-5`, treats a missing minor as `.0`, caps both components so date stamps cannot be misread as versions, and keeps legacy Claude 3 Opus sampling intact. Anthropic-compatible proxies are Anthropic dialect only when configured or their exact payload/endpoint shape proves it (#3110).
## Fallback And Safety
Runtime and canonical reader detection use label-bounded Anthropic host matching or an explicit endpoint kind. A provider using Anthropic Messages through another host must be explicit. Identity-only model cards remain unknown.
## Current Gaps
- The public model list does not provide per-model canonical capability data.
- There is no dedicated Anthropic canonical reader; only `id`, `name`, or
`model` identity survives generic normalization.
- Runtime model-version parsing needs structured identity before a later
consumer can centralize sampling exceptions without another name matcher.

View file

@ -0,0 +1,21 @@
# Atlas Cloud Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `atlas_cloud`; OpenAI-compatible provider proposed in
#5566 with live `/v1/models` observations for current Qwen/DeepSeek offerings.
## Shape
Treat the observed list as identity-only. Even capability-looking item fields
remain raw until an Atlas-specific discriminating shape intentionally maps
them. The model IDs observed by a PR demonstrate availability at that time,
not permanent capability or a reason to hardcode family-name behavior.
## Fallback And Current Gaps
Exact Atlas Cloud host or explicit kind preserves identity; otherwise use the
inventory fallback. The provider work is open/unmerged and has no independently
versioned rich catalog schema, so evidence remains provisional.

View file

@ -0,0 +1,26 @@
# Azure OpenAI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `azure_openai`; Azure deployment-scoped OpenAI dialects;
custom endpoints use explicit configuration.
## Shape
Azure commonly identifies deployments rather than globally stable model IDs.
Preserve endpoint, deployment ID, API version, and underlying model/version as
separate structured identity when returned. A standard OpenAI-compatible model
list is identity-only until an Azure-specific reader intentionally maps its
deployment fields.
Request paths and authentication can be deployment/API-version specific; do
not blindly append public OpenAI paths or copy provider quirks. Capability and
limits are deployment scoped.
## Fallback And Current Gaps
Known `*.openai.azure.com` hosts select Azure OpenAI; other Azure gateways need
explicit kind. Odysseus lacks a native Azure deployment catalog reader and
structured API-version persistence in the canonical record.

View file

@ -0,0 +1,23 @@
# AWS Bedrock Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `bedrock`; UI/provider mapping currently recognizes AWS
Bedrock, but the canonical layer has no native Bedrock runtime reader.
## Shape
Bedrock is not generally an OpenAI-compatible host: model IDs, inference
profiles, request/response unions, signing, and per-family payloads differ.
Only an explicitly configured OpenAI/Anthropic-compatible gateway may use those
dialects. Native Bedrock capability must come from a versioned Bedrock model
catalog plus exact foundation-model/inference-profile identity.
## Fallback And Current Gaps
Do not classify all `amazonaws.com` hosts as Bedrock; use explicit kind or a
future region-aware exact host/path shape. General fallback is safe only behind
an explicitly compatible gateway. Native signing, catalogs, and family payload
mappings remain unimplemented.

View file

@ -0,0 +1,23 @@
# Cerebras Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `cerebras`; OpenAI-compatible cloud transport; runtime
provider detection and cache-affinity safeguards in `src/llm_core.py`.
## Shape And Observations
Model lists use the general identity-only inventory reader. Cerebras rejects
llama.cpp-only `session_id` and `cache_prompt` fields (#4640), so cloud identity
must suppress local slot-affinity extensions. Current regressions pin this
provider boundary.
Tool, reasoning, structured output, and limits remain per model. Do not promote
them from the fact that the API accepts OpenAI Chat.
## Fallback And Current Gaps
Exact `*.cerebras.ai` selects provider identity. Compatible proxies require
explicit configuration. No rich per-model Cerebras catalog reader is present.

View file

@ -0,0 +1,47 @@
# ChatGPT Subscription Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `chatgpt_subscription`; Codex Responses transport;
auth and runtime code in `src/chatgpt_subscription.py`,
`routes/chatgpt_subscription_routes.py`, and `src/llm_core.py`.
There is no dedicated ChatGPT Subscription canonical reader on current `dev`.
## Catalog Shape
The account-scoped Codex models endpoint returns root `models[]`; `slug` is the
request identity and `visibility`/`priority` control availability/order. These
fields do not prove tools, reasoning, vision, or context. Null/malformed model
lists fail soft rather than crashing discovery (#5280/#5281).
The canonical generic reader does not accept `slug`-only items, so this runtime
catalog is not currently normalized into `ModelCapabilityRecord` values.
## Request And Response Shape
Transport uses a ChatGPT backend Responses endpoint, `input` items, flattened
function tools, streamed function-call argument events, exact `call_id`, and
`function_call_output` continuation. Parallel calls and encrypted reasoning
continuity require preserving typed output/history rather than coercing all
roles to text. This shape is supported by the existing adapter and the focused
tool-calling follow-up evidence in #5490; unmerged observations remain claimed
until integrated/reproduced.
OAuth/device credentials and refresh are provider-session behavior. Expired
credentials should return an actionable reconnect error, not generic model
failure.
## Fallback And Safety
Only the explicit internal base/ChatGPT host selects this provider. Never send
subscription credentials to a custom OpenAI-compatible URL. Catalog slugs stay
identity-only unless account-scoped fields or probes supply capability.
## Current Gaps
- Comprehensive Responses tool/reasoning parity is still evolving.
- Account model slugs are not consumed by the canonical reader package.
- The account catalog does not currently provide a complete canonical
capability card for every slug.

View file

@ -0,0 +1,21 @@
# Cloudflare Workers AI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `cloudflare_workers_ai`; OpenAI-compatible Workers AI
endpoint observations in #5175; explicit provider configuration required.
## Shape
Cloudflare account/path identity is part of the endpoint. Use the general
OpenAI-compatible inventory reader for returned model cards, preserving full
model IDs but no capability fields.
Do not identify the provider from broad `api.cloudflare.com` alone or infer
capability from Workers AI catalog prose.
## Fallback And Current Gaps
Provider identity must be explicit until a narrow account/AI path matcher is
implemented. There is no rich normalized capability catalog reader.

View file

@ -0,0 +1,56 @@
# Cohere Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Documented provider identity `cohere`; native Chat v2 plus the OpenAI
Compatibility API. Current `dev` has no dedicated Cohere capability reader or
direct Cohere request adapter; compatible endpoints use the general runtime
path when explicitly configured.
## Catalog Shape
`GET /v1/models` returns a paginated `models[]` envelope. Each model can carry
`name`, `endpoints`, `default_endpoints`, `context_length`, `features`, and
`sampling_defaults`; the root can carry `next_page_token`.
These are candidate fields for a future dedicated reader:
- a single canonical family from `endpoints`: `chat`/`generate`, `embed`,
`rerank`, or `classify`;
- `context_length` to the endpoint/model context limit;
- known sampling-default keys to deterministic controls.
Current canonical normalization does not map them. When the generic reader is
explicitly selected with vendor `cohere`, it preserves only item identity plus
the raw item; family, context, features, and sampling controls stay unknown.
## Request And Response Shape
Native `POST /v2/chat` uses `messages`, structured content blocks, tools,
`response_format`, sampling fields, and an optional structured `thinking`
object. Text lives in `message.content[type=text].text`; reasoning-capable
models use `message.content[type=thinking].thinking`. Streaming uses typed
events rather than one generic text delta.
The OpenAI compatibility base is `/compatibility/v1`. Its current chat subset
includes tools, structured output, sampling, and `reasoning_effort`, but model
support remains per-model. In the compatibility dialect only `none` and `high`
currently map to native thinking off/on; do not assume low/medium support.
## Fallback And Safety
No Cohere host or payload-shape detection exists in the canonical reader
registry. The caller must supply provider/endpoint configuration. Marketing
pages and provider-wide endpoint features do not grant every listed model
tools, vision, or reasoning.
## Evidence And Gaps
- Official List/Get Models resources define the catalog fields.
- Official Chat v2, Reasoning, and Compatibility API resources define the
transport and thinking controls.
- Odysseus has no direct Cohere request adapter, canonical reader, or sanitized
canonical fixtures yet; both normalization and runtime integration remain
follow-up work.

View file

@ -0,0 +1,30 @@
# DeepSeek Provider Shape
Last updated: dev@2e2bb52 | 2026-08-16
## Scope
Canonical provider ID `deepseek`; official cloud OpenAI-compatible API;
curation/detection in `routes/model_routes.py` and runtime reasoning handling in
`src/llm_core.py`.
## Shape And Observations
Use the general model-list inventory shape; capability-looking fields remain
unknown until a DeepSeek-native reader maps them. Cloud response history can use
`reasoning_content`; preserve it structurally for reasoning turns and tool
continuation (#968, #3152). `deepseek-chat`, reasoning models, distilled local
variants, and future V4 models do not share one capability record.
Cloud endpoint evidence can support tools while a local DeepSeek-R1 deployment
may not have a working tool parser. Existing tool-support tests intentionally
separate official host from local engine/model-name heuristics.
Current runtime thinking-pattern detection includes DeepSeek V4 identifiers so their structured reasoning channel is handled like the other supported DeepSeek reasoning families. This name-level compatibility rule is not canonical capability evidence and does not make every V4-labelled local deployment tool-capable.
## Fallback And Current Gaps
Exact `*.deepseek.com` selects provider identity; self-hosted checkpoints use
Ollama/vLLM/SGLang/llama.cpp identity. Curated model IDs and pricing/context
tables are compatibility data, not authoritative capability. A rich official
model-card reader is still absent.

View file

@ -0,0 +1,22 @@
# Fireworks AI Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `fireworks`; OpenAI-compatible cloud transport with path
prefixes such as `/inference/v1`; curation and URL handling in
`routes/model_routes.py` and `src/endpoint_resolver.py`.
## Shape
Use the general identity-only inventory reader. Fireworks IDs can contain
account/model paths; preserve the full request ID and endpoint scope. Item
modalities, supported parameters, task/type, and limits require a
Fireworks-native mapped shape before promotion.
## Fallback And Current Gaps
Exact `*.fireworks.ai` preserves provider identity and its configured path
prefix. Do not normalize account-qualified IDs by taking the last path segment.
No verified rich Fireworks capability catalog is currently mapped.

View file

@ -0,0 +1,46 @@
# GitHub Copilot Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `copilot`; OpenAI-compatible chat with Copilot headers
and OAuth; runtime adapter `src/copilot.py` and routes in
`routes/copilot_routes.py`. There is no dedicated Copilot canonical reader on
current `dev`.
## Catalog Shape
The observed Copilot `/models` response uses `data[]` entries with:
- `id`;
- `model_picker_enabled`;
- `capabilities.supports.tool_calls` and `.vision`;
- optional limit/family metadata.
Runtime model discovery uses picker state for availability. The canonical
reader package does not map the nested support fields; an explicitly supplied
`copilot` vendor currently uses generic identity-only normalization, and
`model_picker_enabled` does not become canonical capability.
## Request And Response Shape
Chat is OpenAI-compatible but requires Copilot/GitHub API version, editor/plugin
identity, intent, integration, and initiator headers; image requests add the
vision request flag. Header derivation must tolerate malformed message entries.
OAuth token exchange and access policies are provider authentication, not model
capability.
## Fallback And Safety
Use exact GitHub Copilot host or explicit kind, including the constrained
enterprise `copilot-api.*.ghe.com` form. Do not treat arbitrary `ghe.com` hosts
as Copilot. Official model availability tables are useful registry context but
do not replace the account-scoped catalog response.
## Current Gaps
- The catalog shape is implementation-observed and needs ongoing fixture
comparison with current Copilot clients.
- Copilot catalog capability fields are not normalized by current `dev`.
- Account/plan/policy availability must remain endpoint-user scoped.

View file

@ -0,0 +1,21 @@
# GitHub Models Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `github_models`; OpenAI-compatible GitHub Models/Azure
inference endpoint observed in #2995; distinct from GitHub Copilot.
## Shape
Use general identity-only inventory. Deployment IDs and account access
can differ from upstream model IDs. Do not copy Copilot picker metadata,
headers, plan rules, or capabilities into GitHub Models; they are separate
providers despite shared GitHub branding.
## Fallback And Current Gaps
The known `models.inference.ai.azure.com` host selects GitHub Models. Other
Azure deployment hosts require explicit provider configuration. No rich
account-scoped capability catalog is currently mapped.

View file

@ -0,0 +1,54 @@
# Google Gemini Provider Shape
Last updated: dev@e71f8ce | 2026-08-25
## Scope
Canonical provider ID `google`; native GenerateContent plus optional Google
OpenAI-compatible chat; readers `google.py` and
`google_ai_studio_mapping.py`; catalog/probe ownership in
`routes/model_routes.py`.
## Catalog Shape
Use the native paginated `GET /v1beta/models` endpoint, including
`nextPageToken`, with `x-goog-api-key` when configured. `models[]` can contain:
- `name`, `baseModelId`, `version`, and `displayName`;
- `inputTokenLimit` and `outputTokenLimit`;
- `supportedGenerationMethods`;
- `thinking`, `temperature`, `maxTemperature`, `topP`, and `topK`.
Embedding-only methods map to embedding. Generation methods prove a native
method, not chat/image/video/audio modality, so those records remain unknown
unless stronger structured evidence exists. `thinking: true` and explicit
sampling fields map to a reasoning claim and controls. Model IDs such as
Imagen, Veo, or TTS names are not parsed.
## Request And Response Shape
Native generation uses `contents`, `systemInstruction`,
`generationConfig`, `tools[].functionDeclarations`, and
`models/{model}:generateContent|streamGenerateContent`. Responses use
`candidates[].content.parts[]` for `text`, `functionCall`, `functionResponse`,
`thought`, and `thoughtSignature`; token accounting is in `usageMetadata`.
Native Google tool/thought continuity must not be flattened through an
OpenAI-only history shape.
## Fallback And Safety
Prefer native model metadata even when chat is configured through Google's
OpenAI compatibility URL. Pagination parameters must remain stable between
pages. The route probe activates only for the exact
`generativelanguage.googleapis.com` hostname, filters the picker list to
content-generation methods, returns no curated fallback after probe failure,
and defaults those endpoints to manual catalog refresh unless explicitly
overridden. The canonical Google reader is not yet called by that probe.
Unknown methods and fields stay raw; unrecognized prediction models remain
unknown.
## Current Gaps
- The Models resource does not expose full modalities for every Google media
family.
- Native Gemini request/response support is not yet the only runtime path.

View file

@ -0,0 +1,24 @@
# Groq Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `groq`; OpenAI-compatible cloud transport; detection and
request behavior in `src/llm_core.py`.
## Shape
Model discovery falls back to the general `data[].id` identity shape. Richer
fields require a Groq-native mapped shape even when the payload happens to
supply modalities, supported parameters, or limits. Groq transport may accept OpenAI-style tools and streaming extensions,
but support remains per model and account.
Runtime currently exempts Groq/OpenRouter from some parameter stripping paths;
that is transport compatibility, not a provider-wide model capability claim.
## Fallback And Current Gaps
Exact `*.groq.com` preserves Groq identity. Do not infer Llama/Gemma model
capabilities from IDs. There is no canonical rich Groq model-card reader or
freshness policy yet.

View file

@ -0,0 +1,41 @@
# Hugging Face Provider And Registry Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical placeholder vendor ID `huggingface`; download/fit metadata in
`services/hwfit/`; OpenAI-compatible inference providers/TGI handled as their
serving dialect. There is no dedicated Hugging Face canonical reader on
current `dev`.
## Hub Model Shape
Hub model info can provide `modelId`/`id`, `pipeline_tag`, `tags`, `config`, and
card metadata. Current canonical normalization does not map `pipeline_tag`,
`config.model_type`, or Hub task/modality fields. An explicitly selected
Hugging Face vendor uses generic identity-only normalization.
This source is `cookbook_hf`/registry confidence, not live endpoint truth.
Free-form tags, README/card prose, repository names, and architecture names do
not automatically claim capability. A serving engine can load a model with
missing projection, different template, or disabled parser.
## Serving Shape
Hugging Face routed inference and TGI can expose OpenAI-compatible endpoints;
their model list may be identity-only. Keep Hub identity separate from the
serving endpoint and merge only when exact revision/model identity is known.
## Fallback And Safety
Hub metadata can fill a scoped registry record after provider payload fields
and probes, but must not overwrite fresh endpoint-negative evidence. Treat
remote code, model cards, and repository files as untrusted content.
## Current Gaps
- Revision/digest linkage between downloads, Hub records, and serving
endpoints is incomplete.
- Hub task/family metadata is not consumed by the canonical reader package.
- Pipeline tags can be missing or overly broad; unknown stays unknown.

View file

@ -0,0 +1,47 @@
# llama.cpp Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `llamacpp`; OpenAI Chat/Responses and Anthropic Messages
compatibility plus native server metadata; reader
`src/model_capability_readers/llamacpp.py`.
## Metadata Shapes
`/v1/models` provides served identity and can include server model entries;
native `/props` is authoritative for the running model/server combination:
- `model_alias`/`model_path`;
- `default_generation_settings.n_ctx` and sampling `params`;
- `total_slots` and optional `/slots[].n_ctx` fallback;
- `chat_template_caps` for tools/system role;
- `modalities.vision|audio`;
- current server/build state.
Capability depends on weights, projection/model assets, chat template, parser,
and launch flags. It is endpoint evidence, not a checkpoint-name claim.
`/props` and `/v1/models` can be merged only for the same served identity.
## Request And Response Shape
llama-server supports several OpenAI-compatible tasks and native extensions.
Do not infer embeddings/rerank/chat solely from the OpenAI model card; use an
explicit server model capability field or endpoint configuration. Tool and
reasoning correctness can depend on selected chat template and parser.
## Fallback And Safety
The registry selects llama.cpp through an explicit vendor or endpoint kind; it
does not auto-detect `/props` from payload shape. Port 8000 currently maps to
the vLLM placeholder, while 8080 falls through to generic OpenAI-compatible.
llama.cpp-only `session_id` and `cache_prompt` affinity fields must remain local
endpoint behavior and never leak to strict cloud providers (#4640 and current
affinity tests).
## Current Gaps
- Multi-model routing requires per-served-model `/props` association.
- Parser/template configuration is not yet fully represented in canonical
endpoint metadata.

View file

@ -0,0 +1,45 @@
# LM Studio Provider Shape
Last updated: dev@e57f60b | 2026-07-20
## Scope
Canonical provider ID `lmstudio`; native LM Studio v1 plus OpenAI Chat and
Responses compatibility; reader `src/model_capability_readers/lmstudio.py`.
## Catalog Shapes
Preferred shape is `GET /api/v1/models` with root `models[]`. Current fields
include `key`, `type` (`llm` or `embedding`), display/publisher data,
`architecture`, quantization/format/size, `max_context_length`,
`loaded_instances[].config.context_length`, and a capability object containing
`vision`, `trained_for_tool_use`, and reasoning options/defaults.
Compatibility shape `GET /api/v0/models` uses `data[]` with `id`, `type`
(`llm`, `vlm`, or embeddings), `arch`, `compatibility_type`, state, and
context metadata. It is an explicit older shape, not a loose fallback.
OpenAI `/v1/models` is identity-only when native endpoints are unavailable.
Loaded-instance context is the effective runtime context; maximum context is a
separate limit. Model type maps family, explicit capability booleans map
vision/tools/reasoning, and architecture is provider-reported model family.
## Request And Response Shape
Native v1 chat is `/api/v1/chat` and can expose stateful/MCP-oriented output;
LM Studio also supports OpenAI Chat and Responses compatibility. Keep dialect
selection explicit because tool/MCP features differ between native and
compatible paths.
## Fallback And Safety
Current reader detection identifies port 1234 as LM Studio. Prefer pathless
native `/api/v1/models` discovery where configured (#1122, #3615), then v0,
then general identity. The port mapping is a normalization hint, not endpoint
trust. An error object from an unsupported native route is not a model list.
## Current Gaps
- Runtime discovery does not yet persist native capability records.
- LM Studio API capabilities continue to evolve; each new native version needs
an explicit shape fixture before promotion.

View file

@ -0,0 +1,37 @@
# Other Local And Proxy Compatible Engines
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical explicit identities `mlx_lm`, `text_generation_inference`,
`lmdeploy`, and `litellm`, plus unknown OpenAI-compatible deployments not
covered by the native Ollama, LM Studio, llama.cpp, vLLM, or SGLang specs.
## Shape
Use explicit endpoint kind when known; otherwise use only the general model
list envelopes for inventory identity. Capability-looking structural fields
remain raw. Local host and port do not distinguish these engines.
MLX/Cookbook launch recipes, TGI task configuration, LMDeploy
adapters, and LiteLLM upstream routing can all change capability independently
of the model ID.
Proxy model aliases are endpoint scoped. A proxy may return richer fields, but
unknown keys remain raw until a versioned shape is added. Provider-specific
headers/extensions must not be applied based on a port or upstream model name.
## Fallback And Safety
Discovery can probe cheap native identity endpoints when available, but
capability probes execute only explicit bounded test contracts. Never read
broad server/environment dumps as ordinary model metadata. Unknown compatible
servers should still list identities and make conservative text calls where
explicitly configured, without appearing on capability-gated surfaces.
## Current Gaps
- These engines need individual safe metadata fixtures before they can graduate
from general fallback.
- Gateway upstream identity and effective downstream model capability are not
yet represented as a chain.

View file

@ -0,0 +1,48 @@
# MiniMax Provider Shape
Last updated: dev@28d27ee | 2026-07-17
## Scope
Canonical provider ID `minimax`; international host `api.minimax.io`, China
host `api.minimaxi.com`; current OpenAI-compatible and recommended
Anthropic-compatible text transports. Odysseus contains MiniMax-oriented tool
output handling and local-serving guidance but no dedicated catalog reader.
## Catalog Shape
Current `GET /v1/models` is an OpenAI-compatible identity list:
`object: list`, `data[]`, and model cards containing `id`, `object: model`,
`created`, and `owned_by: minimax`. The `owned_by` discriminator identifies the
provider shape, but the card exposes no per-model capability or modality
fields. Keep these records unknown and preserve raw identity metadata.
Do not backfill current model capabilities, token limits, or modalities from
the platform overview into this list response. Those tables are useful scoped
registry evidence only after model/version identity and freshness are carried
explicitly.
## Request And Response Shape
- OpenAI compatibility uses `/v1/chat/completions` and structured
`reasoning_content` alongside normal message content.
- Anthropic compatibility uses `/anthropic/v1/messages`; the current M2.7
family supports typed thinking blocks and interleaved thinking, making this
the preferred reasoning/tool-continuation transport in provider guidance.
- Native audio, image, video, music, and file endpoints are separate product
shapes. They must not be inferred from presence in the text model list.
## Local Deployments
The current provider guide documents vLLM, SGLang, and MLX deployment. Those
instances retain serving-engine identity and configuration-derived capability;
the checkpoint name alone does not turn a vLLM/SGLang card into the hosted
MiniMax provider shape.
## Fallback And Current Gaps
Exact MiniMax hosts or the discriminating `owned_by: minimax` model-list shape
select provider identity. Unknown compatible proxies retain the general shape.
The identity list does not safely distinguish M2 reasoning behavior from
speech/image/video/music products, so exact model quirks remain documentation
until structured model-version evidence reaches runtime request builders.

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