* 🔧 chore: Update ESLint config, add import sorting script, Test Sharding, Bump `@librechat/agents`
* Change 'no-nested-ternary' rule from 'warn' to 'error' in ESLint config
* Add new scripts for sorting imports in the project
* Update lint-staged configuration to include import sorting
* Modify GitHub Actions workflows to support sharding for unit tests
* chore: remove nested ternary expressions
* refactor: Extract scale multiplier logic into a separate function in CircleRender component
* refactor: Simplify auto-refill rendering logic in Balance component for better readability
* refactor: Improve width style handling in DataTable components for clarity and maintainability
* chore: remove CircleRender component
* delete: Remove CircleRender component as it is no longer needed in the project
* chore: Bump @librechat/agents to version 3.2.31 and update Node.js engine requirement
* Update @librechat/agents dependency from 3.2.2 to 3.2.31 in package-lock.json, api/package.json, and packages/api/package.json
* Change Node.js engine requirement from >=20.0.0 to >=24.0.0 in @librechat/agents
* chore: Add import sorting check to ESLint CI workflow
* Implement a new job in the GitHub Actions workflow to verify import ordering on changed files.
* The job checks for changes in specific file types and reports any import order drift, providing instructions for local fixes.
The Projects section defaulted to expanded, taking sidebar space for users with no
projects. Now derive the default: collapsed when there are no projects and the user
has never toggled the section; expanded once they have a project or explicitly
expand it. Any explicit toggle (new projectsSectionToggled flag) — or a collapse set
before this default existed — is respected.
* fix(projects): clear landing scope when the selected project is deleted
When a project-scoped new-chat landing (/c/new?projectId=...) was open and the
project got deleted, the chip kept showing the dead project and sends targeted it
(saving unscoped with a visual glitch).
- ChatRoute: only trust the scope when the project query succeeds (isSuccess), so
React Query's retained-on-error data can't keep a deleted project's chip alive;
strip ?projectId once the query settles to not-found so the landing reverts to a
normal unscoped chat.
- useDeleteProjectMutation: invalidate the project-detail query instead of removing
it, so active observers refetch and settle into an error state (removing left them
stuck loading under refetchOnMount: false).
- e2e: regression test for delete-while-scoped.
Fixes a follow-up issue to the projects feature (#13467).
* fix(projects): only drop scope on definitive not-found; clear inactive deleted detail
Address Codex review on #13525:
- ChatRoute: gate scope removal on a 404 (isNotFoundError) or a success that
resolves to a different/empty project, so a transient (non-404) failure under
retry:false no longer unscopes a valid project; keep the chip through transient
errors via retained data.
- useDeleteProjectMutation: also removeQueries({ type: 'inactive' }) so a deleted
project's inactive cached detail is dropped and a later visit refetches into a
not-found state instead of rendering stale cache within cacheTime.
- Upgraded @langchain/langgraph from 1.3.2 to 1.3.4
- Upgraded @langchain/langgraph-checkpoint from 1.0.2 to 1.0.4
- Upgraded @langchain/langgraph-sdk from 1.9.4 to 1.9.15
- Updated uuid from 10.0.0 to 14.0.0 across multiple packages
- Upgraded @langchain/protocol from 0.0.15 to 0.0.16
- Upgraded @remix-run/router from 1.23.2 to 1.23.3
- Upgraded hono from 4.12.18 to 4.12.23
- Upgraded react-router from 6.30.3 to 6.30.4
Add a Playwright mock e2e spec that opens Settings -> Account -> Enable 2FA
and asserts the framer-motion dialog renders. Reproduces the Vite /
framer-motion incompatibility from issue #13511: on the current build the
dialog crashes the client with "e is not a function" and never renders, so
this spec fails until the framer-motion bump in #13512 is merged.
LibreChat recently updated Vite (see 7dba640c9).
The older version of framer-motion we're using is incompatible with
this newer version of Vite; if you try to use it, you get the error
"e is not a function."
(One easy way to reproduce: try to enable 2FA on your account.)
Updating to the latest framer-motion fixes this issue.
* fix: harden shared-link message sanitization with an allowlist
Public shared links built their message payload via `{ ...message }` and
`{ ...attachment }` spreads, which exposed internal fields that are never
needed by the shared view:
- message: endpoint, conversationSignature, clientId, plugin(s), metadata
- attachments: filepath, storageKey, metadata, and other internal keys
Replace the passthrough with an allowlist so only render-relevant fields
(sender, text, content, token/feedback/error flags, and sanitized
attachments) are surfaced. Assistant model ids remain anonymized; other
model names are omitted rather than disclosed.
Also add `tenantId?: string` to ISharedLink, matching the field already
read by the shared-link access middleware for multi-tenant deployments.
* fix: preserve shared render data; sanitize by denylist (review feedback)
Address Codex/Copilot review on the shared-link sanitization:
- The tight attachment allowlist dropped tool-call render data. Switch to a
denylist of storage/identity-internal fields (filepath, storageKey, user,
tenantId, source, metadata, …) so toolCallId, tool payloads (web_search /
file_search / etc.), and dimensions are preserved while internals are stripped.
- Preserve user-uploaded message.files (previously dropped entirely) via the
same denylist sanitizer, so shared links keep uploaded images/documents.
- Introduce a dedicated SharedMessage / SharedFile type and use it for
SharedMessagesResult.messages instead of casting the allowlisted object to
IMessage, so omitted fields are caught at compile time.
Extends the regression test to assert toolCallId, web_search payload, and
files survive while filepath/storageKey/user/tenantId/metadata are removed.
* fix: keep render URLs + skill badges, drop private feedback (review round 2)
Address Codex round-2 findings on shared-link sanitization:
- filepath is the URL the share renderer loads (Files.tsx uses
file.preview ?? file.filepath; image attachments render only when filepath
is set). Drop it from the denylist so shared images/downloads still render;
storageKey (the raw object key) stays stripped.
- Preserve manualSkills / alwaysAppliedSkills so SkillPills still render for
skill-assisted turns (non-sensitive UI metadata).
- Remove feedback from the shared projection — it is the owner's private
rating/notes, is never rendered in the share view, and must not be exposed
to anyone holding the share URL.
Regression test updated to assert filepath/skills survive and feedback is
omitted.
* fix: anonymize file ids + preserve message iconURL (review round 3)
- Persisted message.files records can carry the original conversationId/messageId.
Attachments were already rewritten to the anonymized ids; apply the same
rewrite to files so shared user-uploaded files don't expose the real ids.
- Preserve message.iconURL (read by Share/MessageIcon.tsx) so shared assistant/
custom-endpoint turns keep their custom avatar instead of falling back to the
generic icon.
Regression test asserts the shared file's conversationId is the anonymized id
and that iconURL survives.
* 🎭 test: Run Mock E2E Suite Through createRun With In-Process Fake Model
Replace the standalone HTTP mock LLM server with an in-process fake model
injected into the real createRun -> Run.create pipeline via
run.Graph.overrideTestModel, so the mock suite exercises the agents
integration end-to-end without a live provider or a separate server.
- Bump @librechat/agents to 3.2.2 for the FakeChatModel/createFakeStreamingLLM exports
- Add an env-gated applyTestRunHook seam in packages/api createRun (no /api changes)
- Add e2e/setup/fake-model.js to drive default replies + the skill-authoring tool-call flow
- Drop the mock-llm webServer from playwright.config.mock.ts and set LIBRECHAT_TEST_RUN_HOOK
* 🧹 test: Retire Standalone Mock LLM Server From E2E Recorder
Migrate the `--profile=mock` recorder onto the same in-process fake model
as the Playwright mock suite, then delete the now-unused HTTP mock server
so the fake-LLM logic lives in a single place.
- Point record.js mock profile at the fake model via LIBRECHAT_TEST_RUN_HOOK
- Remove the mock-llm-server spawn/wait and MOCK_LLM_PORT plumbing from record.js
- Delete e2e/setup/mock-llm-server.js (e2e/setup/fake-model.js is now the only source)
- Update e2e/README.md to describe the in-process fake LLM
* 🏷️ ci: Rename Playwright Mock E2E Check to Playwright E2E Tests
navigateToConvo now removes the target conversation's cached messages before
fetching, so a freshly-mounted ChatView refetches them. clearMessagesCache
leaves a left conversation cached as [], and the messages query's
refetchOnMount: false treats that empty array as valid — so returning to the
conversation from a route where ChatRoute was unmounted (e.g. /projects) left
the chat stuck on an empty cache with no /api/messages request.
* 🧭 feat: Add MessageNav Focus Management and Drag-to-Scroll
Resolves#13491: move keyboard focus into the conversation when a message indicator is selected, and add a Shift+Alt+M shortcut to jump focus back to the nav. Also adds drag-to-scrub interaction across the rib column.
* 🖱️ style: Use grab cursor for MessageNav drag affordance
* 🐛 fix: Harden MessageNav drag against stale pointer state and dead clicks
Addresses Codex/Copilot review on #13497:
- Ignore pre-drag pointermoves when the primary button is not held, preventing a stale press (released outside the column) from starting a spurious scrub or calling setPointerCapture on an inactive pointer.
- Clear the post-drag click-suppression flag after the synthetic-click window so a later activation (including keyboard) is never swallowed.
- Match the advertised Shift+Alt+M shortcut by accepting the layout-aware key in addition to the physical code.
* 🎯 fix: Track MessageNav drag globally so it survives leaving the column
Round-2 Codex review on #13497: the 4px threshold was applied before any pointer capture, so a drag that left the narrow rib column before crossing it silently failed to scrub.
Replace per-element capture with document-level pointermove/up/cancel listeners attached on pointerdown:
- Drag tracking continues regardless of pointer position (fixes diagonal/touch drags off the ribs).
- pointerup is always received, so no stale drag state and no setPointerCapture on an inactive pointer (removes the NotFoundError path entirely).
- Native click is preserved, so the keyboard/click selection a11y path is unchanged.
- Listeners are torn down on pointerup/cancel and on unmount.
* 🧹 fix: Reset drag state on pointer replace and gate MessageNav shortcut
Round-3 Codex review on #13497:
- When a second pointerdown replaces an in-progress drag, run the cleanup with the real drag state so draggingRef is cleared and the rib column resumes auto-centering (was hardcoded to finish(false)).
- Only preventDefault on Shift+Alt+M when the nav is actually rendered and has a focus target, so the shortcut no longer swallows browser/AT shortcuts when the nav is absent (<3 messages). focusNav now reports whether it moved focus.
* ✨ fix: Make MessageNav drag span the whole thread and harden teardown
Round-4 Codex review on #13497:
- Map the drag pointer proportionally across the full entries range instead of the visible rib rects, so long conversations whose mini-nav overflows are fully scrubbable in one drag. This is also wobble-immune, so the column auto-centering no longer needs to be frozen mid-drag (removed the freeze and draggingRef).
- focusNav now reports success only if focus actually landed, so Shift+Alt+M does not preventDefault when the nav is mounted-but-hidden (hidden md:flex on small viewports).
- End the drag if the primary button is released mid-move or the window loses focus, covering pointers released outside the document where pointerup/cancel never arrive.
* feat: Add private chat projects
* fix: Format project files
* fix: Address project review findings
* fix: Resolve project review follow-ups
* fix: Handle project stats and cache edge cases
* style: align projects UI with sidebar patterns
* fix: resolve projects UI lint issues
* style: Align project menus and composer
* fix: Avoid project placeholder shadowing
* fix: Handle project search and stale ids
* fix: Polish project sidebar behavior
* fix: Preserve new chat stream after creation
* fix: Stabilize project sidebar sections
* fix: Smooth project sidebar organization
* fix: stabilize project chat entry
* fix: keep project workspace outside chat context
* fix: show default model on project workspace
* fix: fallback project workspace model label
* fix: preserve project scope during draft hydration
* fix: include route project in new chat submission
* fix: persist project id in agent chat saves
* fix: refine project sidebar and creation UX
* fix: export chat project method types
* fix: polish project landing context
* fix: refine project navigation affordances
* feat: rework projects UX — coexisting sidebar sections + URL-driven scope
Sidebar
- Replace the chronological/by-project mode toggle with coexisting
Projects + Chats sections (both always visible)
- Remove ProjectConversations (927 lines), the org-mode Header, and types
- Add ProjectsSection: collapsible project rows that unfurl chats inline
(full-size rows), with per-project new chat and an open/rename/delete menu
- Lift the marketplace/favorites shortcuts above the Projects section
Chat scope
- Derive a new chat's project strictly from the URL ?projectId, so the
global New Chat no longer stays stuck in a project after a project chat
Surfaces
- Chat landing: subtle, clickable project chip instead of the floating badge
- Project workspace: modest header, composer-style entry, chats list
- All-projects grid: Claude-style cards with pluralized chat counts
* chore: prune unused i18n keys; fix project chat-count pluralization
* fix: project new-chat keeps model spec; sidebar header + row polish
- newConversation: ignore a chatProjectId-only template when deciding to
apply the default model spec, so starting a chat in a project no longer
strips the conversation `spec`
- useSelectMention: the Model Selector and @ command now retain the active
project across endpoint/spec/preset switches; other new-chat paths still
clear it
- Chats header now matches the Projects header (inline chevron + a new-chat
icon button) and starts a non-project chat
- Project rows: use the new-chat icon for the per-project add button, render
at text-sm to match the chat list, and align the row actions + hover color
with conversation rows
* fix: read project scope from router params; align sidebar header icons
- useSelectMention now reads the active project from React Router's search
params instead of window.location, which can drift out of sync because
new-chat params are written to the URL via raw history.pushState; the
Model Selector and @ command now reliably keep the project on switch
- Move the Chats section header out of the virtualized list so it renders
in the same context as the Projects header and isn't shifted by the
list scrollbar
- Inset header action icons (pr-2) so Projects/Chats header icons line up
with the project-row and conversation-row trailing actions
- Extract getRouteChatProjectId into utils for the submit path
* fix: preserve chatProjectId through the new-chat template reduction
The param-endpoint guard in newConversation reduced a new chat's template to
{ endpoint } only, dropping the chatProjectId injected by the Model Selector /
@ switch — so switching models cleared the project scope. Keep chatProjectId
in the reduced template.
* style: align chat-history panel top padding; improve projects page contrast
- Add pt-2 to the chat-history panel so its top spacing matches the other
side panels (agent builder, skills, files, etc.)
- Projects grid + workspace now use the darkest surface for the page
(surface-primary) with cards, inputs, and the composer one step lighter
(surface-secondary) and tertiary on hover, so cards read as elevated
rather than darker than the background
* feat: interactive project landing chip + gallery icon for all-projects
- All-projects sidebar button uses the gallery-vertical-end icon
- The project landing chip is now interactive: click it to switch projects
via a searchable combobox (ControlCombobox), or the trailing × to drop the
project scope. Both update the draft conversation and the ?projectId search
param in place, so the typed message and selected model are preserved
* test: fix Conversations unit test for refactored sidebar; add projects e2e
- Update Conversations.test.tsx mocks for the inline Chats header
(useNewConvo, useQueryClient, conversation atom, NewChatIcon, TooltipAnchor),
drop the removed chatsHeaderControls prop, and remove the mock for the
deleted ../Header module — fixes the failing frontend Jest job
- Add e2e/specs/mock/projects.spec.ts covering project creation, the
project-scoped new-chat landing + interactive chip (switch/remove), and
listing projects on /projects
- Give the landing chip combobox a stable selectId for reliable targeting
* fix: refresh project stats after project-chat activity; stabilize e2e
- useEventHandlers: when a project chat is created/updated, invalidate the
live [projects] query (gated on chatProjectId) instead of the now-unused
projectConversations key, so the sidebar + all-projects stats refresh
after a streamed reply (addresses a Codex finding)
- projects e2e: assert the reliable project-landing behavior (chip, scoped
composer, accepted send) rather than the /c/:id transition, which the
mock LLM harness doesn't complete
* test: verify a project chat saves and is filed under its project (e2e)
- Switch to a mock endpoint before sending so the message streams without a
real API key (the default model failed with "No key found", so no chat was
saved and the page never left /c/new); this also asserts the project chip
survives the model switch
- Restore the reply + /c/:id transition assertions and add a check that the
chat is listed under the expanded project in the sidebar
- Add data-testid="project-chats-<id>" to the inline project chat list
* fix: address Codex review findings (project scope edge cases)
- useSelectMention: fall back to the conversation's chatProjectId when the
URL has no projectId, so switching model/spec inside an existing project
chat (/c/:id) keeps the project assignment
- Conversations: include chatProjectId in the MemoizedConvo comparator so a
sidebar row's project menu doesn't stay stale after a reassignment
- useDeleteProjectMutation: clear the active conversation's chatProjectId
when its project is deleted (mirrors the assignment mutation); drop the
now-dead projectConversations invalidation
- useQueryParams: carry the project into the new conversation when applying
URL settings, so /c/new?projectId=...&<settings> stays scoped
* fix: project stats pagination + archived-chat edge cases (data-schemas)
- listChatProjects: include the null lastConversationAt bucket in the desc
cursor so empty projects paginate (a $lt:<date> predicate excluded nulls,
hiding chat-less projects from "Load more")
- saveConvo: recompute project stats instead of the incremental fast path
when the saved conversation is itself archived/temporary/expired, so a
project's lastConversationAt/Id no longer points at a hidden chat
* test: cover chat-less project pagination across the dated→null boundary
* fix: validate project ownership in bulkSaveConvos
Bulk paths (import/duplicate/fork) persisted whatever chatProjectId the
payload carried; an id that does not belong to the user created an orphan
assignment hidden from both the project and the unassigned sidebar. Validate
ownership like saveConvo and strip un-owned project ids before persisting,
refreshing stats only for owned projects.
* fix(projects): preserve chatProjectId on continuation, basename-safe delete redirect, project-detail invalidation
* fix(projects): navigate project workspace chats via useNavigateToConvo to avoid stale conversation state
* fix(projects): include projectConversations cache when resolving deleted chat's project for detail invalidation
* fix(projects): refresh both projects when a save or bulk write moves a chat between them
* style(projects): use Folders icon for the sidebar Projects header
* fix(projects): require id on ProjectUser so ProjectRequest extends Express Request cleanly
* style(projects): taller project chip with hover-revealed remove button, upward combobox; sort en translations
* style(projects): show endpoint/agent icon for project workspace chat rows
* feat: Add granular access control to shared links via ACL system
* fix(shared-links): preserve isPublic on failed migration grants
Transient ACL failures during auto-migration permanently stranded
links — $unset ran unconditionally, removing the legacy flag that
triggers retry. Now only $unset isPublic after all grants succeed.
* fix(config): skip isPublic unset for failed ACL grants
Bulk migration unconditionally removed isPublic from all links,
even those whose ACL writes failed. Failed links then lost the
legacy marker needed for auto-migration retry. Now tracks failed
link IDs per-batch and excludes them from the $unset step.
Also adds sharedLink to AccessRole resourceType schema enum —
was missing, only worked because seedDefaultRoles uses
findOneAndUpdate which bypasses validation.
* ci(config): add jest config and PR workflow for migration tests
config/__tests__/ specs depend on api/jest.config.js module
mappings but had no dedicated runner. Adds config/jest.config.js
extending api config with absolutized paths, npm test:config
script, and a GitHub Actions workflow triggered by changes to
config/, api/models/, api/db/, or packages/ ACL code.
* fix(permissions): honor boolean sharedLinks config
SHARED_LINKS has no USE permission, so boolean config produced
an empty update payload — gate conditions only matched object
form, making `sharedLinks: false` a no-op on existing perms.
* fix(share): resolve role before creating shared link
Role lookup between create and grant left an orphaned link
without ACL entries if getRoleByName threw — retry then hit "Share already exists" with no recovery path.
* fix: Restore Public ACL Access Checks
* fix: Type Public ACL Lookup
* fix: Preserve Private Legacy Shared Links
* chore: Promote Shared Link Permission Migration
* fix: Address Shared Link Review Findings
* fix: Repair Shared Link CI Follow-Up
* fix: Narrow Shared Link Mongoose Test Mock
* fix: Address Shared Link Review Follow-Ups
* fix: Close Shared Link Review Gaps
* fix: Guard Missing Shared Link Permission Backfill
* test: Add Shared Link Mock E2E
* test: Stabilize Shared Link Mock E2E
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* ⚡ feat: Immediate Conversation Title Generation
Generate conversation titles as soon as the request is made (in parallel
with the response, from the user's first message) as the new default,
fixing the #13318 race where a transient /gen_title 404 left new chats
stuck on "New Chat".
- Add per-endpoint `titleTiming` ('immediate' | 'final') to baseEndpointSchema;
`endpoints.all` acts as the global default, unset = immediate. Resolve via
a new `resolveTitleTiming` helper (`all` takes precedence).
- Fire title generation in parallel with `sendMessage`; `titleConvo` waits
(bounded, abortable) for the agent run and titles from the user input only.
Persist after the conversation row exists; defer `disposeClient` until the
title settles.
- Expose `titleGenerationTiming` via startup config; `useTitleGeneration`
fetches eagerly in immediate mode with a bounded 404 retry and never treats
a transient 404 as final. Skip title queueing for temporary conversations.
- Supersedes #13329 while incorporating its bounded 404-retry.
* 🩹 fix: Address Copilot review findings on title timing
- Guard against an undefined conversationId in addTitle (skip + warn) so the
gen_title cache key can't collide as `userId-undefined` and saveConvo is
never called without a conversationId.
- Gate the title `useQueries` on `enabled` so no /gen_title request fires while
unauthenticated (e.g. after logout) even if the module queue holds IDs.
- Drop the stale `conversationId` param from the titleConvo JSDoc.
- Add a regression test for the undefined-conversationId guard.
* 🧵 fix: Harden immediate-title edge cases from codex review
- Cancel in-flight immediate title generation when the request aborts: thread
job.abortController.signal through addTitle so pressing Stop on a new chat
neither consumes the title model nor surfaces a title for a cancelled turn.
- Preserve a locally-applied title when the final SSE event's conversation
carries no title yet (built before the title was saved), so long immediate-mode
responses no longer revert the chat to "New Chat" until reload.
- Guarantee one full post-completion gen_title fetch cycle before giving up, so a
`final`-mode title (generated only after the stream ends) is still fetched under
a global `immediate` default instead of being stranded.
- Add regression tests for the abort propagation and the undefined-conversationId guard.
* 🔁 fix: Correct title abort, post-completion refetch, and replacement ordering
Follow-up to codex review of the immediate-title fixes:
- Use a dedicated title AbortController instead of `job.abortController`. The
latter is also aborted by `completeJob` on *successful* completion, which
cancelled any title slower than a short response. The title is now cancelled
only on a real user Stop or when the stream is replaced; a completed-then-
aborted title is discarded (no save, cache cleared) rather than persisted.
- Reset (not remove) the post-completion title query: `resetQueries` refetches
the mounted observer with a fresh retry budget, whereas `removeQueries` left it
stuck in its error state, so the promised post-completion cycle never ran.
- Run the job-replacement check before resolving `convoReady`, and on a replaced
stream cancel/discard the stale title so a discarded prompt can't persist a title.
* 🧷 fix: Tighten title abort ordering and endpoint-level timing resolution
Follow-up to codex review:
- Abort the title controller before resolving `convoReady` on a stopped turn, so
the title task can't resume and persist before the later abort.
- Cancel the title and unblock its waits on ANY send failure (not just user
aborts): a preflight/quota failure before the run exists otherwise hangs
`_waitForRun`, deferring client disposal until the 45s title timeout.
- Resolve `titleTiming` for custom endpoints via `getCustomEndpointConfig`
(their config lives under `endpoints.custom[]`, not `endpoints[endpoint]`).
- Derive the startup `titleGenerationTiming` via `resolveTitleTiming` for the
agents endpoint so an endpoint-level `final` (without `endpoints.all`) is honored
client-side instead of defaulting to immediate and burning eager gen_title polls.
* 🪢 fix: Per-agent title timing and safer abort/replacement handling
Follow-up to codex review:
- Resolve `titleTiming` from the agent's actual endpoint after initialization, so a
per-endpoint `final` override on a custom/provider endpoint backing an (ephemeral)
agent is honored instead of always using the `agents` endpoint's value.
- Don't preserve a locally-fetched title on a stopped (unfinished) turn: the server
cancels and discards that title, so keeping it client-side would diverge from
server state and leave the stopped chat titled until reload.
- On abort/replacement, only delete the cached title if it still holds THIS task's
value — a replacement stream shares the `userId-conversationId` key and may have
already cached its own valid title that must not be removed.
* 🪞 fix: Mirror AgentClient title-config resolution for titleTiming
Per maintainer guidance, keep titleTiming resolution identical to how
`AgentClient#titleConvo` already resolves the endpoint config — `endpoints.all`
is the intended global override and the agent's actual provider endpoint is used:
- Resolve via `endpoints.all ?? endpoints[endpoint] ?? getProviderConfig(endpoint)
.customEndpointConfig` (was using `getCustomEndpointConfig` directly). Going
through `getProviderConfig` picks up its case-insensitive fallback for normalized
provider names (e.g. `openrouter` → `OpenRouter`), so a custom endpoint's
`titleTiming` is honored like its other title settings.
- Add `titleTiming` to the Azure endpoint schema `.pick()` so
`endpoints.azureOpenAI.titleTiming` is no longer silently stripped by Zod.
Note: per-endpoint title settings being skipped when `endpoints.all` is present is
the existing, intended global-override behavior — not changed here.
* 🧪 test: Cover useTitleGeneration effect logic (integration)
Adds a deterministic white-box integration test that drives the real hook's
React effects with a controllable react-query surface, locking down the
stateful decisions that previously had no coverage:
- immediate mode fetches a queued conversation while its stream is still active
- final mode gates until the stream completes, then becomes eligible
- success applies the fetched title to the conversation caches
- a 404 while active defers (removeQueries) instead of giving up
- a 404 after completion forces a fresh fetch via resetQueries (post-completion remount)
* feat: Stream immediate title events
* style: Format title SSE handler
* test: Preserve data-provider exports in OAuth mock
* test: Isolate OAuth route API mock
* test: Keep OAuth callback factory capture
* fix: Replay streamed title events on resume
* fix: Honor agents title timing precedence
* style: Format title timing fixes
* Shared Role-Sync Core
* Environment Configuration
* Browser OpenID Wiring & improved shared component
* API Auth Wiring
* Improved Role Lookup
* added example for sync env
* small simplification
* protect existing manual assigned ADMIN Roles
* fix: Apply OpenID role-sync fallback for present-but-empty claims
Both role-sync call sites skipped on a falsy `openIdRoleValues`, treating an
empty claim string ('') the same as a missing claim and returning before
`selectOpenIdRole` could apply the configured fallback role. An IdP emitting
an empty roles claim for a user with no mapped groups left the stale local
role in place instead of the authoritative fallback.
Skip only when the helper returns `undefined` (missing/invalid), letting an
empty string flow through to fallback selection — consistent with how an
empty array is already handled. Adds regression coverage on both the OpenID
strategy and the remote-agent API auth paths.
* refactor: Address OpenID role-sync review feedback
- role.ts: reuse the shared escapeRegExp util instead of a local escapeRegex
duplicate, matching prompt/skill/user/userGroup methods (Copilot).
- openidStrategy.js / remoteAgentAuth.ts: make the tenantStorage.run callbacks
async so the documented ALS contract is satisfied and tenant context cannot
be lost during Mongoose execution; the wrapped lookups/updates are already
async, so behavior is unchanged (codex P2).
* fix: Harden OpenID role-sync claim and fallback handling
Addresses the second Codex review cycle (P2 findings):
- Apply fallback when the claim is absent: getOpenIdRolesForOpenIdSync now
returns an empty list (not undefined) when the token source exists but has
no usable claim value, so callers still run selection and assign the
configured fallback instead of leaving a stale elevated role. A truly
unavailable source still returns undefined and skips sync.
- Resolve group overage for access tokens too: the _claim_names/_claim_sources
overage path previously only ran for claimSource 'id'; Entra also moves an
oversized groups claim into access tokens, so 'access'+'groups' (the only
source supported by remote-agent API sync) now resolves overage as well.
- Allow system fallback roles for tenant users: getLibreChatRolesForOpenIdSync
treats SystemRoles (e.g. USER) as always-available canonical names, since
they are provisioned globally at startup and a tenant-scoped lookup may not
return them — preventing a spurious 'configured roles do not exist: USER'.
Adds unit and strategy-level coverage for all three.
* fix: Tighten OpenID role-sync tenant scoping and config validation
Addresses the third Codex review cycle:
- Constrain base-user role lookups to base roles (P2): findRolesByNames now
filters to roles with an unset tenantId when no tenant ALS context is active,
so a base user cannot match — and be assigned — a role that only exists within
a tenant. Tenant-scoped lookups remain controlled by the isolation plugin.
- Re-enforce tenant login policy after role sync (P2): when role sync changes a
tenant user's role, the OpenID strategy re-resolves the tenant appConfig and
re-checks allowedDomains, so a token cannot complete login under the previous
role's looser policy.
- Skip role-sync-specific validation when disabled (P3): getOpenIdRoleSyncOptions
returns disabled options before validating role-sync settings, so a stale or
mistyped value no longer breaks OpenID login while the feature is off.
Adds unit and strategy-level coverage for all three.
* fix: Run base role lookups under system context for strict isolation
Follow-up to the base-role scoping fix (Codex P1). With TENANT_ISOLATION_STRICT=true,
the tenant-isolation pre('find') hook throws on a context-less query before the manual
tenantId filter is honored, so base OpenID/remote-agent auth would 500 instead of
validating base roles. findRolesByNames now runs the no-context lookup inside
runAsSystem (SYSTEM_TENANT_ID), bypassing strict-mode injection while still applying an
explicit base-role (tenantId unset) filter. Adds a strict-mode regression test.
---------
Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
* Add OBO (On-Behalf-Of) token exchange support for MCP server connections
Enables transparent authentication to Entra ID-backed MCP servers using the logged-in user's federated token via the OAuth 2.0 jwt-bearer grant. Configured via obo.scopes in librechat.yaml server config.
- Extract generic OboTokenService from GraphTokenService (jwt-bearer grant + cache)
- Refactor GraphTokenService to thin wrapper delegating to OboTokenService
- Add obo schema field to BaseOptionsSchema in data-provider
- Add resolveOboToken in packages/api/src/mcp/oauth/obo.ts (validates federated token, calls resolver, returns MCPOAuthTokens)
- Wire oboTokenResolver through MCPConnectionFactory, MCPManager, UserConnectionManager
- OBO tokens injected via request headers (not OAuth transport), refreshed on each tool call
- Explicit error on OBO failure (no fallthrough to standard OAuth redirect)
- Add unit tests for both resolveOboToken (9 tests) and exchangeOboToken (14 tests)
* Add OBO authentication option to MCP server UI configuration
Enable users to configure On-Behalf-Of (OBO) token exchange for MCP servers created via the UI (MongoDB-stored), in addition to the existing YAML-based configuration.
- Add "On-Behalf-Of (OBO)" radio option to MCP server auth section with scopes input field
- Remove obo from omitServerManagedFields so the field passes UI schema validation
- Add OBO to AuthTypeEnum, obo_scopes to AuthConfig, and OBO handling in form defaults and submission
- Add .min(1) validation on obo.scopes to reject empty strings
- Add English localization keys: com_ui_obo, com_ui_obo_scopes, com_ui_obo_scopes_description
- Add 5 schema validation tests for OBO field acceptance, transport compatibility, and edge cases
* 🧊 fix: Add obo to safe properties in redactServerSecrets. Fixes the OBO configuration not showing up in the MCP UI after app restart
* Address linter errors
* 🧊 fix: fail closed on OBO refresh errors and retry transient token exchange failures
- stop tool calls from falling back to stale Authorization headers when per-call OBO refresh fails
- add one-time retry for transient Entra OBO exchange failures (network/429/5xx)
- preserve structured OBO failure reasons and retryability in resolveOboToken
- improve OBO auth error messaging for connection setup and tool execution
- add tests for transient vs permanent OBO failure paths
* Addressing linting errors / warnings
* 🧊 fix: isolate OBO MCP auth to user-scoped connections
- block OBO-enabled servers from app-level shared MCP connections
- bypass shared connection lookup for OBO servers in MCPManager.getConnection
- add regressions covering OBO connection scoping and preserve non-OBO app connection reuse
* 🛠️ refactor: centralize MCP user-scoped connection policy
- add shared requiresUserScopedConnection helper for OAuth, OBO, and customUserVars
- use the shared predicate in MCPManager and ConnectionsRepository
- add utils coverage for user-scoped connection policy
* 🧊 fix: restrict MCP OBO config to header-capable transports
- Move OBO configuration out of the shared MCP base options schema and allow it
only on SSE and streamable-http transports, where request headers are applied.
- Explicitly reject OBO on stdio and websocket configs to avoid accepted-but-
nonfunctional server definitions. Add schema coverage for admin/config parsing
and user-input websocket validation.
* 🧊 fix: single-flight concurrent OBO token exchanges
Concurrent tool calls that arrive on a cache miss were each issuing
their own jwt-bearer request to the IdP. Under that fan-out, Entra
intermittently returned errors that the retry classifier saw as
non-retryable, surfacing as:
"The identity provider rejected the OBO token exchange.
Cannot execute tool <name>. Re-authenticate the user or
verify the configured OBO scopes and retry."
A user retry then hit the populated cache and succeeded, which matches
the observed flakiness — the cache was empty at the moment of fan-out
but populated by the time the user clicked retry.
- Coalesce concurrent exchanges in `OboTokenService.exchangeOboToken`
keyed by `${openidId}:${scopes}`. Callers that arrive while an exchange
is in flight share the same upstream request and receive the same
result. `fromCache=false` continues to force a fresh, independent
exchange (and is not joined by `fromCache=true` callers). The IdP
call, single-retry path, and cache write are unchanged — they were
moved into a `performOboExchange` helper so the coalescing wrapper
stays small.
- Tests cover: coalescing on the same key, isolation between different
keys, cleanup on success, cleanup on failure, and the
`fromCache=false` bypass.
* 🔒 feat: gate MCP OBO config behind MCP_SERVERS.CONFIGURE_OBO permission
OBO silently mints per-user delegated tokens from the caller's federated
access token and forwards them to whatever URL the server config points at.
Previously, anyone with MCP_SERVERS.CREATE could configure obo.scopes — so
if server creation is ever delegated beyond admins, a user could stand up
an attacker-controlled server, attach it to a shared agent, and exfiltrate
other users' downstream tokens on tool invocation.
Add a dedicated MCP_SERVERS.CONFIGURE_OBO permission (ADMIN: true, USER:
false by default) and enforce it at three layers so the safety property
no longer depends on CREATE staying admin-only:
- Create/update: POST/PATCH /api/mcp/servers returns 403 when the body
carries `obo` and the caller's role lacks the permission.
- Runtime fail-closed: for DB-sourced configs, MCPConnectionFactory and
MCPManager.callTool re-check the original author's role before each
OBO exchange. If the author has been downgraded, the exchange is
skipped (factory) or refused (callTool) — retained configs lose their
privileges automatically.
- UI: the OBO option is hidden in the MCP server dialog for users
without the permission; a CONFIGURE_OBO toggle is exposed in the MCP
admin role editor.
Existing role docs receive the new sub-key via the permission backfill
in updateInterfacePermissions on next startup, preserving any
operator-set values. YAML/Config-sourced server configs are unaffected
since they're admin-controlled at the deployment level.
* 🧊 fix: wire OBO machinery for servers with requiresOAuth: false
The discovery and user-connection paths gated OAuth wiring (flow
manager, token methods, oboTokenResolver, oboTrustChecker) behind
isOAuthServer(), which only considers requiresOAuth/oauth fields.
A DB-stored OBO server with requiresOAuth: false therefore landed in
the non-OAuth branch, never received an oboTokenResolver, and the
factory's usesObo getter evaluated to false — sending a bare request
that the upstream rejected with invalid_token.
Add requiresOAuthMachinery() (OAuth OR OBO) and use it at those two
gates. isOAuthServer remains for the OAuth-handshake-only check
(shouldInitiateOAuthBeforeConnect), where OBO must not initiate a
handshake. Plumb the OBO resolver/trust-checker through
ToolDiscoveryOptions so reinitMCPServer can pass them on the
discovery path.
* 🧊 fix: lock all OBO-target fields (URL, proxy, headers, auth) without CONFIGURE_OBO
The CONFIGURE_OBO permission was meant to gate control of the endpoint
that receives OBO-minted per-user delegated tokens and the scopes that
are requested. The previous frontend lock + backend gate only covered
obo.scopes and the auth section, leaving url/proxy/headers/etc. editable
by anyone with UPDATE — meaning a non-permission user could still
redirect an existing OBO server's token flow to an attacker endpoint.
Switch to an allowlist policy: when editing an OBO server without
CONFIGURE_OBO, only title/description/iconPath are mutable. Backend
rejects any other field change with 403; frontend disables the
non-allowlist sections (URL, transport, auth, trust) via fieldset.
The comparison surface (MCP_USER_INPUT_FIELDS) is derived from
MCPServerUserInputSchema's union members so it stays in sync with the
schema. New schema fields land in the locked set by default — adding to
the allowlist is the only way to unlock them, which preserves the
security-review boundary.
* 🧊 fix: skip unauthenticated MCP inspection for OBO-only servers
MCPServerInspector.inspectServer() ran an unauthenticated temp connection
unless the config had requiresOAuth or customUserVars set. For OBO-only
servers without standard MCP OAuth advertisement, this caused
MCPConnectionFactory.create to attempt the connection without a user or
oboTokenResolver — failing on servers that reject the MCP initialize
handshake without a valid bearer token, which surfaced as
MCP_INSPECTION_FAILED on create/update.
Add `obo` to the skip list alongside requiresOAuth and customUserVars,
matching the existing pattern for user-scoped auth modes.
* Addressed linting error: watchedTitle is declared but never referenced (the auto-fill logic at line 156 uses getValues('title') instead). Deleted constant.