The visual research report is assembled from LLM output over crawled web
pages (untrusted content) and served under a relaxed `script-src
'unsafe-inline'` CSP. Two values reached that HTML without sanitization:
- `_md_to_html` rendered the report markdown via python-markdown, which
passes raw HTML through verbatim, so `<script>` / `<img onerror>` /
`<svg onload>` / `javascript:` links carried in crawled content ran in
the app origin.
- `category` (from the /api/research/start request body, no enum check) was
interpolated raw into `<body class="category-{category}">`.
Allowlist-sanitize the rendered markdown with nh3, keeping the formatting
the report emits (tables, code, details/summary, toc anchors, codehilite
classes, external-link target/rel) while dropping active content, and
html.escape the category. Adds regression tests.
Adding GigaChat (Sber) or an on-premise enterprise LLM gateway as a
model endpoint fails on first probe with
CERTIFICATE_VERIFY_FAILED: self-signed certificate in certificate
chain (_ssl.c:1000)
because their TLS chain is signed by a private root CA (Russian Trusted
Root CA for GigaChat; corporate CA for on-prem) that isn't part of the
default system / certifi trust store. The endpoint shows offline in
the picker even though the URL and API key are correct (issue #722).
The right fix is to extend the trust store, not to weaken verification.
This change:
- src/tls_overrides.py: new module that resolves an opt-in env var
LLM_CA_BUNDLE at import time, builds a shared SSLContext via
ssl.create_default_context() (so the system / certifi bundle is
loaded first) and layers the operator's PEM on top with
load_verify_locations(). Exposes llm_verify() returning a value
suitable for httpx `verify=`. Defaults to True (httpx built-in
trust) when the env var is unset, when the file is missing, or
when the PEM fails to load — verification is never silently
disabled, the warning is logged and we fall back to the safe path.
- src/llm_core.py: thread llm_verify() into the shared AsyncClient
used by stream_llm / streaming completions.
- routes/model_routes.py: thread llm_verify() into the five httpx.get
call sites in _probe_endpoint / _ping_endpoint so adding a
private-CA endpoint goes green on the very first probe and the
picker stops showing it offline.
- .env.example: document LLM_CA_BUNDLE with the GigaChat case as the
concrete example.
Deliberately NOT included: a verify=False knob (global or per-host).
Disabling verification exposes the affected endpoint to MITM, and the
operator-supplied bundle is the correct fix for legitimate private-CA
providers — so the only switch in this PR is the safe one.
Closes#722.
Pip dependency installs are tracked as download tasks but finish with the
runner's "=== Process exited with code 0 ===" sentinel and pip's
"Successfully installed" line — never the HuggingFace download markers
(DONE / 100% / /snapshots/ / DOWNLOAD_OK) the download heuristics look for.
Once the tmux pane is gone, the backend's only completion check is the HF
cache lookup, which a pip package (e.g. llama-cpp-python[server], no "/")
never matches, so it reports "stopped" — and the frontend maps a stopped
download to "crashed". The reconnect loop's session-gone heuristic had the
same gap. Result: a clean install (exit 0) showed "crashed" in the Running
tab while the Dependencies tab correctly showed it installed.
Add a shared _depInstallSucceeded() helper that keys off the exit-0
sentinel (falling back to pip's success line, rejecting ERROR/Traceback)
and wire it into both the session-gone heuristic and the background status
reconciler, gated on payload._dep so real model downloads are unaffected.
Also fixes the pre-existing test_background_status_poll_reconciles_into_local_tasks
assertion that no longer matched the evolved reconciler, and adds regression
coverage for both paths.
txt/html/md export joined and string-munged message.content directly, so a
multimodal turn (content is a list of blocks) crashed export with a TypeError
on join (txt) / AttributeError on .replace (html), and None content (tool-only
assistant turns) rendered as the literal 'None'. Add a _content_to_text helper
that flattens string/list/None to plain text and apply it at the three export
sites. JSON export is unchanged (it serializes structured content correctly).
Plain-string content is returned unchanged, so existing exports are identical.
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
ModelEndpoint is defined in core.database, not src.database. The wrong
import silently prevented the module from loading in deployment
configurations that do not have a src/database.py shim, resulting in an
ImportError at startup.
Also adds a warning log when resolve_endpoint finds no usable model
(all models hidden or the list is empty), making the otherwise-silent
failure visible in operator logs.
The test_auth_regressions stub for src.endpoint_resolver was missing the
build_models_url attribute, which caused test collection errors.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Agent mode treated local /v1 endpoints, including Ollama on :11434, as native-tool-capable by host/model heuristics. On Ollama's OpenAI-compatible surface some models that advertise tool support stop after a single token when schemas are sent (issue #1567). Default local Ollama /v1 back to fenced tool blocks unless the endpoint explicitly has supports_tools=True.
Also compare both the runtime chat URL and the normalized endpoint base when reading ModelEndpoint.supports_tools. That keeps a saved base URL such as http://localhost:11434/v1 effective when the active session URL is /v1/chat/completions.
Tests: .venv/bin/python -m pytest tests/test_tool_support_heuristic.py
Some test files (e.g. test_llm_core_sanitize_tool_calls) stub
sqlalchemy and core.database at module level with
`if mod not in sys.modules`. During pytest collection these stubs
fire before the real modules are imported, contaminating every
subsequent test that needs real ORM objects (IntegrityError, missing
columns, etc.).
Pre-import the real modules in conftest.py so the module-level
guards find them already loaded and skip the stubs. Fixes ~10+
cascading test failures that only appear in the full suite.
Fixes#2395
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: support large proxy model endpoint refresh
Large OpenAI-compatible proxy endpoints can expose hundreds of models and make /v1/models slow. Treating those endpoints like local model servers caused model picker opens and background probes to repeatedly hit /models, producing timeouts and making otherwise usable endpoints appear offline.
Make model endpoint discovery cached-first for normal UI usage, add explicit proxy/API classification and refresh policy fields, exclude proxy/API endpoints from aggressive local probing, and preserve cached models when refresh fails.
Manual Test/Add/Refresh actions still fetch the full model list with longer timeouts so users can intentionally import large proxy model lists without blocking normal model picker usage.
* fix: preserve endpoint ping status semantics
* fix: revoke API bearer tokens when their owner is deleted
* Re-run CI
* Invalidate bearer-token cache on user delete so warmed cached tokens stop working
Blind Compare anonymized the pane headers, but each pane still created a helper chat session named "[CMP] <real-model>" and GET /api/sessions returned the session's model field. So the sidebar and the session-list API let a user map "Model A" back to its real model before voting, defeating the blind test.
- Frontend (static/js/compare/index.js, panes.js): in blind mode, name helper sessions by their neutral slot ("[CMP] Model A") instead of the model, matching the existing blind pane labels.
- Backend GET /api/sessions (routes/session_routes.py): blank the model field for [CMP]-prefixed helper sessions via a new _public_model helper.
- Backend /api/compare/start (routes/compare_routes.py): name blind sessions by slot and withhold model_left/model_right/mapping from the blind response (revealed at /vote).
- Tests: tests/test_blind_compare_redaction.py.
Fixes#1285.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
read_email, reply_to_email and download_attachment fetched the full message with
the legacy bare RFC822 item (UID FETCH <uid> (RFC822)). iCloud's IMAP server
silently ignores it — the fetch returns status OK but only (UID <uid>) with no
body tuple, so the parse reports 'Email not found with UID' even though the
message exists and list_emails (which uses RFC822.HEADER) shows it. Gmail honours
(RFC822), which is why it only reproduced on iCloud.
Switch the three full-message fetches to (BODY.PEEK[]), which iCloud and Gmail
both honour and which doesn't set \Seen. Response shape is unchanged (raw bytes
still at msg_data[0][1]), so parsing is unaffected; the RFC822.HEADER (listing)
and (UID) probe fetches are left as-is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_latest_inbox_fallback_uids logs out the broken connection before
reconnecting. The outer finally then logs out the new
connection. Both logouts are correct, the test assertion of == 1
was written before the reconnect logic existed. Changed to >= 1.
The sessions table now enforces NOT NULL on endpoint_url, but the
test fixture omitted it when seeding archived sessions, causing
IntegrityError on all three test cases.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
POST /api/research/start (require_privilege "can_use_research" — a normal
user, not admin) resolves an endpoint two ways and feeds the row's *decrypted*
api_key + base_url into research_handler.start_research(llm_endpoint=,
llm_headers=):
1. body.endpoint_id -> query(ModelEndpoint).filter(id == endpoint_id,
is_enabled == True).first()
2. no endpoint + nothing configured -> query(ModelEndpoint).filter(
is_enabled == True).first()
Neither was owner-scoped. ModelEndpoint is a per-user resource (core/database.py:
non-null owner = private, "the model picker only shows the endpoint to that
user"). So a research-privileged user (or a chat-scoped token) could pass another
user's PRIVATE endpoint_id — or fall through to their first-enabled row — and run
research against that owner's endpoint: spending their API key / quota and
reaching whatever internal base_url they configured (SSRF).
This is the same multi-tenant owner-scoping class already fixed for
companion/models, the /api/v1/chat session gate (#870), and the /api/v1/chat
first-enabled fallback (#1045, _first_enabled_endpoint). These two sinks on the
research path were missed.
Extract `_owned_enabled_endpoint(db, owner, endpoint_id=None)` which scopes via
the shared owner_filter helper (own rows + legacy null-owner shared rows),
matching webhook_routes._first_enabled_endpoint and session_routes._owned_endpoint.
Used for both sinks. A scoped miss on the explicit-id path returns the existing
404 ("Endpoint not found or disabled"), so endpoint existence isn't revealed. A
null/empty owner stays a no-op (single-user / legacy mode).
Add regression tests pinning both lookups (cross-owner rejected, own-row
allowed, legacy shared-row allowed, disabled-skipped, fallback never borrows,
null-owner no-op).
POST /api/compare/start (a normal-user route — no admin gate) creates two
caller-owned [CMP] sessions from caller-supplied endpoint URLs (endpoint_a /
endpoint_b), then copies a ModelEndpoint's *decrypted* api_key into each
session's headers by matching on URL:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base).first()
The match was not owner-scoped. ModelEndpoint is per-user (core/database.py:
non-null owner = private, "the model picker only shows the endpoint to that
user"). So a user could pass another user's endpoint base_url, have that owner's
api_key copied into a [CMP] session they own, then drive /api/chat_stream on that
session — spending the victim's API key / quota and reaching whatever base_url
they configured. Same multi-tenant owner-scoping class already fixed for
companion/models, /api/v1/chat (#870, #1045), session create/switch-model
(#1093), and /api/research/start (#1099).
Extract `_owned_endpoint_by_url(db, base_url, owner)` which scopes the match via
the shared owner_filter helper (own rows + legacy null-owner shared rows),
mirroring session_routes._owned_endpoint. A scoped miss copies no key (the
comparison session simply carries no borrowed credential). A null/empty owner
stays a no-op (single-user / legacy mode).
Add regression tests pinning the scoped match (cross-owner rejected, own-row
allowed, legacy shared-row allowed, no-match None, null-owner no-op).
Validate only token-supplied direct base_url values for API-token chat requests, while keeping admin-configured endpoints available for local/LAN providers.
Scope configured endpoint fallback selection to the API token owner, fail closed for unknown token owners, and preserve strict session ownership checks when resuming sessions from chat-scoped API tokens.
Add focused regression coverage for direct base_url SSRF rejection, configured endpoint fallback behavior, token-owner scoping, URL validation, and null-owner session/endpoint handling.
The llama.cpp serve auto-install built a bare `llama-cpp-python` in the Linux
source-build fallback and the Termux path, but the serve command runs
`python3 -m llama_cpp.server`, which needs the `[server]` extra. Because the
"already installed?" guard only checks `import llama_cpp` (a bare install
satisfies it), the missing extra was never added, so serving crashed with
`ModuleNotFoundError: No module named 'starlette_context'` (issue #730).
- Request the `[server]` extra in both the Termux direct install and the Linux
Python-bindings fallback (the Windows path already used `[server]`).
- Shell-quote the package spec in `_pip_install_fallback_chain` via `shlex.quote`
so the `[server]` brackets aren't treated as a bash glob; plain names unaffected.
Tests: tests/test_cookbook_helpers.py gains extras-quoting coverage and a
serve-runner regression guard.
tavily_search, serper_search and google_pse_search parsed response.json()
inside the network try block, which only caught httpx.RequestError and
RateLimitError. When a provider returned a non-JSON body (an HTML error page, a
truncated/empty body, a gateway 5xx), response.json() raised an UNCAUGHT
json.JSONDecodeError that aborted the search in the background — exactly the
'search engines other than SearXNG fail in the background' symptom.
brave_search already handles this correctly: it parses JSON in its own try
block and returns [] on json.JSONDecodeError. Mirror that in the other three
providers so a malformed provider response degrades to no-results instead of
propagating an exception.
Adds tests/test_search_provider_json.py: a non-JSON 200 body now yields [] for
tavily, serper, google_pse, and brave (the last guards the reference behaviour).
Co-authored-by: NubsCarson <nubs@nubs.site>
A header that declares an unknown or invalid MIME charset (e.g. a malformed
or spam Subject like =?x-unknown-charset?B?...?=) raised an uncaught
LookupError. bytes.decode(..., errors="replace") only handles byte-decode
errors, not codec *lookup* failures, so the "replace" safety net did not
apply.
_decode_header decodes Subject/From/To/Cc for the inbox list, single-message
fetch, and the background mail pollers (routes/email_routes.py,
routes/email_pollers.py, src/builtin_actions.py), so a single bad message
could crash the whole inbox render or the poller loop.
Wrap the per-part decode in try/except (LookupError, ValueError) and fall
back to utf-8/replace. Valid charsets (utf-8, iso-8859-1, ...) are unchanged.
Adds tests/test_email_decode_header.py — the unknown-charset case fails
before this change and passes after.
Events are stored with a naive (UTC) dtstart, but standard .ics exporters
(Google, Apple, Outlook, Fastmail) write the recurrence bound as an absolute
UTC value, e.g. FREQ=DAILY;UNTIL=20240105T090000Z. dateutil refuses to mix a
tz-aware UNTIL with a naive DTSTART ("RRULE UNTIL values must be specified in
UTC when DTSTART is timezone-aware"), so _expand_rrule's except branch swallowed
the ValueError and silently downgraded the event to non-recurring — every
occurrence after the first vanished from the calendar.
When dtstart is naive, strip the trailing Z from UNTIL so it matches the naive
DTSTART before parsing. No effect on tz-aware dtstarts or naive-UNTIL rules.
Adds tests/test_calendar_rrule_until_utc.py — a daily series bounded by a UTC
UNTIL expands to all 5 occurrences (fails before: returns 1, non-recurring).
Co-authored-by: NubsCarson <nubs@nubs.site>
* fix: match skill tags as whole tokens, not substrings, in retrieval
* test: skill tag matching uses whole tokens, not substrings
* test: give skill fixtures status=published so they reach the scoring path
parse_markdown_to_values — the read-back path for export-pdf, the export
preview, and prepare-signed-reply — matched the bold field label with [^*]+, so
it could not match a label containing '*' (the near-universal required-field
marker: "Email *", "State *", "Signature *"). The value then stayed empty, so
the exported PDF and the signed-reply attachment came out blank for that field
with no error — a whole form of required fields could export completely empty.
Match the label non-greedily (.+?) so '*' in labels is tolerated while still
splitting at the first ':**' / '**[', which also preserves a value that itself
contains ':**'.
Adds tests/test_form_markdown_roundtrip.py (render -> parse roundtrip): asterisk
text/choice/signature labels survive (fail before, pass after); plain labels and
colon-bearing values are unaffected.
Co-authored-by: NubsCarson <nubs@nubs.site>