* 📦 chore: bump `@librechat/agents` to version 3.4.2
* 📦 chore: bump `mermaid` to version 11.16.1 and update related dependencies
* 📦 chore: bump `js-yaml` to version 4.3.1 in package-lock and data-provider
* 📦 chore: bump `nanoid` to version 3.3.18 in package.json and package-lock.json across multiple packages
* 🔧 fix: Remove stray `api/tsconfig.json` breaking e2e `~` alias
An empty `api/tsconfig.json` was accidentally committed with the agents bump.
Playwright's require hook resolves path aliases from the nearest path-config,
checking `tsconfig.json` before `jsconfig.json` in each folder, so the empty
file shadowed `api/jsconfig.json` — the only place `"~/*": ["./*"]` is defined.
Every e2e spec that calls `cleanupUser` then failed on
`Cannot find module '~/cache/getLogStores'` from `api/models/index.js`.
- delete the stray file and gitignore it so tooling can't re-commit it
- register `module-alias` in `cleanupUser` so backend requires resolve
regardless of which path-config Playwright happens to find
* 📦 chore: bump `@librechat/agents` to version 3.4.3 in package.json and package-lock.json
* feat: add Claude hook compatibility layer
* fix: satisfy API declaration build
* fix: normalize hook matchers and conditions
* fix: align Claude hook lifecycle semantics
* fix: preserve Claude hook execution semantics
* fix: gate unsupported Claude hook controls
* fix: refine Claude payload and matcher translation
* fix: scope Claude hook conditions to tool events
* fix: preserve mixed Claude hook documents
* fix: honor Claude hook execution controls
* fix: close Claude hook lifecycle gaps
* fix: isolate Claude hook declaration state
* fix: preserve Claude matcher semantics
* fix: handle PreemptBoundary in plugin hook payload switch
`PreemptBoundary` joined `HOOK_EVENTS` in @librechat/agents 3.4.2. It has no
Claude counterpart and is absent from the compatibility EVENT_MAP, so it can
never reach a registered declaration, but the payload switch must stay
exhaustive so a future event fails the build rather than returning undefined.
* perf: cut serial round trips from the agent list query path
The agent list was the slowest path on first page load. Three separate
problems compounded:
- `getListAgentsHandler` chained its reads: two ACL lookups, the avatar
refresh cache probe and the viewer skill scope all resolved serially
ahead of the list query, and `attachOwnerContacts` added two more hops
after it. The four independent reads now resolve together, and the
avatar refresh runs alongside the list query instead of before it -
refreshed paths reach the response through `urlCache`, not through
whatever the list query happened to read. Serial hops per request drop
from 7 to 4 on a warm cache.
- The avatar refresh loaded the user's whole accessible agent set (up to
MAX_AVATAR_REFRESH_AGENTS) to discover which entries were S3-backed.
Scoping the query to `avatar.source` means deployments on any other
file strategy match nothing instead of walking the full set.
- `fetchAllAgentPages` walked cursor pages at the server's default size
of 100, and callers consume the flattened result, so every extra page
was a serial round trip for no benefit. It now requests the server
maximum. Measured over a 2,860 agent account: 29 requests / 1.65s
before, 3 requests / 0.29s after.
Also parallelizes the conversation file reads in `initializeAgent`. The
convo file refs and the execute_code thread walk share no inputs, and the
two code-file lookups depend only on `threadFileIds`, so the chain of six
serial reads on every turn collapses to two. This one is time to first
token the user waits through.
* perf: virtualize the model selector agent list
Opening the agents submenu with a large agent set froze the tab and could
kill it outright. With ~10k accessible agents the submenu blocked for over
15 seconds and took the heap from 96MB to 911MB. Four per-row costs were
being multiplied by the full list, which rendered unwindowed:
- `useIsActiveItem` allocated a MutationObserver per row (10,016 of them
for one dropdown). Replaced with an Ariakit store subscription, which
needs no observer at all and returns a boolean so a row only re-renders
when its own active state flips.
- `useFavorites` ran per row, opening a jotai subscription, a query
subscription and a mutation each time. Hoisted to one call per endpoint.
- Each row rescanned `endpoint.models` to recover `isGlobal`, a field the
parent had already discarded from the array it was mapping. The parent
now passes it down from a lookup map.
- The list itself is now windowed above 100 rows. Ariakit's composite only
knows about mounted rows, so arrow-keying to the window edge previously
found no next item and let focus escape the nested menu, closing it;
`handleBoundaryNavigation` scrolls the next index in, waits for it to
mount, then moves the composite onto it. Navigation inside the window is
left to Ariakit.
Open drops from >15s to 96ms, mounted rows from 10,028 to ~18, DOM nodes
from 123,346 to ~1,000, and the heap no longer grows. Verified in browser:
arrow keys track 1:1 to index 238 and back, and click selection works.
* perf: serve the model selector from the shared VIEW agent query
The model selector asked for EDIT-scoped agents whenever the marketplace
is enabled, while `useAgentsMap` and `useMentions` asked for VIEW. Since
the cache key includes the params, that was two distinct entries, so first
page load ran the paginated walk twice and held two copies of the whole
agent list in memory. Measured against a 10k agent account: 22 list handler
invocations per page load, now 11.
Collapsing the two by asking for the same permission everywhere would have
changed what the selector shows - under the marketplace the EDIT scope is
what makes it "My Agents", with discovery handled by the marketplace entry.
So the list endpoint now marks each row with `isEditable`, resolved from an
ACL read folded into the existing parallel batch (no extra serial hop), and
the selector filters the shared VIEW response instead of refetching. A
VIEW-scoped list for a user with 2861 visible / 361 editable agents returns
exactly 360 rows flagged editable, matching what the EDIT query returned.
`AgentSelect` deliberately keeps its own EDIT query: it reads `skills` and
`skills_enabled`, which `sanitizeViewerSkillScope` strips from VIEW-scoped
responses. It also only mounts when the builder panel is open, so it is not
part of the first-load cost.
The field is set unconditionally rather than omitted when false so that a
client talking to an older server sees `undefined`, keeps every agent, and
degrades to showing too many rather than none.
* fix: address review findings on the agent list at scale
Three issues from review, all confirmed against the code before fixing.
Avatar refresh no longer runs alongside the list query. `updateAgent` writes
through `findOneAndUpdate` on a `timestamps: true` schema, so refreshing an
avatar advances `updatedAt` — the field `getListAgentsByAccess` sorts and
cursors on. A write landing after the first page's snapshot moved that agent
ahead of the returned cursor, dropping it from every later page and silently
truncating the caller's flattened list. This was a regression introduced when
the two were parallelized; serializing them costs nothing on the common path,
because a cache hit returns without issuing any query, so only the
once-per-30-minutes miss pays for the ordering. The new test asserts the write
lands before the list snapshot and fails against the parallel version.
The virtualized list no longer inserts a focusable grid into the combobox.
`List` spreads its props onto `Grid`, whose defaults are `role="grid"`,
`containerRole="row"` and `tabIndex={0}`; inside Ariakit's listbox that added a
tab stop ahead of any row and put grid/row semantics between the listbox and its
options. All three are now neutralized so focus and ARIA stay with the combobox
items.
The list also resets to the top when the filter changes. `Grid` keeps its scroll
offset across prop changes and clamps an out-of-range offset to
`totalRowsHeight - height`, the end of the shorter list. Scrolling deep and then
searching landed on the tail: measured at row 626 of 667 matches, with only
those rows mounted and reachable by keyboard. Keying the list on the search
value restores row 0.
* fix: declare option position and set size for the virtualized model list
Once the model list is windowed, only the mounted slice exists in the listbox,
so a screen reader infers position and total from ~19 elements instead of the
real set — announcing "3 of 19" partway through 10,014 agents.
Model rows now carry aria-posinset and aria-setsize. The marketplace entry and
any model specs share the same numbering, because they are options in the same
listbox: declaring the values on some options while leaving others to be
inferred from the DOM would make the set internally inconsistent. Both are
omitted entirely when the list is short enough to render unwindowed, where the
DOM holds every option and the implicit values are already correct.
Verified against a 10,014 agent account: the marketplace entry reports 1 of
10015, the first models 2 and 3, and after scrolling to row 4999 the leading
mounted model reports 5001 of 10015 with 19 options in the DOM.
* 🩹 fix: Address Follow-Ups on the Agent List at Scale
Corrects residual issues in the agent-list perf work, all inside its own scope.
- Forward `idOnTheSource` through `PermissionService.findAccessibleResources`
so `getUserPrincipals` skips the user-document read. The list handler resolves
three permission sets per request and each was paying its own `User.findById`;
the auth strategies already normalize the field to a value or null.
- Gate the editable-set lookup on its own predicate instead of borrowing
`canReturnSkillConfig`. The two answer unrelated questions and only coincide
today, so redefining the skill flag would have marked every agent editable.
- Log mapping failures in the list response instead of swallowing them.
- Apply the walk page size after the caller's params in `fetchAllAgentPages`.
A caller limit only changed page size, never what the flattened walk returned,
so `defaultAgentParams`' `limit: 10` would have turned one request into 301.
- Carry `isEditable` on the agent rows the create and update mutations write
into the list cache. Mutation responses omit the field, so those rows lost it.
- Document `isEditable` as list-only, ACL-derived, and fail-open on absence.
- Restore the truthiness guard on the thread walk in `initializeAgent`. Widening
it to `!= null` made an empty `parentMessageId` issue a full-conversation read
against an anchor that can never match.
- Await `getConvoFiles` directly rather than calling `.then()` on it, restoring
tolerance for synchronous test doubles.
- Correct the avatar-refresh comment: the projection was never full documents,
and the real reason to filter is that an unfiltered budget is self-reinforcing.
Tests: both new `initialize` tests and both new backend tests are
mutation-verified; the concurrency test fails under either serialization order.
* fix: preserve ACL isEditable when merging agent mutation responses
Mutation responses omit list-only isEditable. Inferring true from write
success promoted VIEW-only rows into the editable subset for MANAGE_AGENTS
callers who can PATCH agents their ACL marks non-editable.
* fix: sort imports in agent mutations test
ESLint import-order check failed on the isEditable cache-preservation test.
* 🧷 fix: Carry isEditable Onto Duplicated Agent List Rows
`useDuplicateAgentMutation` prepended the raw duplicate response to the cached
list, and mutation responses omit the list-only `isEditable` field. The row
survived the "My Agents" filter only by failing open on `undefined`, so it would
disappear the moment a consumer read the flag strictly.
Duplicating grants the caller ownership, so the new row is editable outright;
this is the create case rather than the merge case `mergeAgentListRow` handles.
Last cache write on this path that did not carry the field.
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* ci: bump GitHub Actions to Node.js 24 runtimes
Clear Node 20 deprecation warnings on runners by moving workflow
actions to majors that declare node24 (checkout, cache, setup-node,
artifacts, Docker buildx/build-push/qemu/login, github-script,
setup-go, Azure login/helm, create-pull-request, axe-linter).
* fix: release leader lock via ioredis on Redis Cluster
@keyv/redis EVAL can surface unhandled MOVED redirects on cluster,
so resign() logged failure and left LeadingServerUUID set. Use ioredis
for leader election SET NX / GET / Lua (same pattern as principals and
concurrency locks) so cluster redirects are retried and resignation
clears the lock.
* fix(data-schemas): refresh FerretDB harness model coverage, fix compile errors, add bulkWrite differentials
Track 2 of the search-stack plan (PLAN.md "FerretDB track"):
- Replace the three hand-rolled 29-model MODEL_SCHEMAS maps in
multiTenancy/sharding/orgOperations.ferretdb.spec.ts with a shared
getModelSchemas(mongoose) helper (misc/ferretdb/schemas.ts) derived from
the live createModels() registry, so coverage tracks all 37 current
models automatically instead of drifting. Matches the reference pattern
in misc/documentdb/compat.documentdb.spec.ts.
- Fix the 3 compile-broken specs this uncovered: all three imported a
`projectSchema` from '~/schema' that no longer exists (superseded by
`chatProjectSchema`), which `tsc --noEmit` flags as TS2724 but the
babel-based jest transform silently let through as `undefined`. Removing
the hand-rolled maps removes the bad import as a side effect; verified
clean with tsc across misc/ferretdb and misc/documentdb.
- Add misc/ferretdb/bulkWrite.ferretdb.spec.ts: differential specs for the
five bulkWrite flows the plan names as actually at risk (import via
bulkSaveConvos/bulkSaveMessages, bulkWriteAclEntries,
bulkIncrementTagCounts, Transaction.insertMany, file-TTL bulkWrite via
extendFilesTTL). Each flow runs identical operations against a real
mongodb-memory-server (always) and, when FERRETDB_URI is set, against
FerretDB, asserting normalized result equality. Multi-document
transactions already degrade via the existing supportsTransactions
probe — not duplicated here.
- Land the Spike A BSON-legibility findings (bson-legibility.md,
bson-inventory.txt) from the bson-legibility-spike-6e38de worktree so
decision 2's evidence is in-repo.
Verified against a real FerretDB 2.7.0 + postgres-documentdb 17 stack
(docker compose -f misc/ferretdb/docker-compose.ferretdb.yml): all 10
harness spec files pass individually, including all 10 bulkWrite.ferretdb
tests (5 mongodb-memory-server baselines + 5 FerretDB differentials). Full
packages/data-schemas src/ suite (1907 tests) unaffected.
* 📝 docs: make the BSON projection findings self-contained
The doc was written for readers who already knew the internal shorthand — it
opened on "Spike A executed, Spike B scoped" and referred to Options 1/2/3 and
"the handoff" without ever defining them, so a reader arriving from the repo
could not follow the argument or act on the recommendation.
Reframed around what the document actually investigates: the question is stated
up front, the three candidate mechanisms are named in a table before they are
compared, and the recommendation refers to them by name. No findings, numbers,
or SQL changed.
* 📝 docs: drop internal planning references from spec header
* fix(data-schemas): keep FerretDB harness schema derivation side-effect free
`getModelSchemas()` derived its map by calling `createModels(mongoose)`,
which carried three consequences the harness did not want:
- Model creation applies the tenant-isolation plugin to the module-level
schema singletons, so every harness read and write inherited middleware
that throws under `TENANT_ISOLATION_STRICT=true`.
- Registering on the default connection meant the benchmark's own
`mongoose.connect()` auto-created 37 collections in the URI's base
database, adding a database and dozens of collections to the very
catalog metrics it measures.
- The unfiltered registry provisioned app-wide control-plane models
(`SystemGrant`, `AuditLog`, `SkillSyncCredential`, `SkillSyncStatus`)
into every org database.
The helper now builds the registry on a throwaway Mongoose instance,
returns schemas rebuilt from their own definition, options, and declared
indexes, skips the four app-wide models (validated against the registry so
a rename fails loudly), and memoizes the result.
Also in this pass:
- The "adds a new collection" migration test used `AuditLog`, which
provisioning had already created, so it silently reused the production
model and ignored its proposed schema. It now uses a fixture model absent
from the registry and asserts the collection is missing beforehand and
carries the proposed compound index afterwards.
- `bulkWrite` flows run inside `runAsSystem()`; they drive production
methods unscoped, as a cross-tenant maintenance job does, and otherwise
fail closed under strict tenant isolation.
- Phase 2's sparse-index assertion pinned a count the User schema no longer
declares; it now checks that each index type round-trips.
* feat: add search stack PoC infrastructure (Track 1)
Docker Compose stack for PLAN.md's new chat-search architecture:
ferretdb 2.7.0 + its postgres-documentdb 17 backing store (wal_level=logical
for the later CDC spike), a new dedicated chat_search_db (PostgreSQL 17 +
pgvector, non-default credentials, three least-privilege roles per the
Security roles section), and clickhouse (26.3 LTS). vectordb/rag_api are
untouched, per decision 3.
All four image tags verified against their registries via curl (ghcr.io
manifest lookups, Docker Hub tags API) before pinning. Host ports checked
against every existing compose file in the repo to avoid collisions.
The role-provisioning init script and healthcheck script were both actually
run against live containers once Docker became available mid-task: full
Mongo-wire round trip against ferretdb, real INSERT/SELECT proving the
writer's default-privilege grants and the reader's deny-by-default posture
against a throwaway pgvector table, wal_level and pgvector/pg_trgm extension
checks. One bug only surfaced at runtime and is fixed: psql does not
interpolate :'var' inside dollar-quoted DO $$ ... $$ blocks, so role
creation/idempotency uses \gset + \if/\else/\endif instead.
* 🔐 chore: require operator-supplied FerretDB credentials
The chat_search_db and ClickHouse services already refused to start without
operator-supplied passwords; FerretDB's backing PostgreSQL still fell back to
a working ferretdb/ferretdb pair, so the stack booted with a known credential
even though it holds projected chat content. That is the same shape as the
myuser/mypassword default the search plan calls out on the existing vectordb
service.
All four FerretDB credential references now use ${VAR:?} - compose, the
healthcheck script, and the README examples - and .env.example ships
REPLACE_ME placeholders instead of literals.
The differential-test harness at packages/data-schemas/misc/ferretdb keeps its
fixture credentials; it holds only throwaway test data.
* 🩹 fix: Keep Edit Action Fully Hidden While Streaming
#14677 stopped the row-hover reveal from un-hiding the edit button, but the
pencil still shows as a dimmed ghost mid-generation. The shared Button
primitive sets `disabled:opacity-50`, which compiles to
`.disabled\:opacity-50:disabled` — specificity (0,2,0). The hidden state used a
plain `opacity-0` at (0,1,0), so the disabled style won and painted the icon at
half opacity.
Verified in Chromium against a running instance: only two opacity rules match
the button, and the computed value was 0.5. Switching the hidden state to
`!opacity-0` (Tailwind emits `opacity: 0 !important`) drops it to 0 while the
sibling actions still reveal at 1 on hover.
The existing unit test could not catch this: jsdom applies no stylesheet, so
asserting class names never exercised the cascade. It now asserts the important
modifier specifically, with a comment explaining why a bare `opacity-0` is
insufficient.
* 🧪 test: Browser guard for the hidden edit action
The Jest spec can only assert class names — jsdom applies no stylesheet, so it
could not see `disabled:opacity-50` (0,2,0) outranking `opacity-0` (0,1,0) and
repainting the hidden pencil at half opacity. That is exactly how the ghost
survived #14677 with a green suite.
Asserts computed opacity in a real browser mid-stream, and asserts the sibling
Copy action is at opacity 1 in the same breath so a hover that silently failed
to register cannot make the check pass for the wrong reason. Verified to fail on
the pre-fix build with `Received: "0.5"`, and to pass 3/3 after.
* ♿ fix: Restore WCAG AA Contrast for Text Tokens & Hide Edit Action While Streaming
Fixes the unreadable composer placeholder and the edit pencil that appears on
hover mid-generation, plus the sibling token failures found while tracing the
root cause.
Placeholder: #13879 moved the composer from `dark:placeholder-white/60` to the
semantic `placeholder:text-text-tertiary`, but `--text-tertiary` was
`var(--gray-500)` in *both* themes, and #595959 is a dark gray. Dark mode fell
from 5.90:1 to 1.91:1. Fixed at the token (dark -> gray-400, 4.56:1) rather than
the call site: the token has 99 usages and was failing at 1.91-2.77:1 on every
dark surface. The .gizmo dark theme already uses a light gray (#999999) for the
same token, so only the default dark theme carried the inverted value.
Two more instances of the same "token never tuned per theme" bug:
- `--text-warning` was amber-500 in both themes: 2.15:1 in light across 13
real warning strings. Now amber-700 (5.02:1).
- Light `status-{success,warning,error}` on their own `-subtle` fill measured
3.58 / 3.07 / 4.41 -- the exact pairing Alert, Badge, Tag and Chip use for
every status variant. Bumped to the 700 ramp (5.21 / 4.84 / 5.91). Solid
`bg-status-*` is only used for dots, so nothing renders text on it.
Edit action: `hideEditButton` already covers `isSubmitting` and the button got
`isVisible={false}` -> `opacity-0`, but `group-hover:opacity-100` (0,2,0)
outranks bare `opacity-0` (0,1,0), so hovering the row revealed a disabled
pencil. The reveal classes are now gated on `isVisible`, with
`pointer-events-none` so the hidden button is inert.
Both token sources of truth (style.css and themes/*.ts) were updated and verified
in sync across all 67 tokens.
Tests: new HoverButtons spec covers both hover states; semanticTokens.spec.ts
gains a contrast guardrail over text tokens x surfaces and each status hue
against its subtle fill, verified to fail on the original values.
applyTheme.spec.ts now derives its expectation from the theme object instead of
pinning a hex, so retuning a hue no longer breaks an unrelated plumbing test.
* 🔤 style: Sort imports in HoverButtons spec
CI's changed-files import-order check flagged the new spec; the previous
commit bypassed the lint-staged hook that would have caught it.
* 🔑 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.
* 🔁 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.
* 🧪 fix: Add updateUser to appleStrategy test mock for socialLogin migration
The shared socialLogin verify callback now invokes `updateUser` when the
email-fallback path discovers a same-provider user with an empty provider
id, persisting the refreshed sub. The Apple strategy test's `~/models`
mock did not stub `updateUser`, so the migration path hit
`TypeError: updateUser is not a function` and failed the
`should handle existing user and update avatarUrl` case in CI shard 1/3.
* 🧹 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.
* 🔁 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.
* 🔒 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.
* 🔒 fix: Reject refresh for users migrated off the Google provider
The interactive Google admin login path in socialLogin.js already rejects
a user whose provider field is not 'google', returning AUTH_FAILED. Without
a matching guard in the refresh path, a user migrated to OpenID could use
an unexpired Google refresh token to keep minting admin JWTs indefinitely.
Add a PROVIDER_MISMATCH check after resolving the user in both the direct
getUserById branch and the findUsers fallback branch of resolveAdminUser,
mirroring the provider gate the interactive path enforces.
* 🔒 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.
* 🔒 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.
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* feat: day-aware landing greeting schedule
Replace the branching time-of-day greeting in Landing with a declarative
schedule keyed by weekday and hour. Each slot maps to a translation key,
with an optional personalized variant interpolating the user's name, and
days without a custom schedule fall back to the default one.
The greeting resolves after mount to keep server-rendered markup stable,
arms a single timer for the next slot boundary instead of polling, and
recalculates on tab visibility and window focus so a sleeping machine or
timezone change does not leave a stale greeting on screen.
* feat: rotate landing greeting variants by day
Each schedule slot now holds a pool of variants instead of one line, and the
active variant is chosen from the local calendar day, so the greeting holds
steady across a slot but differs from one day to the next. Day-specific lines
join their day's pool rather than replacing it.
Raise the landing large-text cutoff to 56 characters so a personalized
greeting with a long display name, or a longer translation of one, keeps the
intended size, and add a test pinning every variant under that budget.
* feat: add a dawn greeting slot between late night and morning
04:00 to 07:00 sits between the two moods the schedule had: too late for
"up late", too early for "good morning", and the visitor could be up early
or not yet in bed. Give it its own slot that plays on the ambiguity, and
move the early bird line into it, where the timing actually fits.
* test: replace microtask timing assumptions in client specs
useIsActiveItem asserted MutationObserver delivery after a single awaited
microtask, and UploadSkillDialog queried the file input synchronously right
after render. Both assume work settles on a fixed tick, which does not hold
when the host is loaded, and both fail intermittently as a result.
Poll for the expected state instead, and assert in the unrelated-mutation
case that the observer still reacts to a real change afterwards.
* test: address review on client spec timing fixes
Return the narrowed input from the polling callback instead of asserting
through unknown, so the type check the helper performs is the one the
compiler sees.
Synchronize the unrelated-mutation case on a second observer rather than a
wait that was already satisfied before delivery, and drop the trailing
attribute dance that assertion no longer needs. Verified by removing the
attribute filter and setting the state unconditionally: the test now fails,
where before it passed.
Raise this file's Jest timeout above the aggregate wait budget, since a test
that chains two observer waits could otherwise be aborted at five seconds
while both waits were still within their limit.
* test: correct observer wait rationale and revert inert upload polling
Follow-up to #14316 / #14317. The share dialog deduped picker adds by raw
idOnTheSource, but loaded ACL rows carry the external oid for OpenID/Entra
users while search results carry the local _id (searchPrincipals dropped the
selected idOnTheSource before transforming). Re-adding an already-listed user
therefore appended a duplicate row, and the stable-id de-dup in
computeShareChanges let the appended default-role row silently shadow a
pending role edit (no-op PUT with a success toast).
- Dedupe adds via a new dedupeNewShares helper keyed by principalKey, and
skip marking the dialog dirty when nothing was added
- Propagate idOnTheSource through the user-search transform so picker
results match ACL rows (also fixes excludeIds filtering)
- Add regression tests
Closes#14654
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
Expose ADMIN_PANEL_URL through the startup config for users holding the
access:admin capability, and render an Admin section in Settings > General
with an external link to the admin panel. The URL is omitted server-side
for unauthenticated requests and users without admin access.
* 🌊 feat: Wire Adaptive Stream Smoothing Across Google, Bedrock, and Zero-Disable Semantics
With @librechat/agents 3.4.0 smoothing defaults ON (25ms adaptive) for
every provider; this completes the LibreChat side:
- google: fix the unguarded endpoints.all clobber and wire streamRate
into llmConfig._lc_stream_delay — previously read from config and
silently dropped, making google streamRate a no-op end to end
- bedrock: wire streamRate (endpoint + endpoints.all) — previously
absent entirely, so bedrock smoothing was unreachable from config
- anthropic/openai: nullish guards so streamRate: 0 survives as the
explicit smoothing disable; drop the azure 30/17 hardcoded fallback
the SDK default now supersedes
- delete dead createHandleLLMNewToken (no call sites since #6886, and
LangChain backgrounds callbacks so a sleep there never paced anything)
- schema: streamRate gains .min(0) and docs; example docs updated,
including pairing STREAM_DELTA_COALESCE_MS with the smoothing tick
* 🩹 fix: Address Review — Keep Published Shim, Typed Delay Access, Scoped Docs
- restore createHandleLLMNewToken as a @deprecated compatibility shim:
it ships in the public @librechat/api root, so removal is reserved for
a major release
- assign llmConfig._lc_stream_delay via the SDK's typed property (3.4.0
StreamSmoothingOptions) instead of Record<string, unknown> casts at
all five sites
- scope the 25ms-default wording to agents SDK-backed providers (legacy
Assistants/Ollama still per-chunk sleep at DEFAULT_STREAM_RATE=1) and
clarify coalescing guidance when streamRate: 0
* refactor: unify Tailwind color tokens into a single source
Both the client SPA and @librechat/client Tailwind configs now consume one
createTailwindColors() map, eliminating config drift. Fixes the package-side
build along the way: shadcn tokens are wrapped in hsl(), the broken opacity
helper is removed, and text-destructive/border-destructive/switch-unchecked
plus the gray/green palettes are included.
* refactor: replace hardcoded colors in sidebar conversation list with tokens
Migrate the Conversations sidebar section to semantic tokens: focus rings to
ring-text-primary (keeps >=3:1 contrast in both modes; the mid-gray ring would
fail WCAG 1.4.11 on dark), the active-conversation indicator and hover-fade
gradient to surface/text tokens, and the pagination controls. Removes every
dark: color twin; no behavior change.
* feat: add semantic status-color tokens; migrate MCP status badge
Add a status-color layer (status-{success|info|warning|error|neutral} plus
-subtle variants) to style.css and the unified createTailwindColors map, with a
blue palette for the info hue. Migrate MCPStatusBadge (badges + dots) and
MCPCardActions to the new tokens, removing all hardcoded status colors and
dark: twins. Status colors are now themeable like the rest of the system.
* refactor: migrate status badges to semantic status-color tokens
Migrate the genuine status badges to the status-* tokens: MCPConfigDialog
connection pills (info/warning/neutral/error/success + dot), MemoryUsageBadge
usage levels, and DialogImage quality badge (also gains dark-mode support it
previously lacked). Removes hardcoded colors and dark: twins.
* feat: add Alert component and migrate alert banners to it
Add a reusable Alert component (@librechat/client) with error/success/warning/
info/neutral variants backed by the status-color tokens, default per-variant
icons, and role=alert. Migrate the duplicated colored-div banners to it:
Auth ErrorMessage, RequestPasswordReset success, and the identical error boxes
in ToolSelectDialog, AssistantToolsDialog, and MCPToolSelectDialog.
* refactor: migrate remaining alert banners and error states to tokens
Migrate the last banners to the Alert component: ResetPassword success,
MessageContent connection error, and MemoryInfo storage-full errors. Tokenize
the Agents ErrorDisplay error state in place (icon badge, headings, message,
retry button) since it's a full error state, not a compact callout. Also
tokenize ResetPassword field-validation errors to text-text-destructive
(fixes the low-contrast dark:text-red-900).
* refactor: tokenize SidePanel Memories/Parameters/Bookmarks colors
Delete-confirm buttons to surface-destructive tokens (MemoryCardActions,
BookmarkCardActions), drop redundant text-white on submit Buttons (the variant
already sets it), legacy preset button green hover/focus to submit tokens, and
slider hover borders to border-light. Leaves DynamicCheckbox dark overrides for
a separate pass against the Checkbox component.
* refactor: tokenize Settings danger/destructive buttons
Map the DangerButton, the Data tab destructive actions (RevokeKeys, ClearChats,
DeleteCache), and the DeleteAccount button from bg-red-*/bg-destructive to the
surface-destructive tokens.
* refactor: tokenize Chat file-upload table and upload status colors
Tokenize TemplateTable th/td/border classes (surface-primary, border-light,
text-primary/secondary) and FileUpload status colors (text-text-secondary,
text-text-destructive, text-status-success) plus the import button hover.
* fix: explicit type annotations on Alert for isolatedDeclarations
@librechat/client builds with tsdown --isolatedDeclarations, which requires
exported consts to have explicit type annotations (TS9010). Annotate
alertVariants and Alert to match the Button.tsx pattern.
* refactor: add soft status-border token layer for Alert and lighten dark status foregrounds
* refactor: tokenize Chat menus, popovers, and message surfaces
* refactor: tokenize Chat message content, tool output, and file UI colors
* refactor: add semantic link color token and migrate hyperlinks to it
* refactor: tokenize Files and Auth surfaces, text, borders, and CTAs
* refactor: add accent-primary brand token; tokenize Nav/Input/Prompts/Endpoints colors
* refactor: tokenize Auth brand-green accents, Skills, Sharing, Plugins, MCP colors
* refactor: tokenize OAuth, Share, ui, Bookmarks, Tools, Messages, Web, SharePoint colors
* refactor: final solid-color cleanup (brand-green accents, neutral grays, error text)
* refactor: migrate status callout banners to status-subtle/border tokens
* refactor: tokenize token-usage gauge, mic, and oauth countdown status colors
* refactor: replace shadcn color vocabulary with semantic tokens
Remove the shadcn/ui color tokens (background, foreground, card, popover,
muted, accent, secondary, destructive, input) and migrate every usage to
LibreChat semantic surface/text/border tokens.
Add surface-inverted/text-inverted for the neutral inverted CTA and
surface-fixed/text-fixed for controls that must not flip with the theme
(favicon chips, QR container, carousel arrows). New tokens are defined once
in style.css (light + dark), createTailwindColors, the theme types,
applyTheme and the default/dark theme objects so they stay overridable at
runtime.
Collapse paired dark: color variants into the dark-aware tokens and tokenize
the remaining raw palette and white/black utilities, mapping status colors to
the status-* tokens and legacy ring-black/ring-white focus rings to
ring-text-primary.
Retain the background, primary and ring tokens, which are still referenced by
the SidePanel/Agents and SidePanel/Builder panels (excluded from this pass).
* refactor: tokenize remaining status, neutral and message-text colors
Map the leftover semantic colors to tokens: skill error/dirty states and the
selected-version/selected-skill highlights move to status-warning/status-success,
the global indicator to status-success, and the markdown message text to
text-text-primary. Drop the redundant dark: overrides on the dynamic checkbox,
which the Checkbox primitive already handles.
What remains is intentional and stays raw: categorical color sets (category
icons, principal avatars, per-tool toggle accents), brand marks, the
WCAG-tuned toast severities, code/diagram surfaces, scrims, and text-white on
submit/destructive action surfaces.
* refactor: remove unused CSS rules, dead comments, and duplicate keyframes
Drop ~829 lines of dead styles across style.css (2992->2355) and
mobile.css (323->131): unreferenced classes (legacy token utilities,
orphaned animations, form/prose/scrollbar leftovers), commented-out
blocks, and duplicate/orphaned keyframes. Library-injected (hljs, sandpack,
codemirror, markdown language) and dynamically-applied (scroll-animation,
icon sizes) classes were retained.
* fix: resolve ESLint and frontend test failures
- Format with prettier (Alert, MCPStatusBadge, ApiKeys, Memory, etc.) after
--no-verify commits skipped the hook
- Localize the 'Or' auth divider (com_auth_or) instead of a bare literal
- Drop dead InvocationModePicker imports in Skill forms; fix VerifyEmail
unused arg + useEffect deps
- Revert out-of-scope color edits in legacy Files/VectorStore views that
carried pre-existing untranslated-string lint debt
- Update Memory tests to assert status-* tokens (text-status-error,
bg-status-error-subtle) instead of the old hardcoded red classes
* refactor: migrate theme tokens to RGB channels for opacity support
Convert semantic + palette CSS variable values in style.css from hex to bare
'R G B' channel triplets, and emit Tailwind colors as
rgb(var(--token) / <alpha-value>) via createTailwindColors. This makes opacity
modifiers (bg-surface-primary/50, bg-border-medium/60, etc.) resolve correctly
and remain dark-aware, fixing ~26 existing usages that previously fell back to a
hardcoded light hex.
- Wrap direct var(--token) color usages in CSS rules as rgb(var(--token))
(style.css, Dropdown.css, Tooltip.css) and two inline component styles
- applyTheme writes bare triplets to match the new wrapping
- shadcn tokens (HSL) and the JS palette (hex) are unchanged
* fix: prettier formatting after dev rebase
* refactor(client): migrate low-risk primitives to @librechat/client
Swap raw <label>, <textarea>, and native title= tooltips for the
@librechat/client Label, Textarea, and TooltipAnchor components across
Agents, Endpoints settings, Export modal, Prompts, Sharing, and Memory
dialogs. Add localization keys (scroll, sibling navigation, none
selected, select var) for the remaining swap waves.
* refactor(client): migrate buttons, inputs and labels to @librechat/client
Swap raw <button>, <input> and <label> elements for the @librechat/client
Button, Input and Label components across Auth, Chat, Conversations,
Endpoints, Nav, Prompts, Skills, Tools and Web. Preserve bespoke geometry
and behavior via cn className merging, keep data-testid/aria wiring, and
localize previously hardcoded aria-labels. Skip swaps that would break
floating-label animations, tiny bespoke controls or inline-text links.
Add com_ui_reload_page key.
* refactor(client): migrate dialogs, toggles and remaining controls to @librechat/client
Swap behavioral controls for @librechat/client equivalents: HeadlessUI
and legacy dialogs to OGDialog, native checkbox/switch to Checkbox/Switch
(onCheckedChange), and remaining buttons/inputs across Chat, Skills,
Tools, Sharing, Memories and Settings. Convert applicable native title=
tooltips to TooltipAnchor and localize close/scroll aria-labels. Skip
swaps that would break floating-label animations or bespoke select
behavior. Update co-located test mocks to provide the newly-used Button
and cn dependencies.
* style(client): soften dropdown and settings search inputs
Remove the heavy focus ring on the settings search and the searchable
Dropdown's search input, replacing it with a subtle border-light. Make
the search field background inherit the dropdown surface so it matches in
both light and dark mode, and reduce the Dropdown trigger border from
medium to light.
* refactor(client): migrate Agent Builder and Tool Library to @librechat/client
Swap raw buttons, inputs, labels, textareas and native title tooltips for
the @librechat/client Button/Input/Label/Textarea/TooltipAnchor components
across the Agent Builder panel (SidePanel/Agents) and the Tools
marketplace. Remove heavy input focus rings in favor of subtle borders,
soften dropdown trigger borders, and convert stray shadcn/raw colors in
touched lines to semantic tokens. Localize the tool delete aria-label and
toast messages. Update co-located test mocks to provide the newly-used
Button component.
* fix(client): keep Input border static on pointer focus
The pointer-focus override in Field.css used border-color: var(--border-light),
which became an invalid value after the theme moved to RGB channel tokens and
was silently dropped, letting the border fall back to currentColor (text-primary)
on mouse focus. Wrap it in rgb() so mouse focus produces no border, ring, or
outline change; keyboard focus keeps its ring for accessibility.
* refactor(client): remove residual shadcn color tokens
The background/primary/primary-foreground/ring and unused chart-* tokens were
retained only for the then-unmigrated Agent Builder. With that panel migrated,
replace the last usages with LibreChat semantic tokens (ring-primary/ring-ring
-> ring-text-primary; bg-primary/text-primary-foreground -> bg-surface-inverted
/text-text-inverted; text-primary -> text-text-primary; bg-background ->
bg-surface-primary) and drop the token definitions from createTailwindColors,
applyTheme, the theme objects, types, and style.css.
* fix(client): address semantic theme review feedback
* fix(client): use boolean Monaco hover option
* fix(client): resolve CI validation failures
* test(client): update shared component mocks
* fix(client): expose status tokens to runtime themes and document channel format
Add the status, text-destructive and border-destructive families to IThemeRGB,
IThemeVariables, IThemeColors, mapTheme and the bundled light/dark themes so
ThemeProvider consumers can theme Alert and the status badges instead of falling
back to the stylesheet palette.
Update the theme README to document the channel-triplet contract that the RGB
migration introduced, since the previous examples used complete CSS colors that
now produce invalid declarations.
* test(e2e): use accessible message action locators
* fix(client): address theme env, dialog padding and locked button review feedback
Expose every IThemeRGB token through REACT_APP_THEME_* instead of the
hand-maintained subset that omitted the status, destructive, inverted and
fixed families.
Drop the padding OGDialogContent contributes to the Tool Library so the
header divider spans the panel again, and stop disabled:opacity-100 from
overriding the locked delete-account button's dimmed state.
* fix(client): read theme environment variables from the build-time env
getThemeFromEnv read process.env, which vite-plugin-node-polyfills replaces
with an empty shim in the browser, so every REACT_APP_THEME_* value was
dropped and the loader always returned undefined.
Read import.meta.env instead and register the REACT_APP_THEME_ prefix with
Vite so the values are inlined at build time. The env source is now a
parameter, which lets the tests cover the mapping without mutating globals.
* fix(client): replace Tailwind classes that no longer resolve
Several class names in the client and shared component package emit no CSS
rule at all: legacy token- names with no definition, Tailwind v1/v4 names,
and plain typos. They fail silently past typecheck and tests.
- text-md -> text-base (Tailwind has no md font size)
- text-grey-100, text-tertiary -> text-text-tertiary
- text-token-secondary -> text-text-secondary
- bg-token-surface-primary/tertiary, bg-token-main-surface-secondary and
border-token-border-hover -> their semantic tokens
- bg-surface, bg-surface-50 -> bg-surface-primary
- bg-surface-primary-hover -> bg-surface-hover
- outline-hidden -> outline-none where focus styling already exists
- drop focus:shadow-outline, border-d-0 and the malformed
ring-offset-ring-offset, which have no meaningful replacement
MemoryArtifacts keeps its default outline instead of gaining outline-none,
since that button has no other focus indicator. MentionItem drops its dead
background rather than adopting one, which would have matched its hover
colour and erased the hover affordance.
Localize the two literal strings the pre-commit lint flagged in the touched
files, reusing the existing com_ui_upload_image and com_ui_more_count keys.
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* fix: SSRF-guard speech (STT/TTS) and OCR outbound requests at connect time
Speech (STT/TTS) and OCR issued outbound HTTP to operator-provided target URLs
with only proxy config attached, so a target that resolves to a private, loopback,
link-local, or cloud-metadata address was reachable by the server. This is the same
class already guarded for the custom models fetch, Actions, avatar, MCP, and the
OpenAI/Anthropic endpoint clients.
Add one helper applySSRFSafeAgentIfDirect(config, url, allowedAddresses) that rejects
non-http(s) or unparseable target URLs, sets maxRedirects to 0 so a redirect cannot
bypass the connect-time check, and attaches createSSRFSafeAgents when no proxy or agent
is already set (proxy precedence preserved). Wire it into the six speech/OCR call sites
and thread each section's allowedAddresses exemption from pr-01.
Scope is private/internal SSRF only. It does not restrict forwarding a credential to a
public host, which is a separate egress-allowlist concern. Absent an allowedAddresses
entry, private targets now fail closed, matching endpoints, actions, and MCP. Document
the new fields in librechat.example.yaml with the operator warning that allowedAddresses
hostnames are trusted before the private-IP check.
* fix: block literal private-IP hosts and thread STT exemptions through generic uploads
applySSRFSafeAgentIfDirect only attached the DNS-lookup agents, but Node
skips the custom lookup for IP-literal hosts, so a literal private IP such
as http://127.0.0.1 connected unchecked. Reject literal private IPs
synchronously in the helper, reusing the same allowedAddresses exemption
logic as the lookup path.
The generic audio-upload path did not forward the section-level
allowedAddresses to sttRequest, so a private STT endpoint permitted via
speech.stt.allowedAddresses failed with ESSRF outside the speech route.
Thread the exemption through files/audio.ts and widen the STTService type.
* fix: derive effective SSRF port for literal IPs and validate OCR target before opening its stream
The literal-IP precheck normalized an empty URL.port to '', so
allowedAddresses exemptions on a default port (127.0.0.1:80, [::1]:443)
never matched. Derive 80/443 from the scheme when the port is omitted.
uploadDocumentToMistral opened the upload file stream before the SSRF
check, so a blocked or malformed target threw with the descriptor still
open. Run the proxy/SSRF setup before fs.createReadStream.
Reword the librechat.example.yaml allowedAddresses guidance to prefer a
private IP literal over a hostname, and reconcile the speech/OCR SSRF
specs to assert the synchronous literal-IP block instead of driving the
connect-time lookup that Node skips for IP literals.
* fix: block literal private IPs before the proxy return, destroy OCR stream on failure, canonicalize IPv6 exemptions
Move the literal-IP check above the proxy/agent early return so a literal
private IP is rejected even when a proxy is configured; document that a
forward proxy must be SSRF-enforcing.
Wrap the Mistral upload post in try/finally and destroy the file stream,
so an async connect-time block does not leak the descriptor.
Canonicalize IP literals in normalizeAddressCandidate through the same URL
serialization targets use, so IPv4-mapped and expanded IPv6 exemptions match.
* ⏱️ fix: Compile admin file-config MIME patterns on a linear-time engine
convertStringsToRegex compiled admin-configured supportedMimeTypes with the native RegExp engine, and checkType runs those patterns against an uploaded file's Content-Type on the server event loop, so a catastrophic-backtracking pattern in fileConfig could ReDoS the whole process on upload.
The MIME-pattern compiler is now swappable. It defaults to native RegExp, which browser builds keep so no engine is added to the client bundle, and the server injects a linear-time engine (RE2JS) at startup. Only test is ever called on these matchers, so the shared type widens to a structural RegexLike with no behavior change for valid patterns. The browser stays on native because a client-side stall would only affect that one tab.
* ⏱️ fix: Wire the linear MIME compiler in the experimental entry point
api/server/experimental.js mounts the same upload routes and calls mergeFileConfig but never set the linear-time compiler, so admin MIME patterns still compiled with native RegExp there. Mirror the setup, and widen the client-side supportedMimeTypes type to the shared RegexLike so the browser typechecks against the same structural matcher.
* 🧹 refactor: Configure the file-config linear engine from a shared helper
Move the RE2 wiring out of both JS server entry points into a single
configureFileConfigRegexEngine helper exported from @librechat/api, so /api stays a thin
caller and the setup no longer has to be kept in sync across index.js and experimental.js.
Also warn loudly when compiling an endpoint's supportedMimeTypes drops every pattern (an
empty allowlist would reject all uploads), and correct the isMimeTypeSupported docstring to
say RegexLike rather than RegExp.
* fix: fail closed when every MIME pattern fails to compile
convertStringsToRegex returned [] when all configured patterns failed to
compile, and filter.ts reads an empty allowlist as no restriction, so a
restrictive config whose patterns all fail allowed every attachment.
Return a single reject-all matcher instead so every consumer fails closed.
* test: cover streamed subagent results end to end
* test: assert real e2e conversation id
* test: harden streamed subagent e2e
* test: stop incompatible subagent fixtures
* chore: update @librechat/agents to version 3.4.0 in package.json and package-lock.json
* 🌊 fix: Preserve Custom Endpoint `streamRate` When `endpoints.all` Is Defined
`buildCustomOptions` assigned `allConfig.streamRate` unconditionally whenever
an `endpoints.all` block existed, overwriting the per-endpoint `streamRate`
with `undefined` for any `all` block that did not define one of its own.
The value is read back later to set `_lc_stream_delay` on the llmConfig, so
stream smoothing was silently disabled for every custom endpoint whenever
`endpoints.all` was present for unrelated reasons (e.g. `activityLabel`).
Guard on `allConfig?.streamRate`, matching the existing OpenAI path.
* 🌊 fix: Preserve Explicit `streamRate: 0` Through the Custom Endpoint Chain
Codex review: truthy guards dropped zero-valued streamRate at both the
endpoints.all override and the llmConfig assignment. With agents 3.4.0
defaulting stream smoothing ON, 0 becomes the explicit disable, so both
sites now use nullish guards; endpoints.all.streamRate: 0 overrides an
endpoint-level rate and an endpoint-level 0 reaches _lc_stream_delay.
Spec extended with both zero cases.
* fix(langfuse): disable central fanout media uploads
* test(langfuse): cover fanout media policy in run config
* chore(deps): bump agents for Langfuse media policy
* chore(deps): bump agents to 3.3.13
* fix(langfuse): gate central fanout media uploads
* feat: add allowedAddresses exemption to speech (STT/TTS) and OCR config schemas
Add the existing allowedAddressesSchema as an optional field on sttSchema,
ttsSchema, and ocrSchema, reusing the schema already attached to endpoints,
mcpSettings, and actions so port scoping and normalization stay identical.
STT and TTS resolve a single provider by counting non-empty section keys, so
exclude the allowedAddresses key from that scan. Without the exclusion a
configured exemption list would be counted as a second provider and trip the
"Multiple providers are set" guard. The field is inert on its own: nothing
reads it for SSRF yet, and provider detection now ignores it.
* fix: preserve allowedAddresses through the OCR config loaders
loadOCRConfig rebuilt the ocr config with only apiKey, baseURL,
mistralModel, and strategy, dropping allowedAddresses before it reached
req.config.ocr. Pass it through in both the AppService loader
(packages/data-schemas/src/app/ocr.ts) and the duplicate at
packages/api/src/files/ocr.ts so the exemption survives config load.
* 🛡️ fix: Run message-filter PII patterns on a linear-time regex engine
The messageFilter.pii middleware compiled admin-configured customPatterns with the native RegExp engine and ran them synchronously against every message on the shared event loop, so a catastrophic-backtracking pattern such as (a+)+$ could stall the entire process (native RegExp takes tens of seconds at roughly 32 characters) and take the instance down for every user.
Compile these patterns with RE2JS, a linear-time RE2 port with no native addon, so catastrophic backtracking is impossible regardless of the pattern rather than something the code tries to detect. Patterns using features RE2 does not support, such as backreferences, fail to compile and are dropped and logged exactly as an invalid pattern already is. The filter only tests for a match, so this is a drop-in engine swap with no behavior change for valid patterns.
* 🛡️ fix: Reject RE2-incompatible messageFilter patterns at config load
The customPatterns regex was validated with native RegExp at config load, but the runtime now compiles it with a linear-time engine (RE2) that does not support backreferences or lookaround. Such a pattern passed validation, then failed to compile and was silently dropped at request time, quietly removing PII protection after upgrade.
Reject backreferences and lookaround during config validation with an explicit message, and document RE2 syntax in the example config instead of "JavaScript-flavor". The runtime engine remains the authoritative boundary and still drops-and-logs anything this load-time check misses.
* 🧹 test: Use direct MessageFilterPiiConfig annotations in the PII specs
The added ReDoS cases satisfy the exported MessageFilterPiiConfig type directly, so the `as unknown as` assertions were unnecessary. Annotate the config objects directly, matching the repo's type-safety guidance.
* 🧹 fix: Reject named backreferences in messageFilter patterns at config load
Extend the config-load check to also reject named backreferences (\k<name>), which are valid JavaScript regex but unsupported by the linear-time runtime engine, so they surface at load rather than being dropped at request time. Together with the existing numeric-backreference and lookaround checks this covers the RE2-incompatible construct set; the runtime engine remains authoritative.
* 🛡️ fix: Preserve Unicode whitespace matching in messageFilter starter patterns
RE2's \s is ASCII-only, so after the engine swap the built-in api-key and Bearer starters no
longer matched a secret separated by non-ASCII whitespace (e.g. a non-breaking space), which
native RegExp did match. Broaden the whitespace classes to [\s\p{Zs}] so those patterns keep
their original coverage, and add a regression test for a non-breaking-space separator.
* 🛡️ fix: Validate messageFilter patterns with the RE2 engine at config load
Replace the syntax blacklist (numeric/named backreferences, lookaround) with authoritative
validation: config load now compiles each custom pattern with the same linear-time engine the
runtime uses, so any RE2-incompatible construct (including control escapes like \cA) is rejected
at load with a clear error instead of being silently dropped at request time.
The validator is swappable and defaults to native RegExp so browser builds add no engine; the
server wires the RE2-backed check at startup via configureMessageFilterRegexValidator in both
entry points.
* 🛡️ fix: Match the full whitespace set in messageFilter starter patterns
RE2's `\s` omits the vertical tab and `\p{Zs}` omits U+2028, U+2029, and
U+FEFF, so a separator built from one of those characters slipped past the
`api-key` and `Bearer` starter patterns and reached the model. Broaden the
starter whitespace class to the full JavaScript whitespace set so those
separators are covered again.
* fix: fail closed when messageFilter.pii compiles to zero patterns
DB and admin config overrides bypass the RE2 schema validation (it only
runs at YAML load), so an override whose only pattern is RE2-incompatible
was dropped at compile time, left zero patterns, and let the request
through. compile() now returns a failClosed flag when a config declared
patterns but every one failed to compile; the middleware returns 400 and
findPiiMatchInMessages returns a distinct misconfigured match that the
OpenAI and Responses controllers surface with an admin-facing message.
* 🛡️ fix: Fail closed when any messageFilter.pii custom pattern drops
compile() previously set failClosed only when every pattern dropped (patterns.length === 0 && dropped > 0). With the default starters present, a single RE2-incompatible custom override incremented dropped but left patterns.length > 0, so the filter silently enforced only the surviving subset and text matching only the dropped rule passed.
failClosed now keys off dropped > 0, so any dropped custom pattern blocks with the misconfigured 400. YAML patterns are RE2-validated at load, so dropped stays 0 for valid configs and only unvalidated DB or admin overrides can trip it. Reframed the two keeps-others-active specs to assert fail-closed and added a default-starters partial-drop regression.
* 🧹 fix: Correct the misconfigured JSDoc and drop redundant casts in the PII specs
The misconfigured flag now means any configured custom pattern failed to compile, not that every pattern failed, so its JSDoc on PiiMatch is updated to match. The partial-drop regressions now use direct MessageFilterPiiConfig annotations instead of as-unknown-as casts, keeping the specs type-checked, consistent with the rest of the suite.
* ⚡ feat: Coalesce Redis Streaming Delta Publications into Windowed Batches
Every streamed delta currently costs two Redis EVALs (durable append + sequence-allocating publish), and the publish round trip is awaited inside the provider-stream consumption loop. Behind STREAM_DELTA_COALESCE_MS (default off), message/reasoning/run-step deltas now buffer for a small window and flush as one CHUNK_BATCH frame: a single INCRBY reserves consecutive per-event sequences and one EVAL publishes the batch, while a matching batched XADD keeps the durable chunk log on the same cadence so the resume frontier's log-vs-counter timing assumptions hold. Subscribers unpack batch frames at ingress into individually sequenced chunks, so the reorder buffer, duplicate drop, and force-flush behavior are unchanged. Durable, steer-receipt, created, and terminal emissions stay on the awaited per-event path and act as ordering barriers that flush any pending window first; terminal claims flush both sides before the status CAS so a warm tail cannot fence against its own completion.
Benchmarked on local Redis (per-scenario RESETSTAT, INFO cpu/commandstats): at 100-200 ev/s a 25ms window cuts EVAL calls 67-82% and Redis engine CPU 52-70%; at the incident's 40 ev/s it halves EVALs while a 20ms window batches nothing (avg 1.0/frame). Producer await stall drops from ~0.9ms/delta to ~0.05ms/delta, matching the previously measured 16-18% USE_REDIS_STREAMS wall-time overhead. Delivery p95 stays under one window (27-28ms at 25ms).
* 📝 docs: Document STREAM_DELTA_COALESCE_MS in .env.example
* 🚧 fix: Drain Coalesced Windows Before Abort and Shutdown Terminal CAS
abortJob and the graceful-shutdown finalizer claim terminal state through their own CAS calls rather than claimTerminalJob, so the pre-CAS coalescer flush did not cover them: a window tail buffered at abort time flushed against the already-aborted status, fenced (-1), and the false receipts retired the healthy runtime and error-closed subscribers before the abort FINAL frame. Extract the flush into flushCoalescedStreamBuffers and call it from all three terminal paths that can interrupt a live emitter (claim, abort, shutdown); the abort call sits ahead of the content snapshot so a chunk-log reconstruction also observes the flushed tail. Regression test aborts mid-window and asserts the tail is delivered with no subscriber error (fails without the fix). Paused-state terminals (approval expiry, pause-persistence timeout) need no flush: the pause's durable barrier already drained the window and nothing streams while paused.
* 🛡️ fix: Keep Fence Retire a Lost-Signal Backstop on Aborted Runtimes
A cross-replica abort claims its terminal CAS on the aborting replica, so the owner cannot drain its coalesced window pre-CAS; the window flush then fences against the aborted status. When the flush timer lands in the CAS-to-FINAL gap, the false receipts retired the owner runtime and detached its SSE handlers, so the abort FINAL published moments later was dropped and attached clients hung until client-side reconnect. The stop signal reaching the owner (~1ms pub/sub) is proof the abort/replacement flow owns terminal delivery and cleanup, so retireRuntimeAfterDurableFence now returns early for runtimes whose abort signal already landed. The forced teardown remains exactly for its original purpose: a fence observed by a NOT-yet-aborted owner, which is the lost-signal case. Regression test pins the race deterministically via the abort beforePublish hook (which runs between the CAS and the FINAL), forcing the owner flush there: without the guard the FINAL is dropped and the subscriber never completes; with it the FINAL delivers cleanly.
* 🧰 fix: Gate, Isolate, and Bound the Coalesced Delta Path
Three hardening fixes for the coalescing prototype. The manager now enables the fire-and-forget delta path only when the configured services actually batch — presence of flushPendingChunks/flushPendingAppends is the advertisement — so a custom transport that only implements emitChunk keeps the awaited per-event ordering contract even with STREAM_DELTA_COALESCE_MS set, and a batching transport is never paired with a per-event store (which would let the durable log trail the sequence counter by a full window). Batch unpack isolates each event: a throwing subscriber callback now degrades exactly like a lost individual frame (that sequence stalls until the reorder force-flush) instead of discarding the batch tail whose sequences were already reserved. And the emitter tracks outstanding coalesced receipts per stream, awaiting one once 256 accumulate: healthy settlement is a window plus a round trip so the count sits in single digits and the await never runs, while a stalled Redis now paces the producer exactly like the flag-off awaited path instead of accumulating batches, resolver closures, and queued commands without bound.
Unit tests cover the capability gate (hint shape and await behavior for capable, incapable, and window-off configurations) and the backpressure threshold; an integration test pins the unpack isolation (fails without it: the batch tail vanishes instead of recovering via force-flush). Benchmark re-run confirms the counter and gate cost nothing measurable: identical EVAL counts and the serial drain still enqueue-bound.
* 🎛️ fix: Make STREAM_DELTA_COALESCE_MS the Single Coalescing Switch
The per-instance coalesceWindowMs constructor overrides could disagree with the environment the manager reads: overrides without the env silently did nothing, and an enabled env with an override of 0 selected the un-awaited manager path while both services published and appended per-event. Nothing in the repo passed these options, so remove them — the transport, the job store, and the manager now read STREAM_DELTA_COALESCE_MS through one resolver, making a half-enabled process unrepresentable rather than documented against. The capability-presence gate remains for services that do not implement batching at all.
* 🧪 fix: Observe Abort Tail Delivery Before Terminal Teardown in Test
The same-replica abort test waited for the coalesced tail only after abortJob returned, but abortJob's finally-block cleanup tears down local subscription state and publish receipts acknowledge Redis execution, not subscriber delivery. Single-node pub/sub delivers sub-millisecond so the frames always won locally; under the CI Redis Cluster they cross the cluster bus and lost the race, timing out the assertion. Await delivery concurrently with the abort instead — the pre-CAS flush publishes the tail several round trips before the teardown, so observing during the call is deterministic in both topologies. Test-only change.
* fix: improve accessibility with semantic HTML and keyboard support
* fix: preserve focus on attachments and stop CSS leaking into label text
Passing `Wrapper` to FileRow as an inline arrow made it a new component type on
every render, so React remounted the whole file row. A keyboard user who tabbed
to an attachment thumbnail lost focus to <body> the moment the upload settled.
Hoist the wrappers to module scope so their identity is stable.
BlinkAnimation rendered a <style> tag into the DOM; stylesheet text becomes part
of the ancestor's textContent and leaks raw CSS into label readouts. Move the
keyframes into the tailwind config, named logo-blink to avoid colliding with the
existing `blink` keyframes in style.css, and honour prefers-reduced-motion.
* fix: make preset row actions reachable by keyboard
The pin, edit and delete buttons on a preset row were hidden with `invisible`,
which sets visibility: hidden and removes them from the tab order entirely. The
`group-focus-within` variant meant to reveal them never fired, because nothing
inside the row ever receives DOM focus during keyboard navigation. Verified in a
browser: arrowing and tabbing through the presets menu skipped the row and the
buttons reported focusable: false, while hovering made them focusable.
Hide them with opacity instead, which keeps them in the tab order, and reveal on
focus as well as hover. At rest they still compute to opacity 0, so there is no
visual change.
* fix: harden a11y heading, Space activation, and preset hit targets
Gate the page heading on a title that matches the routed conversation so
stale Recoil state is not announced during navigation. Ignore key-repeat
on role=button TooltipAnchor activation while still blocking Space scroll.
Disable pointer events on transparent preset actions until hover or focus.
* fix: address a11y review follow-ups and eslint formatting
Use the shared layout test harness for ChatView heading tests, default
role=button TooltipAnchors into the tab order, ship spinner keyframes in
package CSS, and let native preset buttons handle activation once.
* 🚦 feat: Configurable Circuit Breakers for Runaway Streamed Tool Args
* docs: forewarn create_file about the streamed tool-argument limit
The breaker failing a near-limit write should not be the model's first
exposure to the bound. Both create_file variants now state the default
64 KB per-call limit and the incremental pattern (create the first
section, extend with edit_file) in the tool description and the content
parameter description.
* fix: keep skill create_file description under the provider advisory cap
The limit-guidance paragraph pushed the skill-aware description to 1169
chars, past the 1024-char advisory bound where providers may truncate.
The skill variant now carries the guidance only in its content parameter
description, which sits closest to the generated payload and is not at
truncation risk; the shorter code-sandbox variant keeps the full
paragraph.
* 🚦 feat: per-tool streamed-arg limits with a create_file default
Thirty days of production data show create_file is the only tool class
with legitimate near-limit arguments (p99 80.6 KiB; every other tool
p99 under 10 KiB). Rather than loosening the global 64 KiB cap for all
tools, the yaml gains maxToolCallArgBytesByTool (per-tool overrides,
keyed by model-facing tool name, 0 disables that tool's guard) and
LibreChat ships { create_file: 131072 } by default; yaml entries merge
over and can replace it. Pairs with maxToolCallArgBytesByTool support
in the agents SDK and stays inert until the dependency bump.
* test: pass per-tool spec configs as plain Partial literals
The as-TAgentsEndpoint casts fail TS2352 for object-valued fields:
comparability does not grant nested literals the implicit index
signature that plain assignability does, so casts carrying
maxToolCallArgBytesByTool never sufficiently overlap. The mapper
already accepts Partial<TAgentsEndpoint>, so the new cases pass
uncast literals instead.
* chore(deps): bump @librechat/agents to 3.3.12
* 📉 perf: Bound Early Event Buffering for Detached Generations
A generation streaming with no attached subscriber re-entered buffering
mode on every disconnect and retained each emitted event in
earlyEventBuffer for its remaining duration. A single 26-minute detached
run (~58,800 tool-argument deltas) grew the heap past 2 GiB with GC cost
climbing alongside it, while client reconnects always resume from
durable state and discard that local buffer anyway.
- Close the early buffer after the first attachment drains it in Redis
mode; the durable chunk log and pub/sub own recovery from then on,
matching how cross-replica subscribers already attach.
- Enforce hard bounds (5,000 events / 8 MB estimated) in both modes; on
overflow the buffer is discarded and closed, with recovery falling back
to the durable chunk log (Redis) or resume snapshot (in-memory).
- Add a generation_stream_early_buffer_overflows_total counter and
earlyBufferedEvents/Bytes gauges on getRuntimeStats() for visibility.
- Add incident-shaped regression tests and update specs that pinned the
old post-disconnect re-buffering contract.
* fix: redirect post-overflow first attachments to resume recovery
A buffer discarded by the overflow guard left the initial non-resume
SSE attachment with nothing to replay, silently omitting pre-attach
output until the final event. Track the overflow on the runtime and
close such attachments with the existing reconnect signal instead: the
client already re-attaches with resume=true on transport failure and
its sync frame reconstructs the discarded output from durable/snapshot
state. Adds no per-event work; the check is one boolean per attachment.
* fix: enforce buffer bounds when restoring canceled resume captures
Captured emissions restored by a resume canceled before activation
bypassed the early-buffer hard cap, so one oversized restoration could
persist past the limits with no later emission to trip the guard.
Restoration now applies the same overflow-and-close behavior through a
shared helper, and the restore-cap spec fails before this change
(5 events / ~10MB retained) and passes after.
* chore: add Redis management scripts and update package.json for Redis commands
Skill priming fanned out one unbounded batch upload per cold skill,
bursting through codeapi's per-user upload limiter (30 per 5 min).
Failures degraded silently: nothing persisted, every turn re-burned
budget, and handle_skill reported success with no files mounted.
- Bound batch uploads to 3 process-wide slots across both prime paths
- Single-flight primeSkillFiles per (skill id, version)
- Retry a 429 once per Retry-After, capped at 15s, fresh streams
- handle_skill now tells the model when bundled files are unavailable
- Warn on fulfilled-null primes in primeInvokedSkills
* 🪺 fix: Keep Preempt-Abandoned Siblings Nested, Make Message Tree Order-Robust
* 🔗 fix: Sever Cycle Back-Edges So the Returned Message Tree Is Acyclic
* 🧪 fix: Satisfy TFile in buildTree Spec fileMap Fixture
* 🌲 fix: Assert Repaired Trees in convoStructure Specs, Uncharge Self-Parent Edges
* 📌 feat: Identity-Stable Sibling Selection Across Background Tree Churn
* 🎭 test: E2E Coverage for Thread Fold and Sibling Selection Invariants
* 🔑 fix: Treat Newest-Sibling Re-Key as Hydration, Not a New Branch
* 🧭 fix: Rebind Sibling Selection Per Parent, Detect Appends by Membership
Atomic file claiming (#11675) added a unique partial index on
(filename, conversationId, context, tenantId) for execute_code outputs.
Records written before it inserted a new document per regeneration, so
any deployment that re-ran a cell producing the same filename carries
duplicates the index cannot span: Mongo aborts the build with E11000 and
the constraint is silently absent — the claim path still works, but
without its database-level guard against concurrent inserts.
Adds config/migrate-code-file-duplicates.js to normalize that legacy
data, following the existing migration conventions (dry-run default,
--batch-size, runAsSystem for cross-tenant scans).
Renames rather than deletes: each duplicate is a distinct stored object,
typically still referenced by a message attachment, so removing one
would strip a real artifact from a user's history. The newest record
keeps the canonical name — matching the claim path's latest-write-wins
behavior — and older copies gain a ' (n)' suffix that skips names
already taken in the conversation. Attachments embed their own filename,
so rendered history is unchanged.
After a successful apply the script builds the index directly (targeted
createIndex, not syncIndexes) so the operator learns immediately whether
the constraint is now in place.
* 🔒 fix: Single-Flight MCP OAuth Token Refresh per User/Server
Concurrent refresh-token redemptions (tool-call 401, ping, reconnect
retries, expired-token reads) each replayed the same stored refresh
token at the OAuth token endpoint. RFC 9700 reuse detection treats the
replay as theft and revokes the entire grant family, forcing manual
re-consent every access-token expiry.
MCPTokenStorage.forceRefreshTokens is the choke point every refresh
path converges on; it now single-flights redemptions per
(tenantId, userId, serverName) so concurrent callers share one wire
call and receive the same rotated result. The refresh token is re-read
from storage inside the locked execution — never from a caller
snapshot — so a redemption starting after another refresh completed
uses the rotated token instead of replaying the consumed one.
Fixes#14583
* 🧪 test: Isolate Single-Flight Keys per Test via Unique Server Names
* 🔒 fix: Evict Stalled Refresh Slots, Decouple Waiter Aborts from Shared Redemption
Codex review round 1:
- A redemption that never settles no longer wedges the single-flight
slot until process restart: a stale-entry timer evicts the map entry
so later refreshes start fresh, while existing waiters keep their
promise.
- Caller AbortSignals no longer thread into the shared redemption. An
impatient waiter (silent refresh's short timeout) resolves its own
wait with null via a per-waiter race; the shared wire call proceeds
for everyone else, bounded by transport timeouts plus eviction.
* 🔒 fix: Abort Stalled Refreshes Before Slot Release, Hook Cache Invalidation to Redemption
Codex review round 2:
- The stale timer now aborts the wedged execution instead of deleting
its slot; the slot frees only once the execution has settled, and an
abort guard before the token-endpoint call stops a woken pre-wire
stall from replaying a refresh token a successor already rotated.
- New onRefreshSuccess hook runs inside the shared redemption after
rotated tokens persist, so the silent-refresh path's mcp_get_tokens
cache invalidation fires even when the initiating waiter timed out
before the redemption completed.
* 📝 docs: Record Post-Dispatch Abort Recovery Rationale on Stale-Refresh Valve