Stamp OpenID session token state with the LibreChat user id, OpenID subject,
tenant id, and normalized issuer when tokens are stored.
Fail closed before OBO inline token reuse/refresh when the session token
identity does not match the current authenticated identity, preventing a stale
or mixed Express session from supplying another user's upstream assertion.
Also validate the normal /api/auth/refresh session-token reuse shortcut against
the signed marker-cookie user before returning cached session tokens.
Note: sessions created before this change carry no identity stamp and are
treated as a mismatch. This is self-healing — the reuse path forces a full IdP
refresh (which re-stamps the session) and the OBO path throws, surfacing as a
one-time re-authentication for active OBO users at deploy time. The session
re-stamps within one session lifetime (SESSION_EXPIRY, default 15 min).
Extract the shared OpenID refresh/user-resolution flow in AuthController
so the normal refresh path and bridge-recovery retry use the same grant,
claims, issuer, user lookup, and diagnostic logging code.
Preserve the existing path-specific behavior: the normal path still owns
migration updates and 401 login redirects, while the bridge retry still
falls through to the existing 403 invalid-token response.
Add a bridge-recovery guard that rejects retry results whose resolved
user id differs from the signed openid_user_id cookie before issuing
tokens or re-storing the grace bridge. Cover both the successful
matching-user recovery and the mismatched-user rejection.
Add an OBO-specific guard before MCP tool execution that requires the
effective invocation user and captured request user to both have ids and
to match. This prevents OBO tool calls from falling back to a separate
configurable.user_id identity after request-bound OBO context has already
been captured.
Keep the existing user id fallback behavior for non-OBO MCP calls.
Tests cover mismatched OBO users, missing user ids, and the matching-user
path ignoring a conflicting configurable.user_id.
After successful bridged refresh recovery, re-store the stale-cookie bridge
with a short grace TTL instead of deleting it immediately. This lets parallel
/api/auth/refresh requests that already sent the stale browser cookie recover
before they can observe the first response's Set-Cookie.
Retarget the bridge to the refresh token returned by the bridged retry so
B-to-C refresh-token rotation remains recoverable. The grace TTL is parsed with
math() and defaults to 60s, which shrinks the replay window from the original
REFRESH_TOKEN_EXPIRY bridge lifetime to the short recovery grace period.
Remove the now-unused explicit bridge delete path from the service and
data-schemas method surface. Add regression coverage for grace re-store,
identity symmetry, retry failure behavior, and same-key upsert replacement.
Sync OpenID refresh-token cookie/bridge state before persisting the
session so a transient session-store failure cannot lose an IdP-rotated
refresh token.
Also trigger sync when the session refresh token differs from the
browser refresh-token marker, not only when the current grant rotates
the token. This lets later writable refreshes repair stale browser
cookies left behind by SSE refreshes.
Route refresh bridge identity through the shared identity helper with
the threaded OBO identity context, falling back to request/user context
when needed.
Add regression coverage for session-save failures, stale browser cookie
repair, non-writable bridge storage, and shared-helper identity fallback.
Add shared auth identity helpers for app user ids, OpenID subjects,
tenant ids, and normalized OpenID issuers.
Thread a non-placeholder-visible OBO identity context from the real
request user through MCP connection, tool-call, reinit, and refresh
paths. Keep tenantId and openidIssuer out of createSafeUser so MCP
user placeholders do not expose those fields.
Scope OBO token cache and in-flight exchange keys by tenant, issuer,
OpenID subject, scopes, and a SHA-256 hash of the upstream assertion.
This prevents cross-tenant/cross-issuer collisions and avoids reusing
tokens minted from stale rotated assertions.
Use the shared identity helpers for OpenID refresh-flight keys and
refresh-token bridge recovery records so related OBO refresh paths share
the same identity normalization rules.
The helper is intended for auth-boundary and credential-cache code, not
as a blanket replacement for ordinary app user id ownership checks.
Allow failed OpenID refresh flights to be reclaimed immediately instead of pinning transient errors.
Preserve the browser refresh-token marker when joined refreshes hydrate session tokens from a shared flight result.
Stabilize AuthService tests by isolating mocked module imports from prior suites.
Add RefreshTokenBridge to the tenant-isolation coverage allowlist because
refresh bridge lookups run during unauthenticated OpenID refresh recovery.
The controller first recovers user context from the signed OpenID marker
cookie, then the bridge methods apply explicit user and tenant filters.
Ambient tenant isolation would bind this recovery path to request-local
tenant context that is not available at the point the stale cookie is being
resolved
Make refresh-token bridge cleanup best-effort after a bridged OIDC
refresh succeeds. A transient delete failure now logs a warning but does
not convert the already-refreshed session and cookies into a 403 response.
Track the refresh token last written to the browser cookie separately
from the current session refresh token. When inline OIDC refreshes rotate
tokens without a writable response, keep bridging from the browser-stale
token directly to the latest session token.
Treat missing or non-cookie responses like headers-sent streaming
responses during inline OIDC refresh. When the IdP rotates the refresh
token and cookies cannot be written, persist a recovery bridge so a later
/auth/refresh can recover after session expiry.
Key the process-local OIDC refresh coalescing by the current session
refresh token, matching the Mongo-backed flight key. This prevents a
request with a newly rotated token from joining an older pending refresh
and inheriting its failure/result.
* 🛡️ fix: Prevent ReDoS in YouTube URL extraction for URL Context
The YouTube detection/strip regexes ran as a single global pass over
authenticated, user-controlled chat text. The engine could restart at every
`youtube.com/watch?` occurrence and the lazy `\S*?&` rescanned the rest of a
long non-whitespace token each time, giving quadratic CPU behavior that blocks
the Node event loop (DoS) for Google/Vertex agents with url_context enabled.
- Tokenize on whitespace and skip tokens longer than a real URL, and cap the
total text scanned, so work is bounded to O(n). URLs never contain whitespace,
so per-token matching is equivalent.
- Replace the lazy unbounded `(?:\S*?&)?` with the delimiter-bounded
`(?:[^\s&]*&)*` (no behavior change for real URLs).
- Apply the same discipline to the strip path.
- Add ReDoS regression tests; a 3MB crafted input now completes in <10ms.
* 🛡️ fix: Bound the YouTube strip scan by the same total budget
Address Codex P1: the strip path applied only the per-token cap, so a valid URL
followed by many sub-cap malformed tokens still regex-scanned the entire message
(~1s on 3MB). Injected ids only come from the first MAX_YOUTUBE_SCAN_CHARS
(extraction's cap), so a link beyond that is never in injectedIds anyway; cap the
strip scan at the same budget and leave the tail verbatim. 3MB PoC: ~1s -> ~14ms.
* 🧬 fix: Make YouTube URL matching linear instead of capping the scan
The previous fix bounded the scan with per-token + total-scan caps, but the
total-scan cap discarded content: a URL near the end of a long prompt was missed
(extraction sliced to 100k), and large prepended file/quote context exhausted the
strip budget before the real URL (strip skipped it). Codex round 2 (P2 x2).
Replace the backtracking-prone matcher with a linear one: a single regex captures
host + path/query (greedy `[^\s]*`, bounded `{1,63}`/`{0,10}` subdomain repetition,
no lazy/ambiguous quantifier), and the video id is parsed from the capture
afterwards. This is O(n) over arbitrary input, so the scan caps (and the content
they discarded) are removed entirely. Extraction and stripping now scan the whole
message linearly.
Benchmarks (no caps): 3MB attack token ~3ms, 3MB many-token ~4ms, valid URL at end
of 3MB found in ~18ms. Adds regression tests for long-prompt extraction and
stripping past large prepended context.
* 🔡 fix: Match adjacent + capitalized YouTube URLs after linear rewrite
Codex round 3 (regressions from the linear matcher):
- Stop the path capture at URL-list delimiters (`,` `)` `]` `<` `>`, none of which
occur in a real YouTube URL) so adjacent links in one token (comma-separated or
markdown `](url1)](url2)`) are matched separately instead of swallowed.
- Lowercase the path segment before matching route names, since the detection regex
is case-insensitive (`/WATCH?v=`, `/EMBED/`).
* 🔒 fix: Allowlist URL chars + bounded path parsing for YouTube matching
Codex round 4:
- Replace the path stop-char blocklist with an allowlist of characters that occur
in real YouTube URLs, so adjacent links separated by any prose delimiter
(`;`, `|`, etc.) are matched separately instead of swallowed.
- Parse the route with anchored, bounded regexes instead of `path.split('/')`, so a
malformed path of millions of slashes no longer allocates a huge array / blocks
the event loop. Also bounds the `v=` param read.
* 🎯 fix: Restrict YouTube matcher to recognized video routes
Codex round 5: a nested video URL inside an unrecognized YouTube URL
(`youtube.com/redirect?q=https://youtu.be/<id>`) was swallowed by the greedy
match and missed. Restrict the matcher to recognized single-video forms
(youtu.be/<id>, /(shorts|live|embed|v)/<id>, /watch?<query>) so an unrecognized
route doesn't match and the global scan continues into the nested link. Stays
linear (verified: 3MB redirect/slash/host floods all <25ms) and keeps the
allowlist tail so adjacent links still split. Adds nested-URL + unrecognized-route
regression tests.
* 🎬 fix: Find nested watch links + skip malformed v= duplicates
Codex round 6 (P3 watch-query edges):
- Drop `:` from the path allowlist. It never occurs in a real YouTube path/query,
but `://` of a nested URL does — so `watch?url=https://youtu.be/<id>` now stops
the watch match and the scan finds the nested link.
- Scan every `v=` param and return the first valid 11-char id, so a malformed
earlier `v=` (e.g. `watch?v=tooShort&v=<valid>`) no longer shadows a later valid one.
* 🧹 fix: Strip whole YouTube URL incl. colon-containing trailing params
Codex round 7: dropping `:` from the tail (round 6) made the strip path stop mid-URL
on a URL-valued param (`watch?v=<id>&next=https://example.com`), leaving `://example.com`
orphaned. Use a separate strip matcher whose tail re-includes `:` so the whole URL token
is removed, while detection keeps the `:`-excluded tail to still find nested video links.
Also corrects a stale "per-token cap" comment left over from the linear rewrite.
The "Add to chat" popup lingered over an empty caret after a selection collapsed through a path that fires no mouse/key event — most often a streaming markdown re-render replacing the selected text node. The selection state only updated on mouseup/dblclick/keyup/scroll/resize, so a silent collapse left the button stranded ("showing up with nothing selected").
Add a `selectionchange` listener that hides the popup the instant the selection collapses or empties. It only hides, never shows, so an in-progress drag-select still won't flicker the popup.
Adds an e2e that collapses the selection without a mouse event and asserts the popup disappears.
* 🛡️ fix: Guard Prompts and Mention popovers against empty-result navigation
* 🛡️ fix: Prevent Tab default and clear stale filter on empty popover close
* ✨ feat: Add Google url_context Param with Native YouTube Video Understanding
Mirror the web_search grounding wiring for a new Google/Gemini `url_context`
model param (resolves to the native `urlContext` tool). When enabled, YouTube
URLs in the latest user message are injected as Gemini video parts (fileData),
since the URL Context tool does not support YouTube.
* 🎞️ fix: Provider-aware YouTube injection limits for url_context
Address Codex review on the YouTube video-understanding path:
- Cap injected YouTube parts per request by provider/model (Vertex: 1; Gemini
Developer API: 10 on 2.5+, 1 on earlier models) so multi-link messages cannot
exceed the provider limit and get rejected.
- Set a video/mp4 mimeType on Vertex YouTube fileData (matching Vertex samples);
the Developer API still omits it.
* 🧩 fix: Round-trip url_context for Google-compatible custom endpoints
Add url_context to openAIBaseSchema so the per-chat value persists for custom
endpoints configured with customParams.defaultParamsEndpoint: 'google', matching
how web_search is already picked there.
* 🚦 fix: Gate url_context tool to Gemini 2.5+ models
Per Google's URL Context supported-models list (2.5+/3.x only), skip the native
urlContext tool on earlier models (debug-log + no-op) instead of sending it and
triggering a provider 400. This also gates the coupled YouTube video-understanding
injection to 2.5+, since it keys off the resolved urlContext tool.
* ✂️ fix: Strip YouTube URLs from urlContext text; keep url_context out of OpenAI schema
- Remove url_context from the shared openAIBaseSchema (revert): it is Google-only
and would otherwise leak as an unsupported param to OpenAI/Azure/OpenRouter
requests. On Google-compatible custom endpoints url_context is enabled via admin
addParams/defaultParams, same as web_search.
- When injecting YouTube video parts, strip the matched YouTube URLs from the prompt
text so the urlContext tool (which reads URLs from text and cannot fetch YouTube)
does not consume its URL budget on them. Non-YouTube URLs are left intact.
* 🎯 fix: Refine url_context model gating and YouTube injection edges
Address Codex round 4:
- Exclude non-text modality variants (image/live/tts) from URL Context support,
mirroring the Google tool-combination modality exclusion.
- Use the resolved run model (model_parameters.model) for YouTube injection limits
instead of the saved base model.
- Strip only the YouTube links actually routed to video (id-aware); keep over-limit
links in the text so the model can still reason about them.
- Keep timestamped YouTube links (?t=/&start=) in the text so the moment cue survives.
- Recognize youtube-nocookie.com/embed links.
* 🎚️ fix: Exclude audio Gemini variants + preserve pre-id YouTube timestamps
Address Codex round 5:
- Add `audio` to the url_context modality exclusion so audio-only Gemini variants
(e.g. gemini-2.5-flash-preview-native-audio-dialog) skip the tool instead of 400ing.
- Detect YouTube timestamps anywhere in the matched URL (incl. before `v=`, e.g.
watch?t=90&v=<id>), so timestamped links are kept in the prompt text as intended.
* 🧠 feat: Configurable Reasoning Replay for Custom Endpoints
Adds customParams.includeReasoningContent so OpenAI-compatible custom endpoints (e.g. Xiaomi MiMo, Kimi) can replay reasoning_content on tool-call turns natively, without impersonating the moonshot provider.
* 🔁 feat: Replay reasoning_content across turns for opted-in custom endpoints
Extends the DeepSeek reasoning-content format spoof to honor customParams.includeReasoningContent, so custom OpenAI-compatible endpoints (Xiaomi MiMo, Kimi) reconstruct reasoning_content from persisted history on later turns, matching DeepSeek thinking-mode parity. Adds shouldReplayReasoningContent predicate (tested) and surfaces the flag on the initialized agent.
* 🪢 refactor: Split within-run vs cross-turn reasoning replay flags
moonshot only replays reasoning_content within a run's tool calls, not across turns. Decouples the two: includeReasoningContent = within-run replay (exact moonshot parity), new includeReasoningHistory = cross-turn reconstruction from persisted history (implies includeReasoningContent, since reconstruction is a no-op without the within-run replay flag).
* 🩹 fix: Apply reasoning replay across all param-format branches
Move the within-run includeReasoningContent application out of the OpenAI-only branch in getOpenAIConfig to after the branch dispatch, so custom endpoints using anthropic/google defaultParamsEndpoint gateway modes also honor includeReasoningContent/includeReasoningHistory. Addresses Codex finding.
* chore: Update @librechat/agents to v3.2.46
* 🧽 refactor: De-spoof reasoning replay via explicit preserveReasoningContent
Now that @librechat/agents 3.2.46 exposes an explicit preserveReasoningContent option on formatAgentMessages, pass it directly instead of impersonating provider: deepseek. Behavior is unchanged (shouldReplayReasoningContent still gates DeepSeek + the custom includeReasoningHistory flag); also corrects the comment to reference includeReasoningHistory.
* 🌳 fix: Walk subagents in the reasoning-history replay gate
The gate only checked the primary agent and top-level handoff/parallel configs, so an opted-in custom endpoint used solely as a nested subagent had its persisted reasoning dropped on later turns. New exported anyAgentReplaysReasoningContent walks subagentAgentConfigs (cycle-safe, mirrors anyAgentHasCodeEnv); client.js uses it. Addresses Codex finding.
Refresh token_provider and openid_user_id with the same expiry as the
rotated refreshToken cookie when an inline OBO refresh can still write
headers.
Share the marker-cookie writer with the normal OpenID auth refresh path
so the fallback /api/auth/refresh branch continues to recognize valid
OpenID refresh tokens after session expiry.
Add a short-lived Mongo-backed refresh-flight record so concurrent
OBO refreshes for the same OpenID session do not redeem the same
rotating refresh token on different workers.
The winning worker performs the IdP refresh and stores an encrypted
result; joiners wait for that result, hydrate their request session,
and return without calling the IdP.
Store SSE OBO refresh-token recovery bridges in MongoDB instead of
process-local memory so /api/auth/refresh can recover after worker
restarts or cross-worker routing.
Derive bridge expiry from REFRESH_TOKEN_EXPIRY so the recovery window
matches the stale refreshToken cookie it repairs, and delete bridges
after successful recovery.
Otherwise, it's possible for a config to override the `isValidAgentId` check.
Without that check, it's possible to query `getAgentById()` with a blank `agent_id`,
which can result in polluting the `QueryKeys.agent` cache with a full list of agents
(instead of just a single agent result).
* 🐛 fix: Prevent Infinite Render Loop on Code-Execution File Preview
Loading a conversation that contains a large (>1MB) code-execution
office file crashed the whole app with React error #185 ("Maximum
update depth exceeded") on hard refresh.
Root cause (client-only): the terminal-write effect in
useAttachmentPreviewSync writes the resolved preview record back into
messageAttachmentsMap with a fresh object identity on every run, and
`attachment` is in the effect's dependency array. useAttachments
re-derives `attachment` ({...db, ...liveEntry}) with a new identity on
every map write, so once polling resolves (pending -> ready on a loaded
conversation) the effect ping-pongs forever:
setAttachmentsMap -> re-derive -> effect -> setAttachmentsMap.
Only files large/slow enough to defer extraction are persisted at
status: 'pending', which is why small documents never triggered it.
Fix: an idempotency gate that bails before setAttachmentsMap when the
merged attachment already carries the resolved status/text/textFormat/
previewError. The write happens once and then settles.
Tests:
- useAttachmentPreviewSync.loop.spec.tsx wires the real
useAttachments -> hook feedback to reproduce the loop (verified to
throw #185 without the gate, settle with it).
- e2e/specs/mock/attachment-preview-loop.spec.ts loads a conversation
with a pending code-exec attachment whose preview resolves ready and
asserts the app does not crash.
Closes#13916
* 🔧 feat: Make Office Preview Extraction Cap Configurable (default 2MB)
The inline code-execution preview extraction ceiling was a hardcoded 1MB
constant (MAX_TEXT_EXTRACT_BYTES). Office/text artifacts over that skip
the inline preview and resolve to "Preview unavailable" (download-only).
Make it configurable via FILE_PREVIEW_MAX_EXTRACT_BYTES and raise the
default to 2MB so larger documents get an inline preview out of the box.
The rendered HTML remains independently capped at MAX_TEXT_CACHE_BYTES
(512KB), so image-heavy files over that still fall back to the existing
"preview too large" banner rather than rendering unbounded output.
- resolveMaxTextExtractBytes(env) parses the override, falling back to
2MB on missing/non-numeric/non-positive values (warns on invalid).
- Documented in .env.example next to the other file-size limits.
- Unit tests cover default, valid override, fractional flooring, and
invalid fallback.
* 🐛 fix: Guard sub-byte preview cap from flooring to zero
A fractional FILE_PREVIEW_MAX_EXTRACT_BYTES in (0, 1) passed the
positive-number check then floored to 0, making MAX_TEXT_EXTRACT_BYTES
zero and treating every non-empty artifact as oversized. Floor first,
then require the result to be >= 1 byte before accepting it; otherwise
fall back to the 2 MB default. Adds coverage for the sub-byte case.
* ✅ test: Make exported-ceiling assertion env-independent
The "exported ceiling" assertion compared MAX_TEXT_EXTRACT_BYTES to a
literal 2 MB, but that const is initialized from
FILE_PREVIEW_MAX_EXTRACT_BYTES at module load — so the suite would
falsely fail when run with the override set. Assert the export tracks
resolveMaxTextExtractBytes(env) for the current environment instead; the
undefined-case test continues to pin the 2 MB default.
* 🖱️ fix: Summon Quote Popup on Double-Click Word Selection
Chromium commits a double-click word selection on the `dblclick` event, after `mouseup` has already read a still-collapsed range, so the "Add to chat" popup never appeared for double-click selections. Listen for `dblclick` in addition to `mouseup`/`keyup`.
Adds an e2e covering a native double-click word selection (measured-coordinate dblclick exercises the real browser path, unlike the programmatic-Range helper).
* 🎯 test: Target Reply Text Node in Double-Click Quote E2E
Walk to the text node containing the needle (not the first text node in .message-render, which may be a select-none screen-reader/model-label header) and measure the needle's first character, so the native double-click lands on the reply word rather than metadata.
* fix: withhold custom endpoint headers for user URLs
* fix: require user key for user custom URLs
* test: type custom endpoint header cases
* fix: prompt for keys on user custom URLs
Resolve the new-chat default spec from the most recent conversation setup
(LAST_CONVO_SETUP_0) instead of reconstructing intent from accumulated
cross-endpoint history. Removes hasStoredModelValue, hasStoredPrefixValue,
hasStoredModelSelection, the sticky LAST_SPEC read, the nested
resolveSoftDefault closure, and the duplicated prioritize/modelSelect branches.
Fixes the soft default being dropped on New Chat ("Select a model") when its
preset endpoint sits outside modelSpecs.addedEndpoints alongside a custom
endpoint: a model lingering in LAST_MODEL for that endpoint no longer
suppresses the soft default.
Clear All Chats now also clears LAST_SPEC/LAST_MODEL/LAST_TOOLS so a new chat
afterward cleanly returns to the soft default. Adds the cross-endpoint unit
case, a clearAllConversationStorage test, and a cold-load e2e regression test.
Update single-flight OIDC refresh joiners whenever refreshed access token
state changes, even if the IdP keeps the refresh token unchanged.
This prevents joined requests from retaining stale accessToken or
accessTokenExpiresAt values and redundantly refreshing later in the same run.
When an inline OBO refresh rotates the OpenID refresh token after SSE headers
have already been sent, the browser refreshToken cookie cannot be updated. Store
a short-lived encrypted bridge from the stale cookie token to the rotated token
so /api/auth/refresh can recover after express-session loss.
Use the signed openid_user_id cookie to load user context for bridge validation,
retry only on invalid_grant, and delete the bridge only after the bridged refresh
succeeds.
Build the OpenID upstream-token provider at the request boundary and thread
only the closure through MCP handling, so the MCP service layer no longer
receives the raw Express request. The closure still reads/refreshes the live
session at tool-call time, preserving the walk-away recovery.
- Drop `req`/`capturedReq` from createMCPTools, createMCPTool, reconnectServer,
createToolInstance, and reinitMCPServer; forward `upstreamTokenProvider`
instead. Closure is constructed in loadTools, loadToolDefinitionsWrapper, and
the reinitialize route, where req/res are in scope.
- OBO: fall back to user.federatedTokens when the provider yields no live
session, so OIDC remote-agent calls (verified bearer, no session) still work.
- Inline refresh: mirror a rotated refresh token to the refreshToken cookie via
a shared setRefreshTokenCookie helper, guarded by !res.headersSent (no-op on
the streaming path; session copy stays authoritative).
- Single-flight: hydrate a joining request's own session from the resolved
tokens so a later OBO call doesn't replay a rotated-away refresh token.
Addresses owner feedback and three review findings.
* fix: Demote user abort logging
* fix: Handle abort causes
* fix: Demote user-aborted agent completion to debug log
The error users still saw originated in AgentClient's completion catch,
which logged every caught error (including user aborts) at error level
before checking the abort signal. Branch on abortController.signal.aborted
so user-initiated aborts log at debug while real failures stay error-classified.
Also give the handleAbortError it.each cases distinct titles.
* fix: require admin panel session secret
* 🩹 fix: Plain-Expand Admin SESSION_SECRET So Compose Maintenance Commands Run
The `${VAR:?}` required form fails interpolation for every deploy-compose
subcommand (down/pull/config), breaking `npm run update:deployed` for installs
whose .env predates ADMIN_PANEL_SESSION_SECRET. Plain expansion keeps those
commands working; the admin-panel image fail-fasts on an empty secret, so the
panel still refuses to start without it.