Promotes dev through PR #3443 and includes the changes already on main since
v0.1.46. See the full change inventory.
The release also removes the inactive workflow-run checkout path from backend
CI and fixes whitespace flagged by pre-commit.
Before upgrading: Use Python 3.12 or later. Back up persistent data and stop
API and worker writers before starting the upgrade. Production installs should
check authentication secrets, PostgreSQL settings and outbound allowlists.
Review the WebSocket credential changes and the keyword/tag migration notes
below. Clients using deprecated query credentials should move to a supported
header or the bearer subprotocol.
Added
- Agent Tasks can execute and track work. The encrypted message store introduced in 0.1.46 now feeds the executor. Tool calls carry a structured result envelope for inspection (#3042, #3231).
- Semantic search for Notes. Notes can be indexed by meaning for retrieval when embeddings are configured (#2862).
- Per-user limits for shared model use. Administrators can configure concurrency and usage quotas; usage recording now covers more LLM entry points. Personal provider keys are exempt from shared-provider quotas only when the request actually uses the caller's key (#3144, #3222, #3230, #3234, #3271, #3371, #3391, #3427).
- audio.cpp TTS private-network opt-in:
providers.audio_cpp.extra_params.allow_private_network(defaultfalse, operator config only) lets a remotebase_url(allow_remote_base_url) on a private or LAN address work without relaxing the global egress settings. It scopes requests to exactly the configured origin, public hosts included (any port; noEGRESS_ALLOWLISTentry needed under the strict profile); cloud metadata and link-local addresses stay refused (TASK-13950.11).
Changed
- Chat is easier to use on desktop and mobile. The WebUI and extension improve the composer, message views, conversation header, and history sidebar. Streaming updates and local persistence do less work; mobile composer controls use tighter defaults (#3251, #3255, #3309–#3313, #3316, #3372, #3400).
- Shared model credentials are no longer an implicit fallback on own-key paths. This covers Character Chat, evaluations, document and RAG operations, and Study assistants. Configure a personal provider key where these routes require one (#3423, #3426, #3427, #3439, #3444).
- Usage and pricing reporting. Accounting now captures more streaming completions, includes cache tokens where supported, and improves pricing tier resolution and Gemini pricing coverage (#3286, #3306, #3346, #3350, #3413, #3422).
- Runtime dependencies. Python 3.12+ remains required. This release includes the Uvicorn 0.54.0 and tinycss2 1.5.1 updates already on main (#3327, #2977).
- PostgreSQL connection pooling: PostgreSQL deployments, including the production Docker image, now always use the bounded
psycopg_poolpool (pg_pool_sizedefaults to 20; a checkout waits 30 s, then raisesPoolTimeout). The unbounded fallback pool used when psycopg-pool was missing is removed, and psycopg-pool is now a core dependency. A connection whose tenant scope cannot be applied is discarded and the request fails, instead of proceeding with another account's session settings (TASK-13950.3). - Production detection is unified; upgrade warning.
ENVIRONMENT,APP_ENV,DEPLOYMENT_ENV,FASTAPI_ENV,TLDW_ENVorENVset toprod,productionorlive(any case) now marks the runtime as production everywheretldw_production=truedid, and the reverse. An install that sets, for example,ENVIRONMENT=productionorAPP_ENV=productionwithouttldw_productionwill now refuse to start on a weak or default secret: aSINGLE_USER_API_KEYthat is a placeholder, shorter than 24 characters or not in the server-generatedtldw_<kid>.<secret>format (unlessTLDW_ALLOW_LEGACY_SINGLE_USER_KEY=true), the templateJWT_SECRET_KEY, or SQLite underAUTH_MODE=multi_user. Set strong secrets before upgrading (python -m tldw_Server_API.app.core.AuthNZ.initialize). The same marker also turns on the other production-only guards: required connector-secret encryption, stricter RAG guardrails, no raw-SQL RAG fallback, fatal evaluations-migration failures, and Resource Governor enforcement for embeddings: withRG_ENABLED=false, any production marker now makes importing the embeddings module fail (TASK-13950.6). - Test seams are refused in production. API server startup now fails in a production-like runtime when any test seam is set:
TEST_MODE,TESTING,TLDW_TEST_MODE,TLDW_ALLOW_TEST_FALLBACK_KEYS,SINGLE_USER_TEST_API_KEY,E2E_TEST_BASE_URL,MINIMAL_TEST_APP,JWT_SECRET_TEST_KEY,ENABLE_ADMIN_E2E_TEST_MODE,EVALUATIONS_TEST_DB_PATH,JOBS_TEST_NOW_EPOCHor anyRG_TEST_*.PYTEST_CURRENT_TESTno longer unlocks the public test JWT secret or the test API key in production (TASK-13950.6). - Media and Prompts keywords are case-insensitive and keep their display casing. "Foo" and "foo" are one keyword, stored with the first-seen casing (Media no longer lowercases keywords on write), and lookups also fold ß/SS, accented capitals and NFC forms. On PostgreSQL, Media DB startup merges existing case-variant keyword rows into the earliest one (links move to it; the others are soft-deleted with
merge_parent_uuidset) and then enforces a uniqueLOWER(keyword)index. The PostgreSQL Media keyword vocabulary is shared, so the first user's casing becomes every user's display casing. Ingestion keeps processor-extracted keyword casing, and the Prompts keyword endpoints return stored casing instead of lowercase (TASK-13950.5). - Collections (reading items) and Watchlists source tags are case-insensitive per user and keep their display casing. Tags are no longer lowercased on write: "Foo" and "foo" are one tag, stored with the first-seen casing, and tag lookups, filters, OPML export and content-alert source constraints also fold ß/SS, accented capitals and NFC forms. On first open after upgrade (SQLite and PostgreSQL), each user's case-variant duplicate tags are merged into their earliest row (tagged items and sources move to it, the duplicates are deleted), and a unique case-insensitive index per user is enforced. Tags are never merged across users. Existing tags keep their stored lowercase names (TASK-13950.5).
- Admin webhooks,
github_create_issueand GitHub repository sync follow the general egress policy: ports other than 80, 443 and 8080 are refused unlessWORKFLOWS_EGRESS_ALLOWED_PORTSlists them, so an admin-webhook receiver on a port such as :8443 or :5678 needs it set. WhenENVIRONMENT,APP_ENVorENVisprodorproduction, the strict profile denies every host not inEGRESS_ALLOWLISTorWORKFLOWS_EGRESS_ALLOWLIST, so production deployments without an allowlist must add the receiver hosts andapi.github.com(TASK-13950.11). - audio.cpp TTS
base_urlcredentials: a loopbackbase_urlwith userinfo (user:pass@) has been refused since the audio.cpp client moved tocore.http_client, as a rawValueError. It, and one withallow_private_networkon, now fails with a clearTTSValidationError. A remotebase_urlwithout the opt-in still sends its userinfo as Basic auth, as before. Abase_urlwith an invalid port is now aTTSValidationErrortoo (TASK-13950.11). - Deprecated: ACP and Prompt Studio WebSockets (
/api/v1/acp/multiplex,/api/v1/acp/sessions/{id}/stream,/api/v1/acp/sessions/{id}/ssh,/api/v1/prompt-studio/ws,/api/v1/prompt-studio/ws/{project_id}): credentials in the?token=/?api_key=query and in thex-api-key, <key>subprotocol still work but log a deprecation warning; sendAuthorization: Bearer,X-API-KEY, or thebearer, <token or key>subprotocol instead. These routes now authenticate through the core WebSocket resolver, so revocation, API-key scope and single-user rules match HTTP, which narrows two things ACP did on its own: theAPI_KEYenvironment variable is no longer accepted as a second single-user key next to a differentSINGLE_USER_API_KEY(it still applies whenSINGLE_USER_API_KEYis unset), and a?token=&api_key=pair is resolved byget_auth_principal, the shared HTTP resolver, as it resolves both headers: the JWT first, and the API key only when the JWT is refused with 401. It also widens one:SINGLE_USER_TEST_API_KEYis now accepted wherever HTTP accepts it, under pytest or withTEST_MODE,TLDW_TEST_MODEorTESTINGset (ACP required an explicit pytest runtime). Only a Bearer-schemeAuthorizationheader counts as a WebSocket credential, so a reverse proxy'sAuthorization: Basicon the upgrade no longer hides the query or bearer-subprotocol credential. An API key without the route's scope now closes with 4403 instead of 4401, and the ACP clients no longer retry 4403 (TASK-13950.7). - Deprecated: the Watchlists run stream WebSocket (
/api/v1/watchlists/runs/{run_id}/stream) still takes?token=/?api_key=, which the WebUI's watchlists-stream client sends, but logs a deprecation warning. It and the Sandbox run stream (/api/v1/sandbox/runs/{run_id}/stream) now authenticate through the core WebSocket resolver, so they accept thebearer, <token or key>subprotocol (and echobearer) and match HTTP: a JWT for a missing or inactive user is refused (the old copies checked only its signature and revocation), a JWT is not accepted in single-user mode,SINGLE_USER_TEST_API_KEYis accepted under pytest or withTEST_MODE,TLDW_TEST_MODEorTESTINGset (they required an explicit pytest runtime), a Watchlists?token=&api_key=pair falls back to the key when the JWT is refused with 401, and an API key without thereadscope closes with 4403 instead of 4401. On Watchlists a header credential (Authorization: Bearer,X-API-KEYor the bearer subprotocol) now takes precedence over?token=/?api_key=, and the query is not read; the old code took?token=instead of theAuthorizationheader and?api_key=instead ofX-API-KEY, then verified that token, from either place, ahead of any API key, so?api_key=lost to anAuthorization: BearerJWT (TASK-13950.7). - Deprecated: the Persona stream WebSocket (
/api/v1/persona/stream) still takes?token=/?api_key=, which the WebUI's persona-stream client falls back to for a credential that is not subprotocol-safe, but logs a deprecation warning once per connection; the voice assistant WebSocket (/api/v1/voice/assistant) likewise still reads?token=, with the warning, when itsauthframe carries no token (the voice SDKs send the token in both). Both now verify credentials through the core WebSocket resolver, so revocation, API-key scope and single-user rules match HTTP. For the voice assistant that means a JWT for a missing or inactive user is refused (it checked only the signature and revocation), a JWT is not accepted in single-user mode, andSINGLE_USER_TEST_API_KEYis accepted wherever HTTP accepts it, under pytest or withTEST_MODE,TLDW_TEST_MODEorTESTINGset: the shared gate replaces voice's own pytest-only one. Itsauthframe token is verified alone, so a handshakeX-API-KEYcannot stand in for a refused frame token. On Persona a?token=&api_key=pair, or a header token besideX-API-KEY, falls back to the key only when the JWT is refused with 401 (it fell back on any refusal), and a header credential (Authorization: Bearer,X-API-KEYor the bearer subprotocol) now takes precedence over the query, which is then not read; the old code read?token=ahead of the bearer subprotocol, and fell back from a refused header token to?api_key=. An API key without thereadscope still closes with 4401 on both. The voice assistant'sauthframe protocol and close codes are unchanged (TASK-13950.7). - The audio streaming WebSockets (
/api/v1/audio/stream/transcribe,/stream/tts,/stream/tts/realtime,/chat/stream) and realtime speech (/api/v1/audio/realtime,/v1/realtime) now authenticate through the core WebSocket resolver; the{"type": "auth", "token": ...}first frame and the single-user cookie, whose clients may sendconfigfirst, work as before; with the cookie, anauthframe without a token now keeps the cookie session (it closed 4401). A handshake credential (Authorization: Bearer,X-API-KEY, or thebearer, <token or key>subprotocol, now accepted and echoed) is verified before the socket is accepted, so a refused one now fails the WebSocket handshake before accept (uvicorn answers HTTP 403 with no close code; the Starlette test client reports close 4401) instead of closing 4401 after accept. Without one the socket is accepted only to read the auth frame: a first frame that is not an auth frame now closes 4400 instead of 4401 (no shipped client checks the code), a first frame over 16,384 characters is refused 4400 without being parsed, and nothing is read after it. Verification matches HTTP: an API key is accepted in the auth frame and as a bearer in multi-user mode (only a JWT was), a JWT for an inactive user and a service token are refused,SINGLE_USER_TEST_API_KEYfollows the shared test gate, a single-user key from outsideSINGLE_USER_ALLOWED_IPScloses 4401 instead of 1008, an unavailable credential store closes 1011, and the caller's organizations and teams come from HTTP's membership check rather than unchecked JWT claims, so team-scoped BYOK credentials now apply on/chat/streamand the TTS streams as they do over HTTP. A scoped JWT's or multi-user key's allowed endpoints and paths (4403) and call quota (4003, or 1008 withAUDIO_WS_QUOTA_CLOSE_1008) are unchanged. Deprecated:?token=, still read only withAUDIO_WS_ALLOW_QUERY_TOKEN_AUTHset, logs a deprecation warning and is verified likeAuthorization: Bearer, so it also takes a JWT in multi-user mode (it took only an API key);?api_key=is not read, with or without the setting (TASK-13950.7). - The voice assistant WebSocket (
/api/v1/voice/assistant) reads itsauthframe through the same core first-frame resolver. Its frames and close codes are unchanged, except that a first message that is not JSON now getsauth_error"Expected AUTH message" and 4400 (it closed 1011), and anauthframe without a token also accepts a credential on the upgrade request before the deprecated?token=;?api_key=is not read, as before (TASK-13950.7).
Fixed
-
Audio Studio and workspace links: Audio preview and download validate media ticket destinations. Workspace diagnostics links only to the supported runtime configuration page.
-
Recoverable chat history and cancellation. Stop works before the first token, and permanent history erasure coordinates recoverable copies so deleted data cannot reappear through legacy history (#3368, #3431).
-
Authentication and administration. Sign-in links are consumed atomically, verification-email diagnostics omit secrets, and admin alert history reports the correct results (#3428–#3430).
-
PostgreSQL and background jobs. ChaChaNotes operations keep their intended transaction and tenant scope across requests, WebSockets and background work; job completion verifies current ownership before persisting results (#3082, #3092, #3293, #3295, #3297, #3312, #3323).
-
Audio processing. Audio jobs renew leases and preserve retry state, while model adapters coordinate loading and cancellation to keep in-use engines available (#3257, #3259, #3260, #3262, #3274, #3280, #3281, #3291, #3303).
-
RAG and evaluations. A streamed RAG answer is emitted once, and the benchmark judge receives the inputs it needs to score the answer (#3445, #3447).
-
Workflow and media recovery. Workflow cancellation stops sibling steps and cleans up detached work; media and knowledge flows improve error handling, state recovery, and source navigation (#3194, #3196, #3204, #3290, #3298, #3308).
-
The TTS realtime, voice chat and TTS streaming WebSockets re-raise the server's own cancellation when it lands in their cleanup; they raised a new
CancelledErrorthat escaped the host's cancel scope, which the test client reported asconcurrent.futures.CancelledErrorat teardown (TASK-13950.7). -
Workflows
podcast_rss_publishSSRF: the remote seed-feed fetch now goes throughcore.http_client, so the egress policy (including private and link-local addresses) is enforced on the URL and every redirect hop, and the body is capped at 20 MiB. A refused URL returnsremote_seed_blocked_egress, and a non-XML seed body returnsinvalid_feed_xmlinstead of raising. The 15 s remote-seed timeout now bounds the whole download, not each socket operation (TASK-13950.11). -
ACP MCP streamable-HTTP transport SSRF: requests now go through
core.http_client, so the egress policy is checked on the address the connection uses (a DNS answer that changes to loopback aftervalidate_mcp_http_urlis refused) and env proxies are no longer read. Redirects are still not followed. Withallow_private_network(orACP_ALLOW_PRIVATE_MCP_HTTP) the configured private origin is admitted on any port, but cloud metadata and link-local addresses are refused. Without it, the default egress policy now applies, including its port list (80, 443, 8080;WORKFLOWS_EGRESS_ALLOWED_PORTS) and, in production, the strict profile's host allowlist (EGRESS_ALLOWLIST) (TASK-13950.11). -
ACP MCP-over-SSE transport SSRF: the SSE stream and the JSON-RPC POSTs now go through
core.http_client(astream_sseandafetch), so the egress policy is checked on the address each connection uses and on every SSE redirect hop, and env proxies are no longer read. The SSE stream follows same-origin redirects only, so configured headers never reach another origin; POSTs still do not follow redirects. The private-network opt-in behaves as for the streamable-HTTP transport and applies only to the configured SSE origin, never to a server-supplied POST URL on another origin.astream_ssegains theconfigured_endpointscope thatafetchalready takes, and anallow_cross_origin_redirectsswitch (TASK-13950.11). -
MCP
web.fetchSSRF: the default fetch client now streams throughcore.http_client.astream_bytes, so the egress policy is enforced on the address the connection uses (a DNS answer that changes to a private address after the outbound-policy check is refused asoutbound_policy_denied) and env proxies are no longer read. The outbound policy and robots check on every redirect hop, the byte cap and the timeout are unchanged (TASK-13950.11). -
Admin webhook delivery SSRF:
AdminWebhooksService.deliver(incidentnotify-webhooks) now sends throughcore.http_client.afetch, so every attempt checks the egress policy (loopback, private and link-local addresses, operator allow/deny lists) and pins the resolved IP, and env proxies are ignored. A refused URL is logged as a failed delivery with no retries. Signing, headers, backoff, timeouts and the no-redirect rule are unchanged (TASK-13950.11). -
Workflows
github_create_issueegress: the issue POST now goes throughcore.http_client.apost, so the egress policy (including the operator allow/deny lists and a private or link-local DNS answer forapi.github.com) is checked before the token is sent, the resolved IP is pinned, and env proxies are ignored. A refused request returnsgithub_api_blocked_egress. Valid requests and responses are unchanged (TASK-13950.11). -
Ingestion Sources GitHub sync SSRF: remote
git_repositorysources fetch the GitHub API throughcore.http_client.stream_responseinstead ofurlopen. Every hop, redirects included, must stay onhttps://api.github.comand pass the egress policy, and its resolved IP is pinned, so a redirect can no longer carry the access token to another host or a private address. Env proxies are ignored, and the blob size cap still stops the read while streaming. A refused or failed request now fails as aValueError, which the sync records as a failed item or a failed job as before. A control character in a storedrepo_urlnow fails that one sync job instead of stopping the ingestion worker for every user (TASK-13950.11). -
Standalone HTML generation egress: provider completions now go through
core.http_client(astream_bytes) instead of a barehttpx.AsyncClient, so a provider host that resolves to a private address is refused before any request and the loopback llama.cpp/Ollama adapters are allowed only on their own configured origin;astream_bytesgainedconfigured_endpoint=(TASK-13950.11). -
audio.cpp TTS client SSRF: requests now go through
core.http_client: one attempt, no redirects, and the egress policy checked on the address the connection uses. A loopbackbase_url(the default and the managed sidecar) is admitted for its exact origin. Withallow_remote_base_urla remote server now gets the default egress policy, so a private, link-local or metadata address is refused (TTSConfigurationError,EGRESS_DENIED) unless the operator's egress settings allow it, only ports 80, 443 and 8080 are allowed unlessWORKFLOWS_EGRESS_ALLOWED_PORTSlists others, and under the strict profile (ENVIRONMENT=prod) the host must be inEGRESS_ALLOWLISTorWORKFLOWS_EGRESS_ALLOWLIST(TASK-13950.11). -
Audio Studio ACE-Step egress: every ACE-Step request now goes through
core.http_clientinstead of a rawhttpx.AsyncClient, scoped to the exact originAUDIO_STUDIO_EXTERNAL_ENDPOINT_ALLOWLISTadmitted. A local ACE-Step server on a private address and any port still works, but an allowlisted host whose DNS answer is a cloud-metadata or link-local address is refused before anything is sent (ace_step_endpoint_refused_by_egress_policy, not retried), the resolved address is pinned, and env proxies are no longer read unlessHTTP_TRUST_ENVis set. Redirect handling, request bodies, the bearer header and timeouts are unchanged (TASK-13950.11). -
Qwen3-ASR vLLM upload egress: the unplanned vLLM branch (
[STT-Settings].qwen3_asr_vllm_base_url) now uploads throughcore.http_clientlike the planned branch, scoped to the configured server's exact origin. A local vLLM server on a private address and any port still works; a cloud-metadata or link-local DNS answer is refused before the audio is sent, the resolved address is pinned, a URL with userinfo is refused, and env proxies are no longer read unlessHTTP_TRUST_ENVis set. The request, its 300 s timeout and the no-redirect rule are unchanged (TASK-13950.11). -
audio.cpp STT certificate pins: with the aiohttp transport, the per-transcription session now comes from the new
core.http_client.create_aiohttp_session, soHTTP_CERT_PINSpins for the audio.cpp host are enforced and the default User-Agent and TLS floor match the shared session. Requests were already egress-checked throughafetch(TASK-13950.11). -
Managed OCR readiness probe egress:
wait_for_managed_http_ready(llama.cpp and ChatLLM OCR) now probes throughcore.http_clientinstead ofhttp.client, scoped to the exact configured origin (*_OCR_HOST/*_OCR_PORT, the OCR profile orCHATLLM_OCR_HEALTHCHECK_URL). A local server on any private address and port is still probed, redirects are still not followed, and a host whose DNS answer is a cloud-metadata or link-local address is refused at once instead of being polled. The probe paths, interval and per-probe timeout are unchanged (TASK-13950.11). -
http_client pinned connections fall back through the vetted addresses: a connection pinned to an egress-approved address now tries the next approved address from the same DNS answer when the connect is refused or times out, instead of failing. A configured
localhostendpoint that resolves to::1first therefore reaches a server listening only on 127.0.0.1 again, on the httpx and aiohttp paths, streams, downloads and the certificate-pin check. Nothing is re-resolved, Host and SNI are unchanged, an address the policy did not approve is never tried, and at most 4 addresses are tried. Every try but the last caps its connect phase atHTTP_CONNECT_TIMEOUT(default 5 s) and the last uses the request's own connect timeout, so a host whose addresses all fail costs at most 3 ×HTTP_CONNECT_TIMEOUTplus that timeout. The certificate-pin preflight shares oneHTTP_CONNECT_TIMEOUTbudget across addresses, so it blocks no longer than before.afetch,apostandfetch(httpx) with notimeoutnow use the client's configured timeout instead of running with none; passtimeoutexplicitly to override it (TASK-13950.11). -
The Persona stream WebSocket accepts the single-user API key (
X-API-KEY,Authorization: Bearer, the bearer subprotocol or the deprecated?api_key=); it checked the key with the multi-user API-key manager and refused it on every channel (TASK-13950.7). -
WebSockets that echo the bearer subprotocol (Persona, Workflows, Sandbox, Watchlists, ACP, Prompt Studio) echo the marker as the client offered it, so a client that sends
Bearerrather thanbearercompletes a browser handshake (TASK-13950.7).
Removed
- ACP and Prompt Studio WebSockets no longer accept
Authorization: X-API-KEY <key>or theapi-key, <key>subprotocol; no client sent either (TASK-13950.7). - The Sandbox run stream WebSocket no longer reads a credential from
?auth_token=or, with signed URLs off,?token=; no client sent either. A signedlog_stream_url'stokenandexpare unchanged (TASK-13950.7). - The Persona stream WebSocket no longer accepts MCP-issued JWTs (from
/api/v1/mcp/auth/token), which it tried when AuthNZ refused a token and which carry no AuthNZ revocation check; no client sent one. Use an AuthNZ access token or an API key (TASK-13950.7).