
### 🔒 Security

- Extended the cloud-metadata IP absolute-block in `NotificationURLValidator` to Apprise plugin schemes (signal, gotify, ntfy/ntfys, mattermost, rocketchat, matrix, json, xml, form, mailto). Previously only the http/https branch ran the IMDS guard, so a notification URL like `signal://169.254.169.254/...` would round-trip to Apprise even though Apprise translates it into a plain HTTP request against that host. LAN/loopback reach for self-hosted plugin endpoints (the #4006 use case) is unchanged.
- Fix Google Gemini API key leak in `list_models_for_api` error log. The
  URL constructed at request time embeds the key as a `?key=...` query
  parameter (per Google's documented API), so when the underlying request
  failed, the `requests` exception message — and therefore the
  `logger.exception(...)` traceback — contained the full URL with the key.
  The except handler now redacts the key from the message and uses
  `logger.warning` (no exception chain).

### 🐛 Bug Fixes

- **JavaScript rendering disabled by default in the production Docker image (#3826).** The headless-browser fallback in the content fetcher (Crawl4AI/Playwright) was previously attempted on every fetch even though the default Docker production image ships without a Chromium binary - each attempt failed loudly and contributed to the memory growth reported in issue #3826. A new `web.enable_javascript_rendering` setting (default `false`) gates the fallback. In limited (mostly accidental) internal benchmark comparisons - between dev instances that happened to have Chromium installed and routine Docker runs that did not - JS rendering did not measurably improve research quality, and most regular benchmark runs are on Docker without Chromium anyway, so the change does not regress observed quality. To enable: install Chromium in the runtime environment (`playwright install --with-deps chromium`) and toggle the setting in the UI. ([#3826](https://github.com/LearningCircuit/local-deep-research/pull/3826))
- **Embeddings now work against OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp).** Previously the OpenAI embeddings provider only listed itself as available when an API key was configured, and the relevant `embeddings.openai.*` settings were not registered for the UI to surface — so users who ran an OpenAI-compatible local server had no way to point the embeddings tab at it. The provider now also reports available when only `embeddings.openai.base_url` is set, substitutes a placeholder API key for keyless endpoints (matching the LM Studio LLM provider's behavior), and ships a new `settings_openai_embeddings.json` defaults file that registers `embeddings.openai.api_key`, `embeddings.openai.base_url`, `embeddings.openai.model`, and `embeddings.openai.dimensions`. The model-list lookup now routes through the configured `base_url` so model discovery also targets the local server. ([#3883](https://github.com/LearningCircuit/local-deep-research/pull/3883))
- **"Start Research" button no longer silently does nothing for users with a non-multiple-of-512 context window value.** The hidden `context_window` input on the research form had a `step="512"` constraint, so any stored value not aligned to that grid (e.g. `25000`) failed HTML5 form validation. Because the input lives inside a `display:none` container that is only shown for local providers, the browser could not focus the invalid field to report the error, so submission was aborted with no visible message and no log line — the click appeared to do nothing. Relaxed the step to `1` so any in-range integer is accepted; the value is still bounded by `min`/`max` and persisted unchanged. ([#3909](https://github.com/LearningCircuit/local-deep-research/pull/3909))
- **Chinese/Japanese/Korean text now renders in exported PDFs.** The default PDF stylesheet hard-coded a Latin-only font stack, so any CJK characters in the research result were dropped silently from the download even though they displayed correctly in the browser. The minimal CSS now includes a broad CJK fallback chain (Noto Sans CJK, PingFang, Hiragino, Apple SD Gothic Neo, Microsoft YaHei/JhengHei, Yu Gothic, Malgun Gothic, SimSun) covering Windows, macOS, and Linux desktops out of the box, and the Docker image now installs `fonts-noto-cjk` so the slim base image has glyph coverage. Linux pip/server installs still need a CJK font package on the host — see [install-pip.md](../install-pip.md) and the [FAQ](../faq.md#chinese--japanese--korean-text-is-missing-from-exported-pdfs) for the per-distro commands. ([#4055](https://github.com/LearningCircuit/local-deep-research/pull/4055))
- Cross-engine filtering now keeps LLM ranking fallbacks within the subset of results the model actually evaluated, preventing unevaluated search results from leaking into downstream synthesis.
- Fix file-descriptor leak (`anon_inode:[eventpoll]`) in `_close_base_llm` when invoked while an asyncio loop is running. The previous code path skipped the async-client close in that case and documented that the "loop owner" would close instead — but no loop-owner cleanup actually exists in the project, so the inner `httpx.AsyncClient` (and its `epoll_create` FD) was silently abandoned. The same skip-path fires under the default `langgraph-agent` strategy because LangGraph dispatches some tool steps via asyncio internally, so close calls reached from a sync `finally` can still land inside a live loop. Cleanup now runs in a brief daemon thread that owns its own loop, so `asyncio.run(aclose())` works regardless of the caller's loop state; a bounded 5-second `join` keeps it from holding up shutdown if the Ollama server is unresponsive. This is the gap left by #3855's coverage of #3816 — same leak class, different code path.

  Also fixes a smaller secondary leak in the Docker `HEALTHCHECK`: `urllib.request.urlopen('http://localhost:5000/api/v1/health')` had no `timeout=` argument, so when the app slowed down (FD exhaustion or otherwise) Docker's 10s healthcheck timeout SIGKILL'd the `sh -c` wrapper but the python child got reparented to PID 1 and hung forever, each contributing a `pidfd` and a TCP socket against the app. Adding `timeout=8` lets the child return/raise before Docker's wall so it exits cleanly and gets reaped.

### 📝 Other Changes

- Added a "Testing a Release Candidate (Prerelease Image)" subsection to the developer guide (`docs/developing.md`) covering both the floating `:prerelease` and immutable `prerelease-vX.Y.Z-<sha>` Docker Hub tags, with a side-by-side compose snippet that runs the RC on port 5001 with isolated volumes so a broken migration cannot affect a production instance.
- Pool-dispose failures in the periodic WAL/SHM handle-release workaround (ADR-0004) now log at ``WARNING`` instead of ``DEBUG`` so silent drift becomes visible. Documented in ``docs/CONFIGURATION.md`` that ``LDR_APP_DEBUG=true`` also enables Loguru ``diagnose=True``, which can materialise sensitive locals into exception traces — do not enable in production.
- Refactored environment settings to use specialized exception classes, improving error observability and alignment with `TRY003` standards.
