Commit graph

602 commits

Author SHA1 Message Date
Dustin Healy
bd158905b3 🔒 fix: Harden admin OAuth refresh against user bans, tenant scope gaps, and cross-tenant migration
Post-identity-resolution ban check: the initial checkBan middleware fires before the
refresh token is exchanged and req.user is populated, so it can only evaluate IP bans.
After applyGoogleAdminRefresh/applyAdminRefresh resolves the user identity, we now
synthesize req.user and re-run checkBan against the resolved user's id before emitting
the JWT, so a user-level ban is enforced even from a fresh IP.

Domain allowlist now includes userId: the getAppConfig call in isEmailAllowedForUser
was passing only role, missing user and group-level allowedDomains overrides that the
initial OAuth callback's checkDomainAllowed enforces via userId. Both branches now
pass userId so buildPrincipals takes the full user+group+role resolution path. The
tenant branch is also inlined (replacing resolveAppConfigForUser) to accept userId,
wrapped in tenantStorage.run for correct Mongoose scoping and cache-key resolution.

Cross-tenant email-fallback migration: the Passport verify callback fires before
tenantContextMiddleware, so findUser({email}) is unscoped and can return a same-email
user from another tenant. Writing googleId onto that document permanently corrupts
the other tenant's account. Migration is now blocked for users with a tenantId;
single-tenant users are unaffected.
2026-06-22 10:42:20 -07:00
Dustin Healy
0f14dcce62 🔒 fix: Add ban check and fix domain allowlist on admin OAuth refresh
Two gaps in the /api/admin/oauth/refresh route:

Add middleware.checkBan to the route chain before preAuthTenantMiddleware,
matching the gate that /login/local and createOAuthHandler already apply.
Without it a banned admin could keep minting JWTs until their IdP refresh
token expired.

Replace getAppConfig({ baseOnly: true }) in the non-tenant isEmailAllowed
closure with getAppConfig({ role: user.role }), which includes DB-layer
overrides from the admin panel. baseOnly returns only YAML-derived config,
so any allowedDomains list maintained entirely through the admin panel was
silently inert on this path. Extract isEmailAllowedForUser as a shared
helper, move it into buildAdminRefreshClosures so both Google and OpenID
refresh paths enforce domain policy consistently, and add isEmailAllowed
to AdminRefreshDeps in the TS package so applyAdminRefresh can invoke it.
2026-06-22 10:10:59 -07:00
Dustin Healy
fcdb66bb6b 🔒 fix: Apply brutal-review hardening to Google admin refresh
Tighten the Google OAuth refresh flow against all outstanding code review
findings: enforce JWT aud claim verification against the configured clientId
(ISSUER_MISMATCH on mismatch), reject ambiguous googleId matches (limit:2 in
findUsers, USER_ID_MISMATCH when multiple rows match), scope the authInfo
refresh-token carrier to the Google provider only, add TOCTOU re-read defense
after the admin googleId migration write in socialLogin, deduplicate
canAccessAdmin/mintToken closures via buildAdminRefreshClosures shared by both
OpenID and Google refresh paths, document rotation semantics on
AdminExchangeResponse.refreshToken, standardise all log prefixes to
[admin/oauth/refresh], and expand test coverage for all new paths.
2026-06-22 09:34:41 -07:00
Dustin Healy
0e55d8a1df 🔁 fix: Tighten Google admin refresh and limit social-login changes
Brutal-review findings on top of the upstream feature work.

socialLogin.js: the migrate-or-reject pattern from the previous commit
applied to every provider's chat-side verify callback, not just the admin
flow. Gate both branches on `options.existingUsersOnly` so the chat-side
googleLogin / facebookLogin / etc. keep their pre-existing email-fallback
behavior unchanged. Tests follow: restore the original `should fallback to
finding user by email` chat-side case and re-add the migration and
mismatch-reject cases as admin-only by passing `{ existingUsersOnly: true }`
to socialLogin in those tests.

googleRefresh.ts: add a defense-in-depth `isEmailAllowed(user)` dep that
the helper invokes before `canAccessAdmin`. Mirrors the
`isEmailDomainAllowed` check the initial Google admin login already runs,
so a deployment that removes a domain from `registration.allowedDomains`
after issuance can no longer mint fresh JWTs for that admin via refresh.
The route handler wires it up with `resolveAppConfigForUser` +
`isEmailDomainAllowed`, falling back to `baseOnly` config for users
without a tenantId.

googleRefresh.ts: drop the unreachable `?? ''` defensive coalescing in
`fetchGoogleTokenset`. The `GOOGLE_NOT_CONFIGURED` guard upstream already
narrows `clientId`/`clientSecret` to non-empty strings; the function
takes a narrowed `GoogleAdminRefreshConfiguredOptions` shape and
`applyGoogleAdminRefresh` constructs that shape after the guard.
2026-06-22 08:35:12 -07:00
Dustin Healy
21922eea78 🧹 refactor: Move Google admin refresh into TypeScript @librechat/api helper
Per repo guidance (CLAUDE.md): all new backend code must be TypeScript in
/packages/api, and /api is a thin JS wrapper. The previous commit landed the
Google admin refresh flow as ~120 lines of new JS inside
api/server/routes/admin/auth.js, which violates that. This commit extracts
the flow into a new TS helper at packages/api/src/auth/googleRefresh.ts and
reduces the route handler to a thin dep-wiring wrapper.

The helper exports applyGoogleAdminRefresh(deps, options) with the same
shape as the OpenID applyAdminRefresh: callers pass findUsers, getUserById,
canAccessAdmin, and mintToken as deps so the package stays free of /api
model imports and capability/session helpers. The route handler now builds
those deps from the existing model + capability + token modules and calls
the helper, mapping AdminRefreshError to the documented HTTP responses.

While moving the code, the helper now guards getUserById with
Types.ObjectId.isValid before the direct-lookup branch, matching the
OpenID admin path at packages/api/src/auth/refresh.ts. Without this guard
a malformed user_id from the admin client would hit Mongoose findById's
CastError and surface as a 500 INTERNAL_ERROR instead of falling through
to the documented sub-based lookup.

Tests move with the code: packages/api/src/auth/googleRefresh.spec.ts now
owns the helper's behavior (token endpoint, userinfo fallback, ObjectId
guard, USER_ID_MISMATCH/TENANT_MISMATCH/USER_NOT_FOUND/FORBIDDEN, rotated
refresh-token pass-through, GOOGLE_NOT_CONFIGURED, IDP_INCOMPLETE on
non-JSON body, CLAIMS_INCOMPLETE when both id_token and userinfo miss).
The route-level api/server/routes/admin/auth.refresh.test.js drops the
duplicated end-to-end Google cases and keeps a smaller surface: route
delegates to applyGoogleAdminRefresh with the right deps + options, maps
AdminRefreshError to HTTP status/code, falls through to 500 for unknown
errors, and rejects unknown providers with INVALID_PROVIDER.
2026-06-18 12:18:46 -07:00
Dustin Healy
1dddf97c4a 🔁 fix: Harden Google admin refresh against bot review findings
Five validated findings from the initial bot pass:

socialLogin.js: mirror the OpenID migrate-or-reject pattern on the email
fallback. When an existing user is found by email and the stored provider
id is empty, persist the refreshed sub so the refresh path can later bind
to it. When the stored id is present and differs, reject as AUTH_FAILED
to prevent identity-swap, matching the existing OpenID behavior in
packages/api/src/auth/openid.ts.

oauth.js: scope the non-OpenID admin refresh-token forwarding to
provider === 'google'. The previous else branch would have forwarded a
Discord refresh token (passport-discord supplies one) into the admin
exchange payload even though /api/admin/oauth/refresh only accepts
openid or google, leaving the admin client with a token it could not
refresh.

admin/auth.js (refreshGoogleAdminSession): drop id_token from the
mandatory-fields check. Google's OAuth refresh response is documented to
include id_token only conditionally, so the previous mandatory check
broke refresh whenever Google omitted it. Decode id_token when present
(fast path); when absent, call Google's userinfo endpoint with the
access token to read sub. Wrap tokenResponse.json() in try/catch and
return IDP_INCOMPLETE on parse failure instead of a generic 500.
Tighten access_token to a typeof string check.

admin/auth.js (refreshGoogleAdminSession): reuse serializeUserForExchange
for the response user so the Google refresh shape matches /oauth/exchange
and the OpenID branch exactly (full _id, id, email, name, username, role,
avatar, provider, openidId). The previous Google-specific subset dropped
fields the admin client relies on for later provider-specific refreshes
and disambiguation.

Tests cover each fix: socialLogin's migration and rejection cases, the
oauth.js Discord-gating case, the userinfo fallback path on missing
id_token, CLAIMS_INCOMPLETE when both id_token and userinfo are absent,
IDP_INCOMPLETE on a non-JSON token body, and the full response shape on
the happy path.
2026-06-18 11:50:52 -07:00
Dustin Healy
d40c51616e 🔑 feat: Refresh-Capable Google Admin OAuth Sessions
Google admin sessions cannot be refreshed today. Three gaps add up to that:
passport.authenticate('googleAdmin', ...) in api/server/routes/admin/auth.js
never sets access_type=offline, so Google omits the refresh_token from its
token response; createOAuthHandler in api/server/controllers/auth/oauth.js
only forwards a refresh token into the admin exchange payload when the user's
provider is 'openid' AND OPENID_REUSE_TOKENS is enabled; and
/api/admin/oauth/refresh is openid-only, calling openid-client.refreshTokenGrant
against the configured OIDC issuer. OpenID admins refresh transparently
because all three are in place for them.

This PR closes all three. The googleAdmin authenticate call now passes
accessType: 'offline' and prompt: 'consent' so Google issues a refresh token
on consent; the chat-side googleLogin is untouched. The shared socialLogin
verify callback now passes the IdP refreshToken through as passport's third
argument (info), landing on req.authInfo, with the two-argument call shape
preserved when no refresh token is present so existing strategy tests stay
valid. createOAuthHandler reads req.authInfo?.refreshToken for non-OpenID
admin providers and forwards it into the exchange code; the OpenID branch
and its OPENID_REUSE_TOKENS gate are unchanged. /api/admin/oauth/refresh
now accepts an optional provider field ('openid' | 'google', default 'openid').
The new Google branch POSTs grant_type=refresh_token to
https://oauth2.googleapis.com/token, decodes the returned id_token for the sub
claim, looks up the admin user by googleId, enforces tenant scope and
ACCESS_ADMIN, and mints a fresh LibreChat JWT in the same response shape
/oauth/exchange returns. It is gated on GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET being set (returns 503 GOOGLE_NOT_CONFIGURED otherwise);
unknown provider values return 400 INVALID_PROVIDER.
2026-06-18 07:54:30 -07:00
Dan Lew
743f57f63e
🔖 feat: Add Pinned Conversations (#13492)
* feat: add `convo.pinned`

We want to be able to pin convos (so users can easily find them), thus we
added a new field to the DB schema: `pinned`.

We also had to add an API method for pinning a convo. It's got thorough tests.
It's structured just like how /api/convos/archive works, only for pinning.

* feat: add 'pinned' section to conversation list

If there are any pinned conversations, they will appear above the normal
"chats" list, with a pinned icon next to them.

* feat: added pin/unpin to convo options

ConvoOptions now has a pin/unpin button which lets you change the
pin status of any given conversation.

* fix: adjust ellipsizing gradient on ConvoLink

Because it went across the whole ConvoLink, it would cover up any
children (i.e. icons) that appear after the title. However, the point
of the gradient is just to gradually make the title disappear, not
the icons.

This change places the gradient on the title only, so it achieves
the same ellipsizing effect without interfering with the display of
the child icons.

* Fixed import sorting
2026-06-17 20:26:55 -04:00
Danny Avila
49f4b659f6
🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart (#13814)
* 🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart

MCPServersRegistry was built once at boot from getAppConfig({ baseOnly:
true }), freezing allowedDomains/allowedAddresses to YAML. Admin-panel
mcpSettings overrides were ignored by both inspection (addServer/
reinspectServer/updateServer/lazyInitConfigServer) and runtime connection
enforcement (assertResolvedRuntimeConfigAllowed), so a domain allowed only
via the panel failed inspection and never connected.

Make the registry's effective allowlists mutable and refresh them from the
merged admin-panel config: seed at boot, and re-apply on every config
mutation via invalidateConfigCaches -> clearMcpConfigCache. Both inspection
and connection paths read the same getters, so both honor overrides without
a restart. Fail-safe: current allowlists are preserved when the merged read
fails.

* 🛡️ fix: Scope MCP allowlist refresh to global config, fail-safe on DB error

Address Codex P1 review findings on the allowlist-refresh path:

- Tenant-scoped config mutations no longer push one tenant's merged
  mcpSettings into the process-wide registry singleton (read by all MCP
  connection paths), which would leak allowlists across tenants. Only
  global (non-tenant) mutations refresh the registry; tenant mutations
  still evict the config-server cache.
- The refresh read now uses strictOverrides:true so a transient DB error
  throws instead of silently returning YAML base config — preserving the
  last-known allowlists rather than overwriting them with fallback values.
  Adds the strictOverrides option to getAppConfig (default off, no behavior
  change for existing callers).

* ♻️ refactor: Resolve MCP allowlists per-request (tenant-scoped) instead of a global singleton

Supersedes the prior global-mutation approach. MCP allowlists live in
mcpSettings, which is tenant/principal-scoped admin config, so a process-wide
singleton value is the wrong model — it caused cross-tenant bleed and stale
reads.

Instead, inject a resolver (from the app layer, where the merged config lives)
that the registry calls per inspection and per connection. It reads the ALS
tenant context via getAppConfig and accepts the acting user so user/role-scoped
overrides resolve; config-source inspection (no user) resolves at tenant scope.
Falls back to the YAML base allowlists when no resolver is set or the lookup
fails, so a transient error fails to the operator baseline rather than
disabling the allowlist.

Removes the now-unnecessary setAllowlists / boot-seed / invalidateConfigCaches
refresh / getAppConfig.strictOverrides machinery.

* 🔒 fix: Scope config-source cache by allowlist; resolve OAuth allowlists per-request

Address Codex review of the per-request resolver:

- Config-source cache key now folds in the resolved allowlists, not just the
  raw-config hash. Inspection results became allowlist-dependent, so without
  this a tenant whose allowlist rejects a URL could poison the shared key with
  an inspectionFailed stub for a tenant that allows it (and vice versa). The
  tenant-scoped allowlist is resolved once per ensureConfigServers pass and
  threaded through the cache key + inspection.
- The two remaining request-time OAuth allowlist reads now use the merged
  config instead of the YAML base getters: the fallback OAuth-initiate path
  (routes/mcp.js) via resolveAllowlists, and OAuth revocation
  (UserController.maybeUninstallOAuthMCP) via the request's already-merged
  appConfig.mcpSettings. Without this, an OAuth endpoint allowed only by an
  admin-panel override was rejected while inspection/connection allowed it.

*  test: Update MCP OAuth registry/config mocks for per-request allowlists

CI fix for the Finding-12 change. The OAuth-initiate route now calls
registry.resolveAllowlists() and the revocation path reads the merged
appConfig.mcpSettings, so the affected specs' mocks were asserting the old
base-getter values:
- routes/__tests__/mcp.spec.js: add resolveAllowlists to the registry mock.
- UserController.mcpOAuth.spec.js: provide mcpSettings on the getAppConfig
  mock so revokeOAuthToken still receives the expected allowlists.

* 🧪 test: e2e proof that admin-panel MCP allowlist override takes effect

Adds a Playwright mock-harness spec for #13809. A URL-based MCP fixture
(e2e-http, streamable-http SDK server) boots inspectionFailed because its
origin is omitted from the YAML mcpSettings.allowedDomains; the spec adds that
origin via an admin config override (PUT /api/admin/config/user/:id) and
asserts the server reinitializes — exercising the real resolver path through
the backend + DB. Before the fix, reinspection used the frozen YAML allowlist
and the server stayed unreachable.

- e2e/setup/fake-mcp-http-server.js: streamable-HTTP MCP fixture (health GET /).
- e2e/playwright.config.mock.ts: boot the fixture as a second webServer.
- e2e/config/librechat.e2e.yaml: mcpSettings.allowedDomains (excludes 127.0.0.1)
  + the e2e-http server.
- e2e/specs/mock/mcp-allowlist-override.spec.ts: login → baseline reinit fails →
  apply override → reinit succeeds.
2026-06-17 20:14:53 -04:00
Danny Avila
fdc7e64bb7
🪙 feat: SDK-Aligned Context-Usage Projection (gauge for window-switch & snapshot-less branches) (#13801)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🪙 feat: Context-usage projection — data-provider + client wiring

Consumer side of the SDK-aligned context projection (agents
`projectAgentContextUsage`). Adds the `/api/endpoints/context-projection`
data-provider plumbing (endpoint, service, query key, `TContextProjectionRequest`)
and a `useContextProjectionQuery` gated to fire only when no fresh snapshot
covers the viewed branch.

Wires `useTokenUsage` precedence to: live snapshot → fresh persisted snapshot
(window matches the resolved one) → server projection → per-message estimate.
A model/window switch marks the baked snapshot stale (its `maxContextTokens`
no longer matches) and falls to the projection — closing the gauge's
window-switch (G1) and snapshot-less-branch (G2) gaps. Snapshot and projection
share the render-relevant fields, so they render uniformly.

Backend endpoint + agents version bump land in follow-up commits. Includes the
design spec (CONTEXT_PROJECTION_SPEC.md).

* 🪙 feat: Context-projection backend endpoint

POST /api/endpoints/context-projection → resolveContextProjection (packages/api):
reconstructs the viewed branch (parent-chain walk from messageId), resolves the
agent config (instructions/provider/model/maxContextTokens), reuses LibreChat's
stored per-message tokenCounts as the index map (no re-tokenizing), and calls
the agents SDK projectAgentContextUsage — no model call. Thin controller injects
db.getMessages/db.getAgent; route mirrors /token-config.

First cut targets message-windowing accuracy; tool-schema tokens are deferred to
a follow-up that reuses the full initializeAgent path.

* 🩹 fix: Codex review on context projection (G1 guard, IDOR, recount, summary)

- Guard `currentActive` against a stale window: a model/window switch on the
  current branch left the live snapshot outranking the projection (G1 didn't
  fire). Now defers to the projection unless streaming or the window matches.
- Scope branch lookups to the authenticated user (`getMessages` filter +
  injected `userId`) — was loading any conversation by id (IDOR).
- Recount messages with no stored `tokenCount` via the tokenizer instead of
  charging 0, so snapshot-less/imported histories don't under-report.
- Fall back (null) for already-summarized branches rather than projecting from
  the full raw parent chain (the next call would send summary + tail); the
  client's summary-baseline-aware estimate handles them until a follow-up
  replays the summary boundary.

* 🩹 fix: Codex round 2 — drop agent load, summary marker, edit-invalidation

- Stop loading agent/model-spec config server-side (closes the agent-access
  IDOR and the spec-prompt special-casing). Provider/model/window now come from
  the client-resolved request (`limits.endpoint`/model — the agent's real
  provider, not the `agents` endpoint, so the tokenizer is right). Agent/spec/
  promptPrefix instructions are uniformly deferred to the full-fidelity follow-up.
- Detect summarized branches via the live path's `metadata.summaryUsedTokens`
  marker (was the wrong `summaryTokenCount` field) and fall back to the
  summary-aware estimate.
- Invalidate the projection query on in-place message edits via a branch
  content `revision` in the cache key (the tail id is unchanged on edit).

Deferred (valid, not a regression): same-window endpoint/model switch keeps a
window-matched snapshot — needs endpoint/model persisted on the snapshot, which
lands with the fidelity follow-up. Smoke-tested: fits / prunes / summarized→null
/ no-window→null.

* 🛡️ fix: make context projection strictly additive (no-regression)

Revert the G1 window-match guard on the live/branch snapshot. When no explicit
maxContextTokens is set (the common default), the SDK's snapshot window is
reserve-derived (~0.9·(modelContext − maxOutputTokens)) while useTokenLimits
resolves the raw model context — so `snapshot.maxContextTokens === resolvedMax`
is false for the SAME model, and the guard would wrongly drop a valid
current-branch snapshot to projection/estimate post-stream (a regression in the
default case, per initialize.ts:1240-1243).

The projection now activates ONLY for snapshot-less branches (G2): the
precedence is live snapshot → persisted branch snapshot → projection → estimate,
where the first two are byte-for-byte the prior behavior and the projection just
slots ahead of the estimate. Window/model-switch (G1) detection needs the
snapshot to carry its model/window and defers to the fidelity follow-up.

* 🩹 fix: surface projections as estimates, not authoritative snapshots

A first-cut projection carries the SDK's windowing but omits instruction/tool
overhead, so rendering it as `isEstimate: false` showed a confident under-count
for snapshot-less branches. Mark projection-sourced views `isEstimate: true` +
`snapshotActive: false` (and drop the snapshot field) so they present as a
better estimate than sumBranch — improved used/window number, estimate framing,
no misleading granular breakdown with ~0 tools. Real snapshots stay
authoritative. (Codex round 3, projection.ts:139.)

* 🧹 chore: drop CONTEXT_PROJECTION_SPEC.md from the PR

* 🎨 style: fix import-sort order in projection.ts (CI sort-imports check)

* 🔧 chore: update @librechat/agents dependency to version 3.2.36 in package-lock.json and related package.json files

* chore: npm audit fix

* 🎨 style: fix import-sort order in data-service.ts (CI sort-imports check)

* 🩹 fix: drop dead calibrationRatio in projectionParams (tsc never error)

Inside the ternary, branchSnapshot is narrowed to null (the gate is
), so  accessed a
property on  (frontend typecheck failure). It was also dead — there is
never a snapshot to seed from in this branch — so just remove it.

* Revert "chore: npm audit fix"

This reverts commit 4cdb862d0c.
2026-06-16 17:54:13 -04:00
Dustin Healy
054fa4bfa7
🥽 fix: Restrict MCP Server URL Disclosure to Admins, Owners, and Editors (#13784)
* 🥽 fix: Redact Non-User-Sourced MCP Server URLs by ACL Edit Permission

GET /api/mcp/servers and GET /api/mcp/servers/:serverName return MCP server configs to any caller with MCP-use permission. For user-sourced configs (DB-stored, UI-submitted), the URL is the caller's own and is intentionally disclosed. For non-user-sourced configs (YAML or config-tier, operator-defined), the URL and OAuth flow endpoints (authorization_url, token_url) are operator-sensitive: they can encode internal infrastructure hostnames and are not editable through the API.

This change redacts those fields on non-user-sourced configs unless the caller has edit authority on the resource, using the same ACL check (PermissionBits.EDIT) that the PATCH and DELETE routes already enforce via canAccessMCPServerResource. Callers with broad MANAGE_MCP_SERVERS capability bypass the per-resource check, matching the existing capability bypass in canAccessResource. customUserVars is intentionally not redacted: its values are UI hint metadata (title, description, sensitive), not user-supplied secrets; blanking it would give non-editor callers a Configure form with no field labels.

* 🥽 fix: Correct getResourcePermissionsMap import path + tighten redact comments

The MCP server redaction commit imported getResourcePermissionsMap from ~/server/controllers/PermissionsController, but that controller is a consumer of the helper, not its exporter. The canonical export lives in ~/server/services/PermissionService (which controllers/agents/v1.js already imports from). Fixes the runtime getResourcePermissionsMap is not a function failure on GET /api/mcp/servers and the four downstream route-spec failures whose config mocks lacked a source field and were therefore wrongly treated as non-user-sourced; mocks now reflect the real registry behavior (addServer/updateServer tag DB-stored configs with source: 'user'). Trims narrating JSDoc on the redact helpers and resorts the librechat-data-provider destructure by length.

* chore: import order

* 🥽 fix: Redact OAuth Revocation Endpoint Alongside Authorization And Token URLs

The OAuth-URL strip path only dropped authorization_url and token_url. The UserOAuthOptionsSchema in packages/data-provider/src/mcp.ts (line 146) accepts revocation_endpoint as another operator-configurable URL, and the OAuth handler uses it to revoke tokens; it can hold the same internal IdP hostnames the existing strip is trying to hide. Adds revocation_endpoint to the destructure so a non-user-sourced YAML/config MCP server config no longer leaks the revocation URL to non-editor callers. The existing strip url and oauth flow URLs spec is extended with a revocation_endpoint value to lock in the new field.

* 🥽 fix: Gate Shared DB Server URL Disclosure On ACL Edit Permission

source-driven URL disclosure was incorrect for shared DB-backed MCP servers. ServerConfigsDB.mapDBServerToParsedConfig (packages/api/src/mcp/registry/db/ServerConfigsDB.ts:465) sets source: 'user' on every DB-stored config it returns, regardless of who is accessing it. A user with only VIEW share on a DB server, or with agent-mediated access, was therefore treated by the redaction layer as if they owned the URL, and GET /api/mcp/servers disclosed the owner's URL and OAuth flow URLs to viewers who could not edit the resource.

The redaction is now driven purely by ACL edit authority: computeCanEditByServer routes every dbId-bearing config through PermissionBits.EDIT regardless of source; redactServerSecrets strips on !canEdit regardless of source. POST and PATCH controllers explicitly pass canEdit: true since both endpoints establish edit authority (POST creates the resource, PATCH is gated on the EDIT middleware). Legacy/ephemeral configs without a dbId still fall back to the source heuristic.

* 📝 docs: correct redactServerSecrets URL-disclosure comment

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-06-16 11:20:52 -04:00
Danny Avila
d0f659fa75
🗜️ fix: Support Windows ZIP MIME Uploads (#13794) 2026-06-16 11:19:06 -04:00
Ravi Kumar L
fbc990f684
📈 fix: Isolate RUM Telemetry Proxy Auth from App Auth (#13765)
* fix(rum): isolate telemetry proxy auth

* feat(rum): track proxy error metrics

* refactor(rum): simplify proxy auth strategy flow

* test(rum): clarify proxy success metric assertion

* test(metrics): use typed supertest import

* test(metrics): add local supertest types

* test(metrics): keep supertest types local

* test(metrics): use official supertest types

* fix(rum): log proxy auth strategy errors

* fix(rum): classify proxy auth errors in metrics

* style(rum): sort telemetry metric imports

* ci: mention import sort check command

* ci: show targeted import sort example
2026-06-15 12:49:44 -04:00
Danny Avila
b03b2a0a29
💾 feat: Persist Context Breakdown & Branch/Total Usage Cost (#13734)
* 💾 feat: Persist Context Breakdown & Branch/Total Usage Cost

Persist the granular context breakdown and per-response usage/cost on the
response message metadata, and re-derive branch + total usage/cost from a
per-message index so the popover survives reloads and is branch-aware live.

- Add aggregateEmittedUsage + buildPersistedContextUsage helpers in
  packages/api; capture the latest visible snapshot and every emitted
  on_token_usage payload via contextUsageSink/usageEmitSink.
- Attach metadata.contextUsage (Part A) and metadata.usage (Part B) on the
  agents response message in sendCompletion.
- Carry per-message usage on the token index; add sumTotalUsage/setEntryUsage
  and branch-scoped usage on sumBranch.
- Repurpose the session accumulator into a single in-flight pending holder;
  flush it into the index at finalize; hydrate breakdowns on load.
- Render branch cost with a conditional all-branches total in the breakdown.

* 🧹 chore: Remove orphaned com_ui_session_cost i18n key

* 🩹 fix: Address Codex review — normalize usage server-side, fix reload deltas

- Persist per-event-normalized display units in metadata.usage (TResponseUsage)
  so reloaded mixed-provider turns match the live session; client reads them
  directly instead of re-normalizing with a single stamped provider (P2).
- Persist completedOutputTokens (final call output) on metadata.contextUsage so
  a reloaded multi-call turn adds the post-snapshot delta, not the full
  tokenCount the snapshot already counts (P2).
- buildIndex preserves a prior entry's immutable usage when a rebuilt cache
  message lacks metadata.usage, so a mid-session rebuild (regenerate) keeps a
  sibling branch's flushed cost (fixes the e2e regenerate failure).
- Track costKnown so turns saved with contextCost off don't render $0.00 when
  cost display is later enabled (P3).
- Use an epsilon for the all-branches cost comparison to avoid a spurious total
  row from float summation order (P3).
- Update unit/integration/e2e tests for the new shapes; regenerate e2e asserts
  the all-branches total after reload (deterministic via persisted metadata).

* 🩹 fix: Address Codex round 2 — pending leak, cost coverage, reload delta

- Clear the in-flight pending usage on terminal abort/error (resetLive), so a
  stopped generation's tokens no longer merge into the next response (P2).
- costKnown now means COMPLETE coverage (ANDed): a branch mixing cost-bearing
  and cost-less turns is flagged incomplete and the cost row is hidden rather
  than rendering an under-reported total (P2).
- Drop the tokenCount fallback for completedOutputTokens on reload: only the
  persisted post-snapshot delta is used, so a multi-call turn whose provider
  emitted no usage_metadata no longer double-counts earlier output (P2).
- Update tokens.spec for AND coverage semantics + incomplete-cost case.

* 🩹 fix: Address Codex round 3 — no-usage snapshots, total coverage, provider-less cache

- Skip persisting metadata.contextUsage when the response emitted no primary
  usage event: without a known post-snapshot output the granular gauge would
  undercount the reply on reload, so fall back to the coarse per-message
  estimate instead (P2).
- Gate the all-branches cost row on totalUsage.costKnown so an incomplete total
  (a sibling saved without cost) never renders an under-reported figure (P2).
- aggregateEmittedUsage/finalCallOutputTokens now normalize per-event with the
  client's magnitude fallback (normalizeEventUnits) instead of billing
  splitUsage, so provider-less cached events match live on reload (P2).
- Add backend test for the provider-less cached case.

* 🩹 fix: Address Codex round 4 — abort attribution, complete cost coverage

- aggregateEmittedUsage persists cost only when EVERY call was priced; a partial
  pricing failure now omits cost so the client treats coverage as unknown rather
  than reading an under-reported sum as authoritative (P2).
- finalizeUsage flushes pending into the response entry only when events were
  folded this session (eventCount > 0), so a late/second resumable subscriber
  carrying persisted metadata.usage keeps it instead of being overwritten with
  an empty pending record (P2).
- On user stop, attribute the in-flight pending usage to the partial response
  (new attributePending handler) instead of discarding it in resetLive — the
  stopped reply's billed tokens are kept and still can't leak into the next
  response; resetLive's discard remains for the error path (P2).

* 🐛 fix: Persist branch cost across branch switches via sticky usage history

Branch cost vanished on switching to a sibling branch (until a new turn) — the
cost analog of the granularity bug. buildIndex rebuilds the token index from the
messages cache; a sibling generated this session whose cache message lacks
metadata.usage (and is transiently dropped from the cache during regenerate)
lost its live-flushed usage, so sumBranch found none and the cost row hid.

Fix: a sticky per-response usage map (conversationId → messageId → usage),
written by setEntryUsage and never rebuilt from the cache — the usage counterpart
of snapshotsByAnchorFamily for the breakdown. buildIndex/upsertEntries restore an
entry's usage from it when the message carries none; cleared on convo switch and
migrated with the index. Add unit coverage for the drop-then-readd regression and
an e2e assertion that branch cost survives a branch switch.

* 🐛 fix: Re-index on branch switch so branch cost survives the switch

The sticky usage history alone didn't fix the reported branch-switch cost drop:
on a branch switch no cache `updated` event fires, so the index subscriber never
re-ran, and the post-regenerate rebuild was skipped while `isSubmitting` was
still true — leaving the index stale and missing the now-viewed branch's
response entirely (sticky can only restore entries present in a rebuild).

Re-index from the messages cache on every tail change (created/finalize AND
branch switch), not just while submitting. The cache holds the full message set
at switch time, so the viewed branch's response is re-added and its usage
restored from metadata.usage or the sticky history → sumBranch finds it and the
branch cost renders. Verified locally: the branch-switch e2e now passes (the
cost section shows both the branch row and the all-branches total). Also fixed
that e2e assertion to target a single cost value (strict-mode safe).

* 🩹 fix: Handle stopped-stream usage — reset pending + persist abort metadata

Codex round (stop/abort edges):
- Resumable explicit-stop (intentional SSE close) reset UI state but never
  cleared pendingUsageFamily, so usage folded before the stop leaked into the
  next response in the conversation. Discard pending on intentional close
  (resetLive); a resume re-folds via backfillUsage, so nothing is lost.
- The abort save path (abortMiddleware) persisted the stopped response without
  metadata.usage/contextUsage, so its cost + breakdown vanished on reload.
  Rebuild both from the job's persisted tokenUsage (emitted payloads incl. cost)
  and contextUsage snapshot — parity with the normal sendCompletion path;
  breakdown gated on a primary usage event like buildResponseMetadata.

Deferred (per scope decision): mid-stream branch-switch transiently shows the
streaming branch's pending on the viewed sibling (cosmetic, until finalize).

* 🩹 fix: Persist abort metadata on the real agents route + tighten snapshot gate

Codex round (corrects last round's wrong-path fixes):
- Stopped AGENTS responses are saved by routes/agents/index.js (/chat/abort),
  not abortMiddleware — so last round's metadata fix never ran for them. Moved
  the rollup/snapshot builder into packages/api as buildAbortedResponseMetadata
  (shared, unit-tested) and applied it in BOTH abort save paths, so a stopped
  agent reply keeps its cost + breakdown on reload.
- Persist the breakdown only when the FINAL visible call emitted usage: track a
  per-response snapshot count and require primaryUsageCount >= snapshotCount.
  Previously any earlier primary usage event passed the gate, so a multi-call
  turn whose final call emitted no usage_metadata used an earlier call's output
  as completedOutputTokens (already counted by the latest snapshot) → reload
  over-reported. Now it falls back to the coarse estimate.

Resumable stop pending-reset (prior round, 3cde6fe035) already flows through
clearAllSubmissions → SSE close → the intentional-close handler's resetLive.
Deferred per scope: mid-stream branch-switch pending attribution (tracked).

* 🩹 fix: Abort breakdown over-count + resume re-fold after pending discard

Codex round (on the re-applied abort/snapshot work):
- buildAbortedResponseMetadata now persists ONLY the usage/cost rollup, not the
  context breakdown. The abort path can't tell whether the final call emitted
  usage (the job stores only the latest snapshot, not a count), so persisting
  the breakdown risked reusing an earlier call's output as completedOutputTokens
  (already in the snapshot) → reload over-count. Stopped/incomplete responses
  now fall back to the coarse gauge estimate, which is safe and apt.
- resetLive now also forgets the conversation's folded usage-event identities
  (clearUsageFolded). Discarding pending on a terminal/intentional close left
  the folded keys set, so a later resume's backfillUsage saw the persisted
  events as duplicates and never rebuilt pending — leaving the response's usage
  missing until a full reload. Clearing them lets the resume re-fold.
2026-06-14 10:48:07 -04:00
Danny Avila
db7011d567
📊 feat: Real-Time Context Window & Token Usage Tracking (#13670)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 📊 feat: Real-Time Context Window & Token Usage Tracking

* 🧪 fix: Align Pricing Spec Dep Signatures with TxDeps

* 🩹 fix: Resolve Codex Findings for Context Usage Tracking

* 📊 feat: Granular Tool Token Breakdown with Deferred Splits

* 🧪 test: Cover Session Cost in Mock E2E and Scope Usage Selectors

* 🧪 test: Live Host-Pipeline Usage Verification (Env-Gated)

* 🧪 test: Local Real-Provider Multi-Turn E2E Harness

* 🪙 fix: Keep Tagged Usage Buckets Out of the Live Context Estimate

* 🩹 fix: Scoped Token-Config Fallback and Sequential Visibility for Usage Events

* 🩹 fix: Address Usage Review Findings — Cost Timing, Scoped Caches, Finalized Output

- carry the post-snapshot output estimate into the context snapshot at
  finalize so the gauge keeps the last response after live resets
- accumulate per-rate billable units and price the session cost at
  render, so usage events arriving before the token-config load still
  count once it resolves
- pass user-scoped token-config cache keys through loadConfigModels
  fetches and drop the controller's unscoped fallback to prevent serving
  another user's resolved config
- tag emitted usage events with a per-run seq so resume dedupe never
  drops a distinct call with an identical payload
- admit the static tokenConfig override in the custom endpoint schema so
  it survives zod parsing into req.config

* 🩹 fix: Align Client Usage Accounting with Backend Cost Semantics

- classify cache tokens by provider (shared inputTokensIncludesCache from
  data-provider, consumed by both the backend billing path and the client)
  instead of a magnitude heuristic, so Anthropic/Bedrock turns where cache
  is smaller than uncached input no longer under-bill input
- mirror resolveCompletionTokens on the client so Vertex-style hidden
  thinking tokens are reflected in the Output row and session cost
- prefer endpoint pricing over adapter-provider pricing so a custom
  endpoint can price a known model name without built-in rates shadowing it
- carry static cacheRead/cacheWrite overrides through the tokenConfig
  schema and buildTokenConfigMap

* 🩹 fix: Honor Static Token Config in Billing; Tighten Usage Freshness

- initializeCustom now uses a static endpoint tokenConfig as the agent's
  endpointTokenConfig (billing + balance checks), not just the advertised
  UI config — previously the gauge showed admin rates while the agent
  billed against built-in tables
- invalidate the token-config query alongside models on user-key add/
  revoke so context windows and pricing refresh without a reload
- include maxContextTokens in ChatForm's stabilized conversation memo so
  the gauge reflects a changed context-window setting immediately
- feed the live output estimate from the legacy content path (direct and
  assistants streams), setting from cumulative part text rather than
  accumulating deltas

* 🩹 fix: Resume Usage Dedup, Agent Pricing, and Partial Override Billing

- fold usage events idempotently by (runId, seq) so resume backfill no
  longer resets the conversation totals — a mid-stream reconnect keeps the
  usage of prompts already completed earlier in the session
- tap replayed pending message/reasoning/content events so output streamed
  past the resume snapshot reaches the live estimate, not just the message
- resolve cost against the agent's backing endpoint (Agents conversations
  report endpoint `agents` / provider `openAI`, neither of which keys a
  custom endpoint's tokenConfig)
- getMultiplier/getCacheMultiplier fall back to the standard tables for
  models absent from a partial endpointTokenConfig, so a partial static
  override no longer bills non-listed models at defaultRate while the UI
  shows the correct pattern rate

* 🩹 fix: Repaired Output in Gauge, Cache-Rate Keys, Config Gate, Usage Cleanup

- live/completed gauge counts the repaired completion (normalized output),
  so under-reporting providers don't drop the response from used context
- translate static tokenConfig cacheWrite/cacheRead onto the write/read
  keys getCacheMultiplier reads, so cache tokens bill at the configured
  rate instead of the prompt-rate fallback
- clear the token index and usage atoms when leaving a conversation, so
  visited histories don't accumulate in memory for the tab's lifetime
- wait for startupConfig before mounting the gauge, so a deployment with
  contextUsage disabled never briefly mounts it or fires the token-config
  query on first load

* 🩹 fix: Move Token-Config Resolution to TS; Key Live Usage by Created Convo

- extract the token-config resolution (override gathering + cache lookup +
  buildTokenConfigMap) into resolveTokenConfigMap in packages/api, leaving
  the /api controller a thin request-scoped wrapper (CLAUDE.md TS rule)
- getConvoKey prefers the user message's real conversationId once the
  `created` event stamps it, so a new chat's first-response live gauge and
  totals land under the id TokenUsage subscribes to instead of NEW_CONVO

* 🩹 fix: Clear Stale Redis Job Usage; Live-Tap Legacy Streams; Share Fetched Config

- DEL the Redis job hash before re-creating it so a reused streamId can't
  inherit a prior run's contextUsage/tokenUsage and backfill stale usage
- tap the legacy {message,text} stream branch (non-agent OpenAI/Anthropic
  streams) into the live estimate, not just the content path
- copy a deduped fetch's token config to every sibling endpoint sharing the
  baseURL/key/headers, so /token-config resolves each by its own name

*  revert: Don't DEL Redis job hash in createJob (breaks cross-replica resume)

createJob is an idempotent join — a second replica calls it for the same
streamId to share an in-flight stream's state. DELeting the hash wiped the
prior replica's persisted created/usage state, so a joining replica missed
the created event (GenerationJobManager cross-replica integration test).
Reverts the F1 change from 2bfce0c34b; the stale-usage concern doesn't
arise in practice (streamId is unique per generation).

* 🩹 fix: Best-Effort Usage Emit; Tag Hidden Sequential-Agent Usage

- wrap the ModelEndHandler usage emit in try/catch so a failed telemetry
  delivery (closed SSE / Redis publish error) can't abort the handler
  before thought-signature capture, which would break resumed tool calls
- tag hidden sequential-agent usage as 'sequential' (non-primary) so the
  client folds it into session cost/totals but not the live context gauge,
  instead of letting an undefined usage_type inflate the visible gauge

* 🩹 fix: Refetch Stale Token Config on Mount; Normalize Vertex for Lookup

- useTokenConfigQuery refetches on mount when stale, so a user-key change
  that invalidates tokenConfig while the gauge is unmounted takes effect on
  return instead of serving the prior key's resolved config
- normalize a Vertex-backed agent's provider (vertexai) to the google
  token-config key, so Gemini context windows and rates resolve instead of
  showing unknown context / $0 cost

*  feat: Server-Side Per-Event Cost (Authoritative Pricing for the Gauge)

Move usage-cost pricing to the single source of truth. The backend prices
each model call with the same billing functions (premium tiers via
getMultiplier(inputTokenCount), cache rates) and emits the USD cost on
on_token_usage when interface.contextCost is enabled; the client sums
emitted costs instead of re-deriving from base token-config rates.

- computeUsageCostUSD reuses prepareTokenSpend/prepareStructuredTokenSpend
  so the emitted cost matches what is billed (incl. premium thresholds)
- getDefaultHandlers gains a usageCost pricing context; initialize.js wires
  db.getMultiplier/getCacheMultiplier gated on contextCost (agents path)
- client UsageTotals carries a summed costUSD; retire the client-side rate
  lookups (costFromUnits/calcUsageCost) that drifted from backend pricing
  and produced the provider-keying / cache-key / Vertex / premium findings
- keep normalizeUsageUnits for the displayed token counts; token-config is
  still used for the context-window meter

Fixes the premium-tier session-cost under-report (gpt-5.x / gemini-3.1
above their input thresholds).

* 🩹 fix: Branch-Accurate Usage Snapshot + Clearer Gauge Track Contrast

- re-anchor the context snapshot from the user message to the response
  message at finalize. Regenerating a response branches off a shared user
  message, so anchoring on it made the snapshot read as "active" on both
  branches — switching to the sibling branch showed the wrong (other
  branch's) context. The response message is branch-unique, so sibling
  branches now correctly fall back to their own per-branch totals.
- raise the gauge ring's track/fill contrast (muted track, prominent fill)
  so the used portion reads clearly as a fill-level indicator

* 🩹 fix: Tag Sequential Usage in Billing; Emit Subagent Cost; Reset Live on Resume Errors

- tag hidden sequential-agent usage `usage_type: 'sequential'` on the
  COLLECTED usage (not just the emit), and treat it as non-primary in
  recordCollectedUsage (billed, excluded from the reported output total) so
  hidden intermediate output stops inflating the parent's tokenCount/pruning
- emit on_token_usage from the subagent usage sink (tagged `subagent`, with
  authoritative cost when contextCost is on) so the gauge's session
  cost/totals include billed subagent usage; it stays out of the live meter
- call resetLive on the resumable 404 and max-retry terminal branches so the
  gauge doesn't keep counting stale in-flight tokens after the stream ends

* 🎨 fix: Contrast the Popup Context Bar; Revert Ring Restyle

- raise the popup breakdown's context progressbar contrast (muted
  surface-tertiary track, prominent text-primary fill) — that's the bar the
  contrast feedback was about
- revert the gauge ring restyle (kept its original border-heavy track /
  text-secondary fill); the ring wasn't the element in question

* 🩹 fix: Stop Snapshot Granularity Leaking Across Branches; Revert Tree Memo

- a null-anchor context snapshot was treated as active on every branch,
  leaking one generation's granular breakdown onto sibling branches. Require
  a non-null (response-message) anchor on the viewed branch instead, so
  siblings without a matching snapshot fall back to their own totals.
- revert the buildTree WeakMap memo in messages.ts. buildTree is pure (builds
  from shallow copies) so the memo was behaviorally identical, but it was the
  feature's only change to core branch-navigation selectors — removing it
  matches upstream and rules it out of branch-navigation debugging.

* 🪙 fix: Thread Endpoint Token Config to Agent Billing, Cost, and Context Limits

Custom-endpoint agents resolve an endpointTokenConfig during agent init but
it never reached the AgentClient, so spending, emitted cost, and runtime
max-token resolution all fell back to default rates for those agents.

- Surface options.endpointTokenConfig on the returned InitializedAgent.
- Pass it to the AgentClient (this.options.endpointTokenConfig) so the
  spending path bills at configured rates.
- Thread it through usageCost to computeUsageCostUSD so emitted per-event
  cost matches billing.
- getModelMaxTokens/getModelMaxOutputTokens fall back to the built-in map
  for models absent from a partial override (matches buildTokenConfigMap);
  consolidates the duplicated fallback in pricing.ts.

* 🪙 fix: Preserve Granular Breakdown Across Branch Switches

The granular context breakdown lives only in the live on_context_usage
snapshot — a single per-conversation slot, anchored to the latest response
and overwritten by each generation. Switching to a branch generated earlier
this session lost its tool/skill/system rows and fell back to coarse totals.

Retain each generation's finalized snapshot in a per-conversation map keyed
by its branch-unique response id (snapshotsByAnchorFamily). When the live
snapshot is off the viewed branch, walk the branch tail for its deepest
stored anchor and render that breakdown. Bounded by generation count and
cleared on conversation switch; the live/just-generated path is unchanged.

* 🪙 fix: Harden Resume Seeding and Subagent Usage Emission

- useResumableSSE: skip the trailing-output live seed when the resume
  carries a context snapshot; the snapshot's messageTokens already counts
  produced output, so seeding it again inflated usage until the next reset.
- AgentClient subagent emitter: await GenerationJobManager.emitChunk like
  every other caller (it persists before publishing), so a floating promise
  can't race job cleanup and a Redis/publish failure is caught by the
  emitter's try/catch instead of surfacing as an unhandled rejection.

* 🧪 test: Playwright Coverage for Context Breakdown Granularity

Add a test-only data-testid distinguishing the granular snapshot breakdown
(context-breakdown) from the coarse message-history estimate
(context-estimate), then assert granularity in the mock e2e harness:

- renders the granular breakdown from the live on_context_usage snapshot
  (guards that the snapshot event actually reaches the popover, not just the
  usage totals).
- preserves the granular breakdown after switching branches — regenerate to
  overwrite the single live snapshot, switch back, and confirm the rows
  survive via the per-anchor snapshot history map.

Branch regenerate/sibling selectors mirror the existing chat.spec branch test.
All three usage specs pass against the mock pipeline.

* 🪙 fix: Correct Resume Live-Seed, Fallback Re-index, and Subagent Emit Flush

Codex round on the prior commit:

- countTrailingOutputChars now counts only output at the very END of the
  aggregated content (0 when the model paused at a tool call), and the resume
  path always seeds it. The earlier skip-trailing-tool-parts behavior plus the
  skip-seed-when-snapshot gate together over- or under-counted in-flight
  output on resume; one rule fixes both — pre-invoke snapshot budget is never
  double-counted, and genuine in-flight output is no longer dropped.
- useTokenUsage re-indexes from the messages cache on tail change while
  submitting. The cache subscriber is muted during streaming, so without a
  context snapshot (non-agent streams) sumBranch missed the created tail and
  dropped history + prompt until finalize. Bounded — tailId only shifts on
  created/finalize/branch-switch.
- AgentClient tracks subagent usage emit promises and flushes them in
  chatCompletion's finally. The sink fires the emitter without awaiting, and
  resume reads the usage emitChunk persists (HSET), so cleanup must not race
  it or resumed clients miss billed subagent usage.
2026-06-13 19:38:28 -04:00
Danny Avila
49859c04a2
🗄️ fix: Gate Request-Scoped MCP Servers Out of Persistent Tool Cache (#13672)
* 🗄️ fix: Gate Request-Scoped MCP Servers Out of Persistent Tool Cache

PR #13626 established that request-scoped MCP servers (runtime
OPENID/GRAPH/BODY placeholders) must not use the persistent 12h tool
cache, but only gated three of five touchpoints. The panel endpoint
still back-filled the cache and the OAuth callback still wrote to it,
while agent loading read those entries ungated — pinning ephemeral
model-spec/agent toolsets to stale definitions for up to 12h.

Centralize the invariant in createMCPToolCacheService: a getServerConfig
resolver dep gates both writers and a new service-owned getMCPServerTools
read, so every current and future caller is covered. Callers that already
hold the parsed config pass it to skip resolution; the per-call skipCache
flag and duplicated call-site gates are removed in favor of the single
config-based mechanism. Resolution failures fail open to preserve prior
behavior.

* 🩹 fix: Address Codex Review on Cache Gating

- Repair getCachedTools.spec.js, which destructured the relocated
  getMCPServerTools directly from the module; its coverage now lives in
  the service-level tools.spec.ts.
- Resolve the merged (Config-tier-aware) server config in the OAuth
  callback before writing tool definitions, so the cache gate detects
  request-scoped servers supplied via admin Config overlays that the
  base registry lookup cannot see.
- Discover tools actively for request-scoped servers in the panel
  endpoint via ephemeral reinitialization: such servers have no stored
  app/user connections, so the previous getServerToolFunctions fallback
  returned an empty toolset once the cache read was gated.

* 🧵 fix: Address Second Codex Review on Cache Gating

- Resolve the merged server config before the OAuth callback reconnects,
  so the connection itself uses Config-tier overlays rather than only
  the subsequent cache write.
- Pass Config-tier candidates into the panel's request-scoped discovery,
  matching the reinitialize route: reinitMCPServer forwards configServers
  (not the provided serverConfig) to its OAuth discovery fallback.
- Document the accepted read-path trade-off: the gate resolver sees base
  configs only, all writers pass merged configs, so a pre-gating or
  overlay-divergent entry survives at most one cache TTL.

* 🚏 chore: Rework Cache Gating for BODY-Only Request Scoping

After #13673 narrowed requiresEphemeralUserConnection to BODY
placeholders, the central gate follows the predicate unchanged, but the
panel's active discovery no longer serves a purpose: the only remaining
request-scoped class cannot connect outside a chat turn, so the
reinitialization attempt would always fail at the missing-body check.
Remove that path; OpenID/Graph servers are persistent user-scoped again
and flow through the stored-connection and cache lookups as before.

Flip test fixtures that used OPENID placeholders to denote
request-scoped configs over to BODY placeholders.

* 🪟 fix: Check Config Overlays in Agent-Loading Cache Reads

The cache service's registry resolver sees only base YAML/DB configs, so
a BODY placeholder introduced by a request-tier Config overlay was
invisible to the gate on the agent-loading read path: model-spec and
ephemeral-agent expansion could read a leftover persistent entry and pin
stale concrete tool names instead of the mcp_all fresh-discovery path.

Check the raw overlay candidate inline in loadEphemeralAgent and
loadAddedAgent — a pure placeholder scan with no extra IO — and skip the
cache read when the overlay makes the server request-scoped. Widen
UserScopedConnectionConfig so raw (pre-inspection) configs qualify for
the scoping predicates, which only check key presence.

* 🧪 test: Guard Run-Scoped MCP Definition Handoff Boundaries

The original ClickHouse breaker storm regressed precisely at field
pass-through boundaries that unit tests of each end could not see:
initializeAgent dropping mcpAvailableTools from its destructure, and the
agent tool context losing it on the way into ON_TOOL_EXECUTE. Add direct
guards on both hops: the loadTools result must surface on the
initialized agent, and the captured toolExecuteOptions closure must
forward it to loadToolsForExecution.
2026-06-13 11:26:49 -04:00
Danny Avila
197a1dc4e2
🧬 feat: Add GitHub Skill Sync (#13293)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
* feat: Add GitHub skill sync

* fix: Address GitHub skill sync CI

* fix: Harden GitHub skill sync review paths

* fix: Prevent overlapping skill sync runs

* fix: Address GitHub skill sync review findings

* fix: Satisfy Git ref lint rule

* fix: Address GitHub sync review follow-ups

* fix: Match skill frontmatter closing fence

* fix: Address GitHub sync review cycle

* fix: Address GitHub sync review follow-ups

* fix: Harden GitHub skill sync worker

* fix: Format GitHub sync rollback log

* fix: Address GitHub sync review feedback

* fix: Format skill import parse handling

* fix: Coerce scalar skill frontmatter and correct scheduler timer clear

- parse: coerce numeric/boolean name and description scalars to strings instead of dropping them to empty (restores pre-refactor behavior; preserves absent-vs-empty distinction for the when-to-use fallback)
- scheduler: clear the setTimeout handle with clearTimeout rather than clearInterval
- test: cover non-string scalar frontmatter coercion

* fix: Tolerate trailing whitespace after SKILL.md opening frontmatter fence

extractFrontmatterBlock required the opening fence to be exactly '---\n', so an opener with trailing spaces/tabs (e.g. '---   \n') silently dropped all frontmatter even though the closing-fence regex already tolerates it. Match the opener with /^---[ \t]*\n/ for symmetry. Addresses Codex P3 (parse.ts:24).

* feat: Run GitHub skill sync under a per-source tenant context

Under TENANT_ISOLATION_STRICT, the sync ran with no async tenant context, so the tenant-isolation mongoose hooks threw on every Skill/SkillFile/AclEntry operation; in non-strict mode synced skills were written tenant-less and never matched tenant-scoped reads. Add an optional per-source tenantId to the skillSync config; when set, each source sync runs inside tenantStorage.run({ tenantId }) so skills, files, and public ACL grants are created and listed within that tenant, and the skill row is stamped with the tenantId for correct dedup. Sources without tenantId keep the prior single-tenant behavior. Avoids runAsSystem. Addresses Codex P2 (sync.js:70).

Lock/status/credential bookkeeping stays outside the tenant context (those collections are intentionally global).

* test: Restore dropped tenant-context coverage for GitHub skill sync

The prior commit shipped the getTenantId import in github.spec.ts without the tenant tests that use it (lost in an interrupted edit), which failed the eslint --max-warnings=0 CI job on an unused import. Restore both github.spec.ts tenant tests (tenant-scoped run stamps tenantId and executes inside the tenant ALS context; no-tenant run stays ambient) and the two config-schemas tenant tests (accepts tenantId, rejects __SYSTEM__).

* test: Restore dropped github.spec tenant-context tests

The previous commit's github.spec.ts edit did not apply (anchor mismatch), so the getTenantId import remained unused and failed eslint --max-warnings=0. Add the two tenant tests that use it: a tenant-scoped run stamps tenantId and executes inside the tenant ALS context, and a no-tenant run stays ambient.

* feat: Scope synced skill author to tenant and harden tenant-context sync

Addresses the latest Codex review on the per-source tenant change:
- makeSourceAuthorId now folds tenantId into the synthetic author hash so the
  same source mirrored into different tenants gets distinct author ids (clearer
  audits, no cross-tenant author collisions). Single-tenant author ids stay
  stable (suffix omitted when tenantId is absent).
- syncSourceInTenantContext uses an async callback per the tenant-context
  contract so the ALS store propagates across awaited Mongoose calls.
- Tests: same-source/different-tenant yields distinct authors; mirror cleanup
  is scoped to the source and deletes only its absent-upstream skills.

* fix: Repair tsc error and guard external edits in github skill sync

- Fix TS2352 in github.spec mirror-cleanup test: build the existing-skill mock via makeSkill with authorName instead of an under-typed 'as CreateSkillInput' cast (this was the failing TypeScript CI check on f00ce3c5a).
- 808: commitExistingRemoteSkillAfterFileSync re-reads to clear our own file-sync version bumps, but now compares refreshed content against the pre-sync snapshot (body/name/description/always-apply) and throws SKILL_CONFLICT on a concurrent external edit instead of overwriting it.

* docs: Note skillSync source tenantId is effectively immutable

Changing/adding/removing a source's tenantId orphans previously mirrored skills in the old tenant (a tenant-scoped sync cannot clean another tenant's data without runAsSystem, which is intentionally avoided).

* fix: Key GitHub skill upstream identity on source id and path only

Addresses Codex finding (github.ts:217): makeUpstreamId previously included owner/repo, so repointing a source to a renamed or replacement repository (same source id) changed the upstreamId, made findSkillBySourceIdentity miss the existing mirror, and then collided on the (name, author, tenantId) uniqueness constraint — leaving the source stuck failing. Identity now keys on the stable source id + root path only. The feature is unreleased, so there is no stored-id migration. Updated spec upstreamId fixtures to the new format; the existing ref-independent identity test now also covers repo moves.

* fix: Scope GitHub skill mirror deletion to the source tenant

Addresses Codex P1 (github.ts:1047/1057): an ambient source (no tenantId) runs listSkillsBySource without tenant context, which under non-strict isolation returns github-synced skills across all tenants. The mirror-deletion pass then treated other tenants' skills as absent-upstream and could delete them. Filter existingSyncedSkills to rows whose tenantId matches the source's configured tenantId (absent = its own ambient bucket) before deleting, so a sync never removes another tenant's mirrored skills. Covered by a test where an ambient run leaves a tenant-b-owned skill untouched.

* fix: Apply tenant-scoped mirror deletion implementation

The prior commit (75ccfa3fc) added the test but the source change to github.ts was lost in an interrupted edit, leaving a failing test with no implementation. This adds the actual guard: the mirror-deletion pass skips skills whose tenantId does not match the source's configured tenantId (absent = ambient bucket), so an ambient source whose listSkillsBySource returns cross-tenant rows under non-strict isolation cannot delete another tenant's mirrored skills.

* fix: Resolve global access role outside tenant context for synced skill grants

Addresses Codex P2 (github.ts:1166): default access roles (incl. skill_viewer) are seeded globally with no tenantId under runAsSystem, but a tenant-scoped sync wraps ensurePublicViewer in the source's tenant context. The PermissionService grantPermission resolved the role via a tenant-isolated AccessRole query, so the global role did not match and tenant-scoped syncs failed with 'Role skill_viewer not found'. The sync adapter now resolves the role inside runAsSystem (matching the global seed) and writes the ACL entry in the active tenant context, so the AclEntry is tenant-scoped (visible to tenant users) while the role lookup still succeeds. Covered by service tests for the resolve-vs-write split and the missing-role failure.

* fix: Strip placeholder frontmatter booleans and check skill conflict before file sync

- 1083 (github.ts:759): toCleanFrontmatter now drops a non-boolean always-apply (e.g. the 'always-apply:' / 'always-apply: # TODO' placeholder, which js-yaml yields as null). The boolean is already captured in the dedicated alwaysApply field; persisting null left ambiguous frontmatter on the synced skill.
- 1080 (github.ts:1057): for an existing mirrored skill, check for an external content edit (via getSkillById + hasExternalSkillEdit) BEFORE syncSkillFiles mutates the bundled files, so a concurrently edited skill fails fast with SKILL_CONFLICT without partial file rewrites. The post-file-sync check still guards edits that land during the file sync window.
Tests: placeholder always-apply is dropped from synced frontmatter; concurrent-edit conflict leaves files unmutated (no upsert/delete).

* fix: Harden GitHub skill sync review paths

* fix: Reuse moved GitHub skill mirrors

* fix: Scope GitHub sync identity conflicts

* test: Fix GitHub sync conflict mock typing

* fix: Support nested env-backed skill sync

* fix: Keep skill sync config base-only

* fix: Scope GitHub skill identity lookup by tenant

* fix: Harden GitHub skill sync admin gates

* fix: Guard existing skill sync permission grants

* feat: Trigger skill sync from resolved config

* fix: Scope resolved skill sync by tenant

* test: Allow manual skill sync status tenant scoping

* refactor: Extract skill sync trigger orchestrator

* test: Complete orchestrator status fixture

* chore: Bump data provider version

* fix: Restrict skill sync server credentials

* test: Complete admin skill sync status fixtures

* fix: tighten skill sync trigger safeguards

* fix: preserve alwaysApply skill sync alias

* chore: sort skill sync imports

* fix: preserve skill sync request scope

* fix: harden skill sync review edges

* refactor: move skill sync admin access to api package

* fix: add skill sync declaration return types

* fix: satisfy skill sync type checks

* fix: resolve codex skill sync review findings

* fix: harden skill sync review edges

* fix: resolve codex skill sync edge findings

* fix: satisfy API declaration build after rebase
2026-06-10 21:05:54 -04:00
Danny Avila
c27d6b85a4
🤫 refactor: Silent MCP OAuth Refresh on Mid-Session 401 (#13369)
* 🤫 fix: Silent MCP OAuth Refresh on Mid-Session 401

Avoids the hourly interactive re-auth prompt when an MCP server
(e.g. Azure Entra ID) returns 401 mid-session by attempting a refresh
token exchange first, and only falling back to the interactive OAuth
flow when no refresh token is stored or the refresh server rejects it.

Resolves #13364.

* fix: Use distinct flow type for silent token refresh to avoid cache hit

Addresses the Codex review on PR #13369: `attemptSilentTokenRefresh` was
reusing the `'mcp_get_tokens'` flow type, so
`FlowStateManager.createFlowWithHandler` would short-circuit and return
the same tokens cached by an earlier `getOAuthTokens` call — the very
tokens the server just rejected — without executing the forced-refresh
handler.

Switch silent refresh to the distinct `'mcp_force_refresh_tokens'` flow
type so coalescing still works but stale `mcp_get_tokens` cache entries
are not reused. After a successful refresh, invalidate the
`mcp_get_tokens` flow cache so the next `getOAuthTokens` call reads the
freshly persisted tokens from storage rather than the stale cached
value.

Add a regression test that simulates the real
`FlowStateManager.createFlowWithHandler` cache-hit behavior for
`mcp_get_tokens` and verifies the silent refresh handler still runs and
returns the freshly refreshed tokens.

* fix: Address Codex round-2 review on silent MCP OAuth refresh

Three follow-up findings from Codex on PR #13369:

1. The new `mcp_force_refresh_tokens` flow type was itself cached by
   `FlowStateManager.createFlowWithHandler`, so a subsequent 401 within
   the refreshed token's `expires_at` could re-serve the just-rejected
   token without ever re-running the refresh handler.

2. The factory's `oauthRequired` listener was removed immediately after
   the initial `attemptToConnect` succeeded, so a real mid-session 401
   emitted by `MCPConnection.connectClient` during transport recovery
   had no listener — the OAuth handled-promise would simply time out
   instead of triggering the silent refresh.

3. Routing the silent refresh through a distinct flow type broke
   coalescing with the `mcp_get_tokens` lock used by `getOAuthTokens`,
   letting two paths concurrently redeem the same stored refresh token.
   For providers that rotate refresh tokens (e.g. Azure Entra) the
   second redemption is rejected, kicking the user back into interactive
   OAuth despite a successful refresh elsewhere.

Resolution:

- Drop `FlowStateManager` from the silent-refresh path entirely. Replace
  with a process-local `inflightSilentRefreshes` Map keyed by
  `userId:serverName` that holds only the in-flight Promise (no cached
  result), so every fresh 401 after settlement triggers a fresh
  redemption while concurrent 401s for the same user/server still share
  one redemption.
- Stop calling `cleanupOAuthHandlers()` on successful initial connect,
  keeping the OAuth handler attached for the connection's lifetime so
  mid-session 401s actually reach `attemptSilentTokenRefresh`.
- Add a regression test reproducing the stale-cache scenario by faking
  the `mcp_get_tokens` cache hit and asserting silent refresh still runs
  against storage and returns the fresh tokens.
- Add a coalescing test asserting two concurrent oauthRequired events
  for the same user/server result in a single `forceRefreshTokens` call.
- Clear `inflightSilentRefreshes` in `beforeEach` to prevent
  cross-test leakage; switch the silent-refresh test mocks to
  `mockResolvedValueOnce` / `mockImplementationOnce` so leftover mock
  state cannot leak into later test cases.

Acknowledged remaining gap: the silent refresh still races
`getOAuthTokens`'s `mcp_get_tokens` flow when both run concurrently
(narrow window when an existing connection's local `expires_at` is
still valid but the server invalidated the token, and a new connection
is being created in parallel). The race is self-healing on the next
401 and documented inline.

* fix: Address Codex round-3 review on silent MCP OAuth refresh

Three more findings from Codex on PR #13369:

1. The in-flight silent-refresh promise was unbounded. If
   `forceRefreshTokens()` ever hung (slow provider, dropped TCP), the
   `inflightSilentRefreshes` lock stayed occupied forever and every
   later 401 for the same user/server joined the stuck promise instead
   of starting a fresh attempt or falling back to interactive OAuth.

2. The interactive-OAuth fallback didn't invalidate the
   `mcp_get_tokens` flow cache after persisting fresh tokens. For
   providers that don't issue refresh tokens (so silent refresh
   returns null), the old cache could still feed stale access tokens
   to the next `getOAuthTokens` call until its TTL expired — causing
   an immediate reconnect with the same just-rejected token.

3. When silent refresh failed, the handler fell through to
   `handleOAuthRequired()` whose recent-completion fast path can
   reuse a COMPLETED `mcp_oauth` flow within `PENDING_STALE_MS`. Those
   cached tokens are exactly the ones the server just rejected, so
   the connection would keep adopting them and looping on 401s until
   the cache aged out.

Resolution:

- Wrap `runSilentRefresh()` with a 60-second `withTimeout` (well under
  `connectClient`'s 120s OAuth timeout). On timeout the `.catch`
  resolves to null and the `finally` clears the in-flight entry, so
  the next 401 starts fresh and falls through to interactive OAuth.
- Extract two helpers — `invalidateGetTokensFlow` and
  `invalidateCompletedOAuthFlow` — and call them from the right
  branches: clear `mcp_get_tokens` after silent-refresh success AND
  after interactive-OAuth `storeTokens`; clear the COMPLETED
  `mcp_oauth` state (plus its CSRF mapping) before falling through to
  interactive OAuth so the fast-reuse path can't re-serve the
  rejected tokens.
- Add three regression tests: hung refresh release-the-lock under
  fake timers, completed-OAuth cache invalidation pre-fallback, and
  `mcp_get_tokens` invalidation after interactive token store.

* fix: Address Codex round-4 review on silent MCP OAuth refresh

Three more findings from Codex on PR #13369:

1. (P1) The silent-refresh in-flight lock keyed only by
   `userId:serverName`. In multi-tenant setups where two tenants share a
   userId (e.g. username-based IDs) and the same MCP server name, a
   concurrent mid-session 401 from tenant B would join tenant A's
   in-flight refresh and adopt tenant A's freshly minted tokens onto a
   tenant-B connection — a cross-tenant credential leak.

2. (P2) `invalidateGetTokensFlow` deleted the `mcp_get_tokens` flow
   state regardless of its status. When another connection was
   currently in `getOAuthTokens()` (PENDING flow) and joiners were
   monitoring it, the unconditional delete made those waiters see
   "Flow state not found" and unnecessarily fall back to interactive
   OAuth — even though fresh tokens were already being written.

3. (P2) The 60s `withTimeout` wrapping `runSilentRefresh()` only races
   the promise; it does not cancel the underlying `forceRefreshTokens`
   /  refresh-token HTTP request. If the request returned after a
   subsequent interactive OAuth had stored newer tokens, the late
   completion would `storeTokens` over the newer state. This requires
   a provider that doesn't rotate refresh tokens AND a refresh slower
   than 60s AND a successful interactive OAuth in that window — narrow
   but real.

Resolution:

- Capture `getTenantId()` into a new `factory.tenantId` field at
  factory construction time (before the OAuth handler closes over it
  outside the original request's async context) and include it in the
  silent-refresh lock key as `tenantId:userId:serverName`.
- `invalidateGetTokensFlow` now calls `getFlowState` first and only
  deletes when `status === 'COMPLETED'`. PENDING lookups are left
  alone so concurrent `getOAuthTokens` waiters via `monitorFlow` can
  still settle.
- For (3), document the race as a known limitation inline. Fully
  closing it requires threading an `AbortSignal` through
  `MCPTokenStorage.forceRefreshTokens` and the OAuth refresh handler
  to skip the late `storeTokens` after timeout — out of scope for this
  PR's surgical change.
- Add `getTenantId` to the `MCPOAuthConnectionEvents` test's
  `@librechat/data-schemas` mock so the factory constructor doesn't
  blow up under that suite.
- Add three regression tests: per-tenant lock isolation, PENDING-state
  preservation under `invalidateGetTokensFlow`, and (reused) the
  existing interactive-store invalidation test now driven through
  `getFlowState` returning the COMPLETED state.

* fix: Address silent MCP OAuth refresh review

Restore captured tenant context around token storage and OAuth fallback paths so mid-session callbacks do not lose tenant scope.

Thread AbortSignal through forced refresh and OAuth token requests, cap silent refresh by the connection OAuth timeout, and prevent timed-out refreshes from writing stale credentials after fallback.

Complete pending mcp_get_tokens flows with fresh tokens, add missing FlowState createdAt test fixtures, and cover the new tenant/abort/cache behaviors.

* fix: Tighten tenant-scoped MCP token refresh

Cap silent refresh by both the factory connect timeout and the connection OAuth wait timeout so fallback OAuth wins before the outer connect attempt expires.

Tenant-scope mcp_get_tokens flow ids for both token lookup and refresh invalidation, preventing cross-tenant flow completion or cache deletion when tenants share user ids and server names.

Add regression tests for the omitted initTimeout budget and tenant-prefixed token flow locks.

* fix: Reserve MCP OAuth fallback budget

* fix: Harden MCP OAuth refresh races

* fix: Keep MCP OAuth fallback route-compatible

* test: Add SDK MCP OAuth refresh repro

* fix: Address MCP OAuth refresh review findings

* fix: Address MCP OAuth tenant review findings

* fix: Close MCP OAuth route tenant gaps

* fix: Preserve MCP OAuth refresh flow guards

* fix: Avoid reprocessing MCP OAuth reauth config

* fix: Release timed-out MCP refresh locks

* fix: Release MCP OAuth request callbacks

* fix: Tenant-scope remaining MCP OAuth flow lookups

* ci: Sort imports in MCP OAuth test suites
2026-06-10 13:12:42 -04:00
Dustin Healy
5867f1a065
🛡️ feat: Configurable Message PII Filter (#13602)
* 🛡️ feat: Reject chat messages matching configured credential patterns

Adds an opt-in `messagePiiFilter` middleware mounted on the agent
chat route ahead of `moderateText`. When the configured patterns
match the user's input the request is refused with 400, so the
credential never reaches OpenAI moderation, the model, or MongoDB.
Three starter patterns ship by default and operators can subset
them or add their own regex via `customPatterns` in librechat.yaml.

* 🧪 test: Memoize compiled patterns + add middleware spec

Memoize the compiled pattern array via a WeakMap keyed by the
messagePiiFilter config object so repeat requests against the same
config skip the per-request RegExp construction. Cache entries are
released automatically when the config object itself rotates.

Adds packages/api/src/middleware/messagePiiFilter.spec.ts covering
the default-starter rejections, the starterPatterns subset and
empty-array semantics, customPatterns matching layered on top of and
in place of the starters, the no-config and empty-text pass-through
paths, and a memoization regression check.

* 🛡️ fix: Skip invalid customPattern regexes instead of crashing the request

Admin DB overrides for `messagePiiFilter.customPatterns` reach
`req.config` via `mergeConfigOverrides`, which deep-merges raw
override values without re-running `configSchema`. A typo'd regex
like `(` would slip past the YAML-load validation and throw inside
`new RegExp(...)` during `compile()`, returning 500 for every chat
request until the operator rolled the override back.

Wrapped the per-pattern compile in a try/catch that logs the
invalid pattern id + reason and skips it, so other valid patterns
(starters and other custom entries) keep filtering. Added a
regression test alongside the existing spec.

* 🛡️ feat: Extend PII filter to OpenAI-compatible and Responses agent APIs

The chat-route middleware operates on `req.body.text`, but the remote
agent API endpoints (`/api/agents/v1/chat/completions`,
`/api/agents/v1/responses`) accept the same prompt content as a
`messages` array or an `input` field. A caller using their API key
could send a credential-shaped value through either route and bypass
the configured PII filter even though they share the same agent and
model backbone the middleware is meant to guard.

Factored out `findPiiMatchInMessages`, a tolerant walker that handles
both `content: string` and `content: ContentPart[]` user-message
shapes against the same compiled, cached pattern list. Wired it into
the OpenAI-compat controller after agent lookup and into the
Responses controller right after `convertToInternalMessages`. Each
returns the endpoint's native 400 error shape
(`sendErrorResponse` / `sendResponsesErrorResponse`) with the
`message_pii_filter_block` code when a user message matches.

* 🩹 test: Add findPiiMatchInMessages to OpenAI + Responses controller mocks

The OpenAI-compat and Responses controller specs mock `@librechat/api`
with a hand-listed object. The new `findPiiMatchInMessages` export
wired into both controllers in 3ea35af9a was missing from those
mocks, so the production lookup returned undefined and the controllers
threw at request time under jest. Added the missing entries (default
mock: returns null so the handlers fall through to the existing happy
paths). All 278 agents-controller tests pass locally.

* 🧹 refactor: Namespace messagePiiFilter under messageFilter.pii + fix import order

Renames the yaml field `messagePiiFilter` to `messageFilter.pii`, the
module to `messageFilterPii`, the factory to `createMessageFilterPii`,
the type to `MessageFilterPiiConfig`, and the error code to
`message_filter_pii_block`. The wrapper `messageFilter` namespace
gives future safety filters (e.g. `messageFilter.toxicity`) a place
to plug in without restructuring the config later. The
`findPiiMatchInMessages` helper kept its name because it already
describes what it does at the value level.

Also fixes import order Danny flagged on the OpenAI-compatible and
Responses controllers: `findPiiMatchInMessages` was appended at the
bottom of two `require('@librechat/api')` destructures rather than
placed in the length-sorted slot the house style expects.

* 🧹 chore: Length-sort the general require destructure in responses.js

Reorders the general sub-group inside the `require('@librechat/api')`
destructure shortest to longest so the whole block conforms to the
length-sort rule the file's `// Responses API` sub-group already
follows. Pure reorder, no other changes.

* 🧹 chore: Length-sort the defaultConfig block in AppService

Reorders the `defaultConfig` keys in `packages/data-schemas/src/app/service.ts`
shortest-line to longest-line, with the explicit-value entries
(`mcpConfig`, `fileStrategies`, `cloudfront`) trailing the shorthand
ones. Pure reorder, no behavior change.
2026-06-10 09:03:05 -04:00
Danny Avila
a7f16911b2
fix: Extend and Decouple MCP OAuth Flow Timeouts (#13622)
*  fix: Extend and decouple MCP OAuth flow timeouts

The OAuth auth button disappeared after 2 minutes (the internal OAuth
handling timeout) while the flow state lived for 3 minutes, leaving users
who didn't click immediately stuck in an unrecoverable re-auth loop. The
handling timeouts also reused the connection/init timeout, so a short
initTimeout would shrink the OAuth window further.

- Add MCP_OAUTH_HANDLING_TIMEOUT (10m) and MCP_OAUTH_FLOW_TTL (15m) to mcpConfig
- Decouple the reactive/proactive OAuth waits from initTimeout/connectionTimeout
- Use OAUTH_FLOW_TTL for the FlowStateManager TTL and the UI status window
- Ensure the flow TTL outlives the handling timeout, fixing the
  "Flow state not found" race
- Remove dead FLOW_TTL constant and document new env vars

Fixes #13615

*  fix: Coordinate OAuth pending window with handling timeout

Address Codex review: the extended OAuth wait was still capped by other
timeouts that were not updated.

- Align PENDING_STALE_MS (button validity + pending-flow reuse window)
  with MCP_OAUTH_HANDLING_TIMEOUT so a flow stays reusable for the full
  wait instead of 2 minutes (Finding 3)
- Clamp MCP_OAUTH_FLOW_TTL to never fall below the handling timeout so a
  callback near the deadline still finds its flow state (Finding 2)
- Floor attemptToConnect's timeout to the handling window for OAuth
  servers so the reactive in-connect OAuth wait is not killed by the
  30s connection timeout (Finding 1)
- Update flow staleness tests to reference the threshold symbolically

*  fix: Align OAuth window across status, action flows, and client polling

Address Codex round 2: extending the server wait exposed three more
windows that were still capped or now over-extended.

- checkOAuthFlowStatus reports a PENDING flow as active only within the
  usable PENDING_STALE_MS window, not the longer Keyv retention TTL, so
  the connect button reappears instead of a stuck 'connecting' state
- Give Action (custom tool) OAuth its own FlowStateManager on the prior
  3-minute TTL so the longer MCP OAuth TTL can't leave an action tool
  call waiting up to 15 minutes
- Extend the MCP server-card client polling to the 10-minute handling
  window so a user who completes OAuth after 3 minutes is still picked up

* 🧪 test: Make stale-flow CSRF test track PENDING_STALE_MS

The CSRF-fallback stale-flow test hardcoded a 3-minute age, which is now
within the 10-minute PENDING_STALE_MS window and was wrongly treated as
active. Derive the age from PENDING_STALE_MS so it tracks the constant.

*  fix: Add grace buffers and surface OAuth timeout to the client

Address Codex round 3 (near-deadline edges):

- Clamp MCP_OAUTH_FLOW_TTL to handling timeout + 60s grace (not equality),
  so flow state outlives the wait instead of expiring at the same instant
- Extend attemptToConnect's OAuth floor by a 60s grace so a user who
  authorizes near the deadline still gets the post-OAuth reconnect
- Surface OAUTH_HANDLING_TIMEOUT on the connection-status response and
  have the client poll for the configured window instead of a hardcoded
  10 minutes, so a tuned server deadline isn't capped on the client

*  fix: Refresh client OAuth timeout from the first status refetch

If the connection-status cache is empty when polling starts, the client
captured the 10-minute fallback and never picked up a tuned oauthTimeout.
Re-read it after each refetch so a longer configured deadline is honored
even on a cold cache.

* 📝 refactor: Type oauthTimeout on MCPConnectionStatusResponse

Declare the oauthTimeout field on the shared response type in
data-provider instead of an ad-hoc inline cast in the client hook, and
replace the pre-existing 'as any' on the status query read with the
typed getQueryData. Type-level only; no runtime change.
2026-06-09 17:50:02 -04:00
Danny Avila
2a956f143d
🪞 fix: Preserve Model Spec Icons Across Stream Resume and Abort (#13603)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
2026-06-08 17:14:21 -04:00
Danny Avila
ae0c187ddd
📋 refactor: Attach Message Context to Langfuse Feedback Scores (#13604) 2026-06-08 15:54:01 -04:00
Gil Raphaelli
90ebecb254
📊 feat: Surface Message Feedback as Langfuse Scores (#13544)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* feat: surface message feedback (thumbs up/down) as Langfuse scores

When Langfuse tracing is enabled, the message feedback endpoint now posts a
boolean `user-feedback` score (1/0 + tag/comment) to Langfuse for the
assistant message's trace; clearing feedback deletes the score. Fire-and-
forget, so the feedback UX never blocks on Langfuse.

Linking is lookup-free: the run opts into deterministic Langfuse trace ids
(`langfuse.deterministicTraceId`, passed to the agents Run), so the trace id
is sha256(messageId)[:32]. The feedback route recomputes the same id and
scores by it.

- api/server/services/Langfuse.js: POST/DELETE /api/public/scores (env-gated)
- api/server/utils/langfuseTrace.js: traceIdForMessage(messageId)
- api/server/routes/messages.js: fire feedback score after the Mongo write
- packages/api: pass langfuse.deterministicTraceId to the run
- bump @librechat/agents to ^3.2.21 (adds LangfuseConfig.deterministicTraceId)

Closes #13537

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: match Langfuse trace environment for feedback scores

@librechat/agents passes no environment to its Langfuse tracer, so
@langfuse/otel falls back to LANGFUSE_TRACING_ENVIRONMENT and otherwise to
Langfuse's "default". The score helper instead fell back to NODE_ENV, so a
deployment with only NODE_ENV=production filed scores under "production" while
the trace stayed on "default" — the score never landed on the trace.

Use LANGFUSE_TRACING_ENVIRONMENT only, and omit `environment` when unset so
Langfuse defaults both score and trace to "default".

Addresses Codex review on #13544.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: don't require LANGFUSE_BASE_URL to post feedback scores

The agent tracer emits traces with just the public/secret keys (defaulting to
Langfuse Cloud, or via the legacy LANGFUSE_BASEURL alias), but the score helper
disabled itself unless LANGFUSE_BASE_URL was set — so an otherwise-traced
deployment silently posted no scores. Resolve the base URL the same way the
tracer does (LANGFUSE_BASE_URL -> LANGFUSE_BASEURL -> Cloud) and gate enablement
on the credentials only.

Addresses Codex review on #13544.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: only post feedback scores for agent-endpoint messages

The feedback route is shared by all message types, but deterministic Langfuse
trace IDs are only enabled for agent runs. Rating a message from a non-agent
endpoint (with Langfuse configured) posted a user-feedback score for
sha256(messageId) that no trace will ever match, leaving orphan scores.

Gate scoring on isAgentsEndpoint(message.endpoint); `updateMessage` now returns
`endpoint` so the route can check it.

Addresses Codex review on #13544.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: gate feedback scoring by !isAssistantsEndpoint, not isAgentsEndpoint

The previous gate used isAgentsEndpoint, which only matches the literal
`agents` endpoint. But provider endpoints (anthropic, openai, custom, …) run
through the agents runtime as ephemeral agents and DO emit deterministic
AgentRun traces, so isAgentsEndpoint('anthropic') === false suppressed scoring
for the common case. Only the OpenAI/Azure Assistants endpoints use a separate
runtime with no agent trace, so gate on !isAssistantsEndpoint instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: sort message method imports

* fix: honor Langfuse tracing gates for feedback scores

* refactor: move Langfuse feedback logic to api package

* fix: support Langfuse host for feedback scores

* test: type Langfuse feedback fetch mock

* chore: compact Langfuse feedback comment

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-06-07 11:23:41 -04:00
Danny Avila
cb1d536874
📻 fix: Replay MCP OAuth Prompts for Coalesced Connections (#13565)
* fix: Replay MCP OAuth URL for Joined Connections

* chore: Sort MCP OAuth Imports

* test: Restore MCP OAuth Registry Spies

* fix: Replay pending MCP OAuth prompts

* fix: Replay MCP OAuth on Stream Resume

* fix: Preserve MCP OAuth Replay Context

* chore: Format MCP OAuth Replay Context

* test: Expect MCP OAuth Replay Expiry

* fix: Render pending MCP OAuth prompts

* chore: Clean MCP OAuth Replay Type Narrowing

* fix: Stabilize new MCP OAuth chats

* fix: Re-emit cached MCP OAuth prompts

* fix: Replay pending OAuth for selected MCP tools

* fix: Avoid stalling pending MCP OAuth replay

* test: Clean MCP OAuth review findings

* test: Restore MCP OAuth registry spy

* fix: Resolve OAuth Typecheck Regressions

* fix: Harden MCP OAuth replay edge cases

* test: Cover MCP OAuth joined prompt expiry

* test: Mark joined OAuth replay fixture

* test: Use OAuth fixture for joined replay expiry

* fix: Anchor resumed MCP OAuth prompts

* fix: Seed resumable turn metadata before MCP init

* test: Format resume metadata regression

* fix: Prioritize resumable stream routes

* fix: Preserve MCP OAuth resume message tree

* test: Fix MCP OAuth Resume Test Types

* fix: Replay MCP OAuth Regenerate Prompts

* fix: Skip OAuth-only Abort Persistence

* fix: Stabilize OAuth Resume Replay

* fix: Target Non-Tail Regenerate Responses

* fix: Scope Regenerate Step Updates

* fix: Clean Up OAuth Abort State

* fix: Preserve Regenerate Branch Siblings

* fix: Preserve OAuth Resume Branch State

* fix: Preserve OAuth Branch Resume State

* chore: Sort OAuth Resume Imports

* fix: Address OAuth Resume Review Findings

* test: Fix Abort Fixture Typing
2026-06-07 10:45:54 -04:00
Danny Avila
4b871a11ad
🧼 fix: Prevent Shared Link Caching and Strengthen Log Redaction (#13561)
* fix: tighten share caching and log redaction

* fix: sort changed imports

* fix: redact splat log arguments

* fix: avoid mutating log metadata during redaction

* fix: redact error and api_key log values

* fix: preserve error log context during redaction

* fix: cover remaining log redaction paths

* fix: bound log redaction work

* fix: align redaction scan cap with log config
2026-06-06 18:40:57 -04:00
Danny Avila
07af6ee288
🔀 fix: Reconcile Agent Action Credential Merges (#13559)
* fix: Refine Agent Action Updates

* fix: Format Action Update Helper

* fix: Refine Agent Action Update Handling

* fix: Move Agent Action Update Planning

* fix: Sort Action Update Imports

* chore: Reorder imports in actions.js for clarity
2026-06-06 15:09:58 -04:00
Danny Avila
5011be4d38
🚦 fix: Guard Auth Continuation with Dedicated Limiter (#13555)
* fix: refine auth continuation handling

* test: align auth route mock setup

* fix: separate auth continuation throttling

* test: format auth route mock

* fix: preserve continuation limiter context

* fix: hydrate continuation user before bans
2026-06-06 14:21:28 -04:00
Danny Avila
da5876331e
🔐 fix: Reuse MCP OAuth Authorization URL (#13532)
* fix: reuse MCP OAuth authorization URL

* fix: validate MCP OAuth initiate flow ID
2026-06-05 17:18:59 -04:00
Danny Avila
aeb5adff34
🪦 fix: Add Durable MCP Config Tombstones (#13534)
* fix: add durable MCP config tombstones

* fix: preserve scoped config tombstones

* fix: clean up config tombstone lint

* fix: handle empty model spec skill allowlist

* fix: preserve inactive config tombstones
2026-06-05 15:05:40 -04:00
Danny Avila
2c8d54e18c
🗂️ feat: Add Deployment Skill Directory (#13523)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* feat: Add deployment skill directory

* chore: Address deployment skill review feedback

* fix: Include deployment skill file metadata

* test: Add deployment skills e2e smoke test
2026-06-05 10:24:28 -04:00
Danny Avila
6357ea10c1
🧭 feat: Scope Model Spec Skills (#13522)
* feat: scope model spec skills

* style: format skill catalog limit

* fix: serialize model spec skill resolution

* test: satisfy model spec load config typing

* fix: apply model spec skills to added conversations

* fix: support alwaysApply frontmatter alias

* fix: address model spec skills review
2026-06-05 10:22:02 -04:00
Danny Avila
baa23a8e24
🗂️ feat: Add Private Chat Projects (#13467)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* 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
2026-06-03 15:29:18 -04:00
Atef Bellaaj
86fe79c37d
🔗 feat: Add Granular Access Control to Shared Links via ACL System (#13051)
* 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>
2026-06-03 14:17:17 -04:00
Danny Avila
2ef7bdfbc2
feat: Immediate Conversation Title Generation (#13395)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
*  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
2026-06-02 16:40:57 -04:00
Teresa Blanco
b45e4aeae5
🎭 feat: Add Credential-Free Playwright Smoke Suite with a Local Mock LLM (#13472)
* 🧪 feat: add e2e playwright tests

* 🧪 feat: Add Playwright Recording Harness

* test: fix mock playwright config

* test: harden mock e2e environment

* test: preserve mock dotenv secrets

* test: harden mock isolation setup

* ci: cache mock e2e builds

* test: harden e2e cache and recorder checks

* test: preserve data-provider exports in oauth route test

* test: isolate mock auth logout state

* test: allow isolated logout smoke setup

* test: prepare logout smoke auth via api

* test: isolate oauth route module mock

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-06-02 16:36:39 -04:00
Danny Avila
317b8dfbd5
🩻 refactor: Replace Opaque OAuth Errors with Structured Failure Diagnostics (#13471)
* Improve OAuth failure logging

* Improve OAuth failure logging

* test: type oauth failure request helper

* refactor: move OpenID callback helper to api package
2026-06-02 15:06:42 -04:00
Ravi Kumar L
f27e7d7cad
🛂 fix: Gate RUM Proxy Route on the RUM_ENABLED Flag (#13475) 2026-06-02 14:13:10 -04:00
Danny Avila
83d8ac0682
🪜 feat: Add OpenID Role Sync (#13415)
* 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>
2026-06-02 14:00:56 -04:00
jcbartle
268f095c1a
🔒 feat: Add On-Behalf-Of (OBO) token exchange support for MCP Servers (#13429)
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
* 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.
2026-06-01 22:36:18 -04:00
Ravi Kumar L
a86e504a57
📡 feat: Add Authenticated Proxy Mode for Browser RUM Telemetry (#13464) 2026-06-01 21:11:35 -04:00
Danny Avila
e0c346c0a4
🤫 chore: Quiet Repetitive Log Noise from Balance, CloudFront, and Capability Paths (#13461)
* chore: reduce auth and balance operational noise

* chore: tighten balance and capability noise handling

* chore: avoid balance 404s when disabled

* chore: use response locals for balance handoff
2026-06-01 20:40:16 -04:00
Danny Avila
100871c3ec
🛂 fix: Enforce MCP Permissions for Agent Tools (#13174)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* fix: Enforce MCP Permissions for Agent Tools

* fix: Measure MCP Image Limit by Decoded Size

* fix: gate cached MCP tools and tighten remote image URL detection

Addresses Codex review findings on the MCP permissions PR:

- filterAuthorizedTools previously fast-accepted any tool present in the
  global tool cache before reaching the MCP-use permission gate. App-level
  MCP tools (keyed `name_mcp_server` by MCPServerInspector and merged into
  the cache via mergeAppTools) therefore bypassed the canUseMCP check,
  letting a user without MCP_SERVERS.USE persist/bind them. Route all
  MCP-delimited tools through the permission + server-access gate
  regardless of cache presence.

- assertImageDataWithinLimit / image formatter used startsWith("http")
  to skip the size cap, which also matched base64 payloads that happen to
  begin with those chars. Require http:// or https:// via a shared
  isRemoteImageUrl helper so oversized inline base64 can no longer bypass
  MCP_IMAGE_DATA_MAX_BYTES.

Adds regression tests for both paths.

* fix: address Codex round-2 findings on MCP permissions PR

- parsers.ts: parseAsString dropped the image payload for unrecognized
  providers, returning only `Image result: <mimeType>`. Pre-PR these
  items survived via JSON.stringify(item). Keep the size guard but fall
  through to JSON.stringify so the data/URL is preserved.

- MCP.js: the runtime MCP-use check only read `configurable.user`, so
  paths that propagate `user_id` only (e.g. the OpenAI-compatible API in
  agents/openai/service.ts) rejected every MCP tool call for an
  authenticated user. Add resolveMCPPermissionUser: use the safe user
  directly when it already carries a role (no extra DB call), otherwise
  fall back to loading the role by user_id. Update fail-closed tests to
  the resolved behavior.

- v1.js: the update path only re-filtered newly added MCP tools, so a
  user who lost MCP_SERVERS.USE kept existing MCP bindings on edit while
  create/duplicate/revert stripped them. Strip all MCP tools on update
  when the permission is revoked; keep the narrower new-tool gating (and
  disconnect/registry preservation) when it is intact.

Updates and adds regression tests for all three paths.

* fix: populate safe user at producer instead of resolving in runtime MCP check

Corrects the Finding B approach from the previous commit. Rather than
loading the user by id inside the runtime MCP permission check, populate
`configurable.user` (and createRun's `user`) with the full safe user at
the producer, matching the in-repo agent controllers
(responses.js / openai.js) which already pass `createSafeUser(req.user)`.

- service.ts: derive `safeUser` via createSafeUser(req.user) and pass it
  to both createRun and processStream's configurable, so the role-bearing
  identity reaches the runtime `userCanUseMCPServers(configurable.user)`
  check. Falls back to a bare id when the host app attached no user,
  which correctly leaves MCP gated (fail closed).
- MCP.js: revert the resolveMCPPermissionUser DB-load fallback; the
  runtime check again reads configurable.user directly and fails closed
  when absent (defense in depth).
- MCP.spec.js: revert to the matching runtime test expectations.

* test: cover safe-user propagation in createAgentChatCompletion

Adds a focused spec for the OpenAI-compatible chat completion service
(the producer fixed for Codex Finding B). Injects mocked deps and asserts
that createRun and processStream's configurable.user carry the role from
req.user (with sensitive fields stripped by createSafeUser), and that an
unauthenticated request falls back to a bare { id: 'api-user' } so the
runtime MCP check fails closed.

* fix: address Codex round-3 findings + TS6133

- MCP.js (P1): the assistants required-action path invokes tool._call(
  toolInput) with no LangChain config, so the runtime check saw no
  configurable.user and rejected authorized users. createToolInstance now
  captures the creation-time user (req.user via createMCPTool) and _call
  falls back to it for both the permission check and userId. Still fails
  closed when neither config nor captured user carries a role.

- v1.js (P2): the update-path isMCPTool used a bare mcp_delimiter substring
  check, misclassifying action tools whose operationId contains "_mcp_"
  (e.g. sync_mcp_state_action_...) as MCP and dropping them on a
  permission-revoked edit. Delegate to the canonical isActionTool so only
  real MCP tools are gated. Regression test added.

- service.ts: drop the now-unused IUser import (TS6133); derive reqUser's
  type from createSafeUser's own parameter instead.

* fix: resolve TS7022 self-reference in service.spec mock res

The mock response object referenced `res` inside its own `status`/`json`
initializers without a type annotation, so tsc inferred `res` as `any`
(TS7022). Annotate the object and assign the self-referencing chainable
methods after declaration.

* fix: correct round-4 findings (isActionTool import, captured user, partial-update)

- v1.js: import isActionTool from librechat-data-provider (its real export;
  @librechat/api does not export it, so the prior import was undefined and
  threw TypeError). Exclude action tools from MCP classification in both the
  main filterAuthorizedTools loop and the update path, so action tools whose
  operationId contains _mcp_ (e.g. sync_mcp_state_action_...) are preserved
  regardless of MCP permission.
- v1.js: evaluate the effective tool set (updateData.tools ?? existingAgent.tools)
  so a tools-less PATCH by a user who lost MCP_SERVERS.USE still strips stale
  MCP bindings, matching create/duplicate/revert.
- MCP.js: createToolInstance now receives the construction-time user and _call
  falls back to it (permissionUser) when configurable.user is absent, fixing the
  assistants required-action path that invokes _call without a config and
  resolving the capturedUser no-undef/ReferenceError.
- Tests: action-tool preservation (authorized + denied), tools-less revocation
  PATCH, updated revocation test to expect all MCP tools stripped.

Affected specs pass locally: MCP 49/49, filterAuthorizedTools 49/49.

* fix: guard isActionTool against non-string tools; correct actionDelimiter import

Two test regressions from the prior commit:
- The main filterAuthorizedTools loop called isActionTool(tool) directly,
  but isActionTool does toolName.indexOf(...) and throws on null/undefined.
  Compute isActionToolName = typeof tool === 'string' && isActionTool(tool)
  once and reuse it, restoring graceful null/undefined handling.
- The action-tool test referenced Constants.actionDelimiter (undefined);
  actionDelimiter is a standalone librechat-data-provider export. Import and
  use it directly.

filterAuthorizedTools 36/36 and MCP 40/40 pass locally.

* fix: address MCP permission review follow-ups

* fix: preserve shared agent MCP tools
2026-05-30 16:19:49 -04:00
ChrisJr404
6db059b8a9
🔒 fix: Strip post-login fields from unauthenticated /api/config response (#13102)
* 🔒 fix: Strip post-login fields from unauthenticated /api/config response

Follow-up to #12490 reported in #12688.

The unauthenticated /api/config response still included fields that are
only consumed after login (helpAndFaqURL, sharedLinksEnabled,
publicSharedLinksEnabled, showBirthdayIcon, analyticsGtmId,
openidReuseTokens, allowAccountDeletion, customFooter, cloudFront).
None of these are read by the auth pages (Login, Registration,
RequestPasswordReset, ResetPassword, VerifyEmail, TwoFactorScreen,
AuthLayout, Footer, SocialLoginRender).

Split buildSharedPayload into two helpers:

- buildPreLoginPayload returns only the fields the unauthenticated auth
  pages need (appTitle, server domain, social-login flags, OpenID/SAML
  labels and image URLs, registration/email/password-reset flags,
  minPasswordLength, ldap).
- buildPostLoginPayload returns the post-login informational fields and
  is merged into the response only when req.user is present.

Also move buildCloudFrontStartupConfig into the authenticated branch:
useAppStartup is the only consumer and it runs after login.

Tests updated: existing CloudFront and allowAccountDeletion assertions
move to the authenticated context, and two new assertions cover the
stripped fields (one for the post-login informational fields, one for
cloudFront) in the unauthenticated context.

Signed-off-by: ChrisJr404 <chris@hacknow.com>

* fix: Request share-context startup config

* fix: Pass share startup config into footer

---------

Signed-off-by: ChrisJr404 <chris@hacknow.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-05-30 09:51:21 -07:00
Danny Avila
444d923e29
✂️ chore: Strip Session JWT Forwarding from Browser RUM (#13414)
* fix: disable RUM user JWT auth

* fix: remove stale RUM bootstrap import
2026-05-30 10:34:44 -04:00
Ravi Kumar L
71a7c9ce7b
📡 feat: Add Configurable HyperDX Browser Real User Monitoring (#13287) 2026-05-29 11:04:26 -07:00
Danny Avila
94c73123ee
📋 fix: Cap Default Limit on Agent List Queries (#13382)
* 🛡️ fix: Cap Default Limit on Agent List Queries (#13363)

`GET /api/agents` accepted unbounded requests: when the client omitted
`limit`, the value flowed straight into `getListAgentsByAccess`, which
set `isPaginated = false` and issued an uncapped MongoDB query. Combined
with the unindexed `findPubliclyAccessibleResources` AclEntry scan run
on every request, this produced 10-19s response times and stalled the
connection pool on instances with 100+ agents.

- Default `limit` to 100 in the route handler so client requests without
  `?limit=` paginate by default.
- Default `limit` to 100 in `getListAgentsByAccess` itself as
  defense-in-depth. The function already caps numeric limits at 100, so
  there is no client-facing change.
- Pass `limit: null` explicitly in the actions route, which legitimately
  needs the full editable-agent set, to preserve its existing behavior.
- Add regression tests covering the default cap and the explicit
  unbounded opt-out.

* 🛡️ fix: Avoid agent-list regression for users with 100+ agents

Codex review pointed out that capping `getListAgentsByAccess` at 100
silently truncated agents past the first page for the four consumers
(`useAgentsMap`, `AgentSelect`, `ModelSelectorContext`, `useMentions`)
that read `res.data` without following `has_more`/`after`.

- Raise the function's hard cap from 100 to 1000 to match
  `MAX_AVATAR_REFRESH_AGENTS`, the realistic upper bound the
  avatar-refresh path already assumes. (Side effect: the avatar refresh
  call site was silently being capped at 100 by the old normalize step.)
- In `useListAgentsQuery`, merge `limit: 1000` into params so the four
  consumers above get the user's full accessible set in a single
  round-trip instead of needing cursor pagination.
- Route handler default stays at 100 as defense-in-depth for any other
  caller that omits `limit`.
- Add a regression test asserting an explicit `limit` above 100 now
  returns the full set instead of being clipped.

* 🪢 fix: Keep agent-list cache key stable for mutations

Codex P2 review noted that folding `limit: 1000` into the cache key
broke `allAgentViewAndEditQueryKeys` in `Agents/mutations.ts`, which
references `[QueryKeys.agents, { requiredPermission }]` directly across
eight mutation handlers. After my prior change the cached entry lived
under `[QueryKeys.agents, { limit: 1000, requiredPermission }]`, so
create/update/delete/avatar/action mutations stopped updating the list
the four consumer hooks render — and with `refetchOnMount` and focus/
reconnect refetches disabled, the UI would stay stale until something
else triggered a fetch.

Split the merged limit out of the cache key: the request to
`dataService.listAgents` still uses `requestParams` (with the default
limit applied), but the React Query cache key uses the caller's `params`
as-is. The mutation cache updates land again, and the request still
returns the user's full accessible set in one round-trip.

* 🛡️ fix: Index AclEntry and paginate agent list internally (#13363)

Completes the perf fix for #13363 properly — resolves both the
unbounded ACL scans Copilot flagged and Codex's tension between "show
all agents" and "don't bypass the server cap".

Backend:
- Add a compound index on `{ principalType, resourceType, permBits,
  resourceId }` to the AclEntry schema. This is the index missing for
  `findPublicResourceIds` and the public branch of the `$or` in
  `findAccessibleResources`, both of which previously fell back to a
  collection scan on every `GET /api/agents`. Adds an `explain`-based
  regression test asserting the public query no longer COLLSCANs.

Client:
- Rewrite `useListAgentsQuery` to follow the server's cursor
  pagination internally and concatenate every page into a single flat
  `AgentListResponse`. Consumers (`useAgentsMap`, `AgentSelect`,
  `ModelSelectorContext`, `useMentions`) get the user's complete
  accessible-agent set without any of them needing to learn about
  cursors, and each individual request uses the server's default
  page size (so the route's 100-default defense-in-depth fires for
  real). Cache key shape is unchanged, so the eight mutation handlers
  in `Agents/mutations.ts` keep matching `allAgentViewAndEditQueryKeys`
  and update the cached list as before.
- Drop the `FULL_AGENT_LIST_LIMIT = 1000` injection added in the
  previous commit — no longer needed once pagination handles the full
  set, and removing it stops bypassing the route default.

* 🧹 fix: CI fallout from C-done-properly refactor

- Collapse multi-line `fetchAllAgentPages` signature in queries.ts so
  prettier stops complaining.
- In the new public-principal index test, grant one ACL entry before
  calling `.explain()` so the collection exists (otherwise mongo returns
  `nonExistentNamespace` and there is no winning plan to inspect).
- Cast the `.explain('queryPlanner')` result to a typed shape — the
  mongoose return type doesn't expose `queryPlanner` directly and was
  failing the TypeScript check.

* 🧪 fix: Test the AclEntry public-principal index via hint, not planner choice

The previous test asserted the query planner did not pick COLLSCAN for
the public-principal lookup. That assertion fails on small collections
(under the planner's collection-size heuristic) — the index exists and
is usable, but with a single document in the test the planner correctly
chooses COLLSCAN as the cheaper plan.

Reshape the assertion:
1. Confirm the new compound index is actually declared by inspecting
   `collection.indexes()` after `syncIndexes()`.
2. Force the planner to that index via `.hint()` and assert the winning
   plan is `IXSCAN` — proves the index is real and serves this query
   shape, without depending on collection-size heuristics.

* 🧹 chore: Slim down verbose comments

The JSDoc and inline comments added across the perf fix had drifted
into multi-paragraph rationale better suited to the PR description than
the source. Collapse to single-line JSDoc that just describes what each
piece does; drop the inline comment in `actions.js` entirely — the call
is self-evident.
2026-05-28 21:37:53 -07:00
Danny Avila
6d6ea08da4
🆔 feat: Built-in Build Metadata for Support Triage (#12756)
* 🏗️ refactor: Derive App Version from Root package.json + Add buildInfo Schema

The hardcoded `Constants.VERSION` in `data-provider` is now replaced at
rollup build time via `@rollup/plugin-replace`, sourcing from the root
`package.json` so version bumps are a single-file change.

Adds the shape needed by the rest of the series:
- `interface.buildInfo` boolean flag (default `true`) — lets self-hosters
  opt out of exposing commit/branch/date.
- `buildInfo` on `TStartupConfig` — commit/commitShort/branch/buildDate.
- `SettingsTabValues.ABOUT` — new settings tab enum value.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 🛠️ feat: Add Build Metadata Resolver and Expose via /api/config

Adds `resolveBuildInfo()` in `@librechat/api` that surfaces commit SHA,
branch, and build date from (in order) `BUILD_*` env vars, then local git
metadata. Result is cached per-process.

`/api/config` includes a `buildInfo` field on both authenticated and
anonymous responses when `interface.buildInfo !== false` and at least one
resolver field is populated. Omitted entirely otherwise.

Designed so pre-built Docker images carry metadata via build-arg while
source installs pick it up from `.git` — no manual version tracking.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* ℹ️ feat: Add Settings → About Panel with Diagnostics Copy

New Settings tab that renders the running build's version, commit (short
SHA), branch, and build date in a monospaced block alongside a "Copy
diagnostics" button that emits a preformatted text blob for pasting into
support issues.

Tab is hidden when `interface.buildInfo` is set to `false`. Reads from
`startupConfig.buildInfo` provided by `/api/config`.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 🐳 ci: Inject BUILD_COMMIT/BRANCH/DATE into Docker Images

Adds optional `BUILD_COMMIT`, `BUILD_BRANCH`, `BUILD_DATE` ARGs to both
`Dockerfile` and `Dockerfile.multi`, wired as `ENV` vars in the runtime
stage so the backend's `resolveBuildInfo` picks them up.

All image-publishing workflows (`tag`, `main`, `dev`, `dev-branch`,
`dev-staging`) now compute `${github.sha}`, `${github.ref_name}`, and a
UTC timestamp, then pass them to `docker/build-push-action` as
`build-args`.

Defaults are empty — non-CI builds (local `docker build`) still work,
and the backend falls back to local `.git` metadata if ARGs aren't set.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 📝 docs: Direct Bug Reporters to Settings → About for Version Info

The previous instructions (`docker images | grep librechat`,
`git rev-parse HEAD`) only worked for a subset of deployments and
rarely produced a commit SHA for users pulling pre-built images.

Point users to the new in-app Settings → About panel's
"Copy diagnostics" button, which captures version, commit, branch,
build date, and user agent in a single preformatted block. Fallback
instructions preserved for older installs.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 🐳 fix: Move BUILD_* ENV to End of Docker Stages to Preserve Layer Cache

Per-commit BUILD_COMMIT/BUILD_DATE changes were being promoted to ENV
before `npm ci` / `npm run frontend` (single-stage) and before
`npm ci --omit=dev` (multi-stage api-build), which invalidated the cache
for every subsequent layer on every CI run.

Move the ARG/ENV block below the heavy install and build steps in both
Dockerfiles. Metadata is still available in the runtime image but no
longer busts layer reuse.

Addresses codex review on #12756.

* 🔧 fix: Propagate interface.buildInfo=false to Unauthenticated /api/config

The unauthenticated branch of `/api/config` was emitting an `interface`
object only when `privacyPolicy` or `termsOfService` was set, which
meant an admin's explicit `interface.buildInfo: false` opt-out was never
visible to anonymous/guest clients. `Settings.tsx` gates the About tab
on `startupConfig?.interface?.buildInfo !== false`, so a missing field
fell through as "enabled" for those clients.

Include `interface.buildInfo: false` in the unauth payload whenever it's
explicitly disabled. Keep the implicit default (true) absent to preserve
the minimal-unauth-payload convention.

Addresses codex review on #12756.

* 🔀 ci: Trigger Dev Image Workflows on Root package.json + Dockerfile Changes

The baked `Constants.VERSION` now reads from the root `package.json` via
rollup-plugin-replace, but the `dev-images.yml` and `dev-branch-images.yml`
path filters only matched `api/**`, `client/**`, `packages/**`. A release
commit that only bumps root `package.json` would not trigger a rebuild,
leaving `latest` dev images with stale Footer/About version metadata.

Include `package.json`, `package-lock.json`, and both Dockerfiles in the
path filters so dependency changes (lockfile rebuilds) and image build
tweaks also rebuild dev images.

Addresses codex review on #12756.

* 🧽 fix: Harden About Panel Lifecycle, A11y, and Loading Gate

Review follow-ups on #12756:

- #1 timer leak: stash the copy-state `setTimeout` in a ref and clear it
  from a `useEffect` cleanup so unmounting the Settings dialog mid-toast
  doesn't fire `setCopied(false)` on an unmounted component.
- #3 flash of About tab: gate `aboutEnabled` on `startupConfig != null`
  so the tab stays hidden until `/api/config` returns. For admins who
  disabled `interface.buildInfo`, the tab no longer briefly appears and
  vanishes on page load.
- #6 aria-live placement: move the live region off the interactive
  button onto a dedicated `<span role="status" aria-live="polite">` so
  screen readers announce the copied state, not the full button content
  on every re-render.
- #2 missing coverage: add `About.spec.tsx` exercising populated/empty
  buildInfo rendering, invalid-date handling, diagnostics clipboard
  payload, copy-state toggling, unmount cleanup, and the live region.

*  perf: Eagerly Resolve Build Info at Module Load

Review follow-up #4 on #12756: `resolveBuildInfo()` calls `execFileSync`
with a 2s timeout on source installs without `BUILD_*` env vars. Paying
this cost on the first HTTP request blocks the event loop mid-flight.

Call `resolveBuildInfo()` once at config route module load so the
resolver's cache is warm before any request arrives. Docker images with
the BUILD_* env vars set sidestep the git path entirely, so this only
affects the edge case of source installs.

* 📝 docs: Document rollup Version Placeholder Contract

Review follow-ups #5 / #8 on #12756. The `__LIBRECHAT_VERSION__`
placeholder relies on a substring replacement rule that only works
because the token appears inside a string literal, and the substitution
only runs during `npm run build:data-provider`.

- Expand the `Constants.VERSION` JSDoc to spell out that consumers read
  the placeholder through the built dist bundle; source-level test
  imports would see the raw placeholder.
- Add a NOTE above the rollup `replace` config warning future
  contributors not to repurpose the token as a bare identifier without
  switching to a quoted replacement value.

Non-functional; prevents future contributors from stepping on a subtle
constraint.

* 🪪 fix: Only Toast "Copied" When Clipboard Copy Actually Succeeds

Codex R5 on #12756. `copy-to-clipboard` returns a boolean indicating
whether the underlying `execCommand('copy')` / fallback prompt actually
wrote to the clipboard. The previous handler flipped to the "Copied"
state unconditionally, which in hardened browsers or when the
permission prompt is dismissed would mislead users into filing bug
reports without the diagnostics blob attached.

Gate the state/timer/live-region on the boolean return; silently no-op
on failure rather than showing a false positive. Adds a test asserting
the button label stays at "Copy diagnostics" when the clipboard call
fails.

* 🐳 fix: Derive main image metadata from checkout

* 🪪 fix: Keep About enabled until disabled

*  test: Avoid literal Settings mock text

* 🧱 refactor: Rename Build Info Module
2026-05-23 09:41:13 -04:00
eleite93
5d393ad79a
🪪 fix: Support OpenID PKCE Without Client Secret (#12364)
* fix: allow OpenID PKCE authentication without client secret

* Linting

* Strategy fix

* fix(openid): trim secret gates and add PKCE client metadata tests

* chore(openid): normalize spec line endings

*  perf: Short-Circuit Config Override Resolution for Empty Principals (#12549)

Skip the getApplicableConfigs DB query when buildPrincipals returns
an empty array, since there are no principals to match against.

*  perf: Separate Error Handling for Principal Resolution vs Config Overrides (#12550)

Distinguish between buildPrincipals and getApplicableConfigs failures
so the uncached fallback to baseConfig is intentional and logged
separately from config override errors.

* Revert " perf: Separate Error Handling for Principal Resolution vs Config Overrides (#12550)"

This reverts commit 1729378a65.

* Revert " perf: Short-Circuit Config Override Resolution for Empty Principals (#12549)"

This reverts commit a100aa5738.

---------

Co-authored-by: CMF\e-leite <EduardoLeite@criticalmanufacturing.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-05-23 08:57:59 -04:00
Ravi Kumar L
03b477a84c
🖲️ feat: Trace SSE Stream Lifecycle with OTel (#13266) 2026-05-22 21:19:45 -04:00
Danny Avila
bd64251eb9
🪪 fix: Prevent MCP Server Name Collisions (#13256)
* fix: prevent MCP server name collisions

* chore: address MCP registry review nits

* fix: reserve MCP config names from request context

* chore: format MCP registry changes

* chore: address MCP collision review findings
2026-05-22 20:46:14 -04:00