Commit graph

4029 commits

Author SHA1 Message Date
Danny Avila
c4bb41137d
🔑 fix: Clear Stale Client Registration on invalid_client During OAuth Token Refresh (#12643)
* fix: clear stale client registration on invalid_client during token refresh

When a token refresh fails with `invalid_client`, the stored DCR client
registration is no longer valid on the authorization server. The existing
error handler only checked for `unauthorized_client` and returned null,
leaving the stale client_id cached in the database permanently. Every
subsequent token refresh attempt would fail with the same error.

Now when `invalid_client` is detected during refresh:
1. The stale client registration is deleted from the database
2. A `ReauthenticationRequiredError` is thrown to trigger a fresh OAuth
   flow with new dynamic client registration

Also passes `deleteTokens` from MCPConnectionFactory to getTokens() so
the cleanup has access to the token deletion method.

* fix: address review findings for stale client cleanup on token refresh

- Delete stale refresh token alongside client registration on invalid_client (Finding 1)
- Add tests for all new code paths: cleanup, warning, case-insensitivity, cleanup failure (Finding 2)
- Detect all vendor-specific client rejection patterns (client_id mismatch, client not found, unknown client) with case-insensitive matching (Finding 3)
- Use else-if for mutually exclusive error branches (Finding 4)
- Log warning when deleteTokens is not available on client rejection (Finding 6)
- Fix log message to say "attempting to clear" before async cleanup (Finding 7)
- Extract isClientRejectionMessage to shared utility, refactor MCPConnectionFactory.isClientRejection to use it

* fix: address followup review findings

- Extract isInvalidClientMessage (4 stale-client patterns) from isClientRejectionMessage
  to eliminate pattern duplication between utils.ts and tokens.ts (Finding 1)
- Remove redundant staleIdentifier variable, reuse identifier already in scope (Finding 2)
- Separate await from .then() on Promise.allSettled for readability (Finding 3)
- Add dedicated unit tests for isInvalidClientMessage and isClientRejectionMessage (Finding 4)
- Assert error message content in primary invalid_client test (Finding 5)
- Add JSDoc on deleteTokens in GetTokensParams (Finding 6)

---------

Co-authored-by: Mani Japra <mani@muonspace.com>
2026-04-13 10:12:54 -04:00
Danny Avila
7b48203906
🗂️ feat: Sidebar Icon Toggle & New Chat History Switch (#12642)
* 🗂️ feat: Sidebar Icon Toggle & New Chat History Switch

Add collapse-on-active-click for sidebar icons (VSCode-style) and optionally switch to Chat History panel when creating a new chat.

* fix: Address review findings — extract DEFAULT_PANEL constant, add tests

Export DEFAULT_PANEL from ActivePanelContext and use it in ExpandedPanel
instead of hardcoding 'conversations'. Add ExpandedPanel tests covering
NavIconButton collapse toggle and NewChatButton panel switch behaviors.

* fix: Address review — prop-drill setActive, test disabled setting, strengthen assertions

Pass setActive as a prop to NewChatButton instead of subscribing to
ActivePanelContext, avoiding wasted re-renders on every panel switch.
Add negative-path test for switchToHistory=false. Add positive panel
assertions to inactive-icon click tests. Fix import order.
2026-04-13 09:46:38 -04:00
Caesar Isidro Va-ay
9b9a86d17d
🔀 fix: Resolve Action Tools by Exact Name to Prevent Multi-Action Domain Collision (#12594)
* 🐛 fix: resolve Action tools by exact tool name to prevent multi-action collision

When two OpenAPI Actions on the same Agent share a hostname, the second
action's entry overwrote the first in the encoded-domain Map and one
action's tools silently disappeared from the LLM payload. The buggy
resolution loop also used substring matching, which caused similar
shadowing for any encoded-domain prefix overlap.

This change builds a Map keyed on the full tool name
(`<operationId>_action_<encoded-domain>`) directly, mirroring the exact
lookup pattern that getActionToolDefinitions already uses. Each function
in an action's spec gets its own slot, so two actions sharing a hostname
no longer collide. Both the new and the legacy domain encodings are
registered for each function so agents whose stored tool names predate
the current encoding still resolve.

Applied at all three call sites that had the buggy pattern:

  - processRequiredActions (assistants/threads path)
  - loadAgentTools (agent build path)
  - loadActionToolsForExecution (agent execution path)

Adds three regression tests covering both ordering directions and the
execution path. Tests fail without the fix and pass with it.

* 🐛 fix: Normalize action tool name at lookup + cover assistants path

Follow-up to the multi-action domain collision fix. Addresses PR #12594
review feedback:

**Must-fix #1 — short-hostname lookup mismatch.** The toolToAction map
is keyed on the `_`-collapsed domain, but `agent.tools` and
`currentAction.tool` persist the raw `domainParser(..., true)` output,
which for hostnames ≤ ENCODED_DOMAIN_LENGTH is a `---`-separated string
(e.g. `medium---com`). Exact-match `Map.get()` missed those keys and
silently dropped the tool. Fix: normalize every incoming tool name
through a new `normalizeActionToolName` helper before the lookup in
`loadAgentTools`, `processRequiredActions`, and
`loadActionToolsForExecution`.

**Must-fix #2 — assistants path coverage.** `processRequiredActions`
received the same structural rewrite but had zero tests. Added a
regression test under `multi-action domain collision regression` that
drives two shared-hostname actions through the assistants path and
asserts each tool reaches its own request builder.

**Must-fix #3 — legacy encoding branch coverage.** The
`if (legacyNormalized !== normalizedDomain)` registration was never
exercised by any test. Added a test where `agent.tools` stores the
legacy-format name and asserts it still resolves.

**Should-fix #4 — DRY the registration loop.** Extracted
`registerActionTools({ toolToAction, functionSignatures,
normalizedDomain, legacyNormalized, makeEntry })`. All three call sites
now share the same key-building logic; the key template lives in one
place.

**Should-fix #5 — remove stale optional chaining.** In
`loadActionToolsForExecution`, `functionSignature?.description ?? ''`
became `functionSignature.description` — `sig` is always defined by the
iterator, matching the style of `loadAgentTools`.

**Should-fix #6 — drop unreachable `!requestBuilder` guard.** Entries
in `processRequiredActions` are now pre-built with
`requestBuilder: requestBuilders[sig.name]`, which `openapiToFunction`
always produces alongside the signature, so the guard is dead.

**Should-fix #7 — unwrap `actionSetsData`.** It now holds a bare
`Map` instead of `{ toolToAction }`; the sentinel `!actionSetsData`
check still works because `new Map()` is truthy.

Also added a short-hostname regression test
(`loadAgentTools resolves raw ---separated tool names`) that reproduces
Must-fix #1: it fails against the previous commit (0 create calls) and
passes with the normalization in place.

41 tests, all passing. The 3 new regression tests are under
`multi-action domain collision regression` and cover the assistants
path, the legacy encoding branch, and the short-hostname lookup path.

* 🐛 fix: Tighten registerActionTools key handling and assistants test

Follow-up to d643444 addressing the second review pass on PR #12594.

**ESLint** — Two prettier errors in the spec file (multi-line arrow
function bodies that should fit on one line). Auto-fixed.

**[MINOR] operationId containing `---` → key mismatch.** The lookup
path collapsed every `actionDomainSeparator` sequence in the full tool
name, but the registration path passed `sig.name` through unchanged.
A `---` that survived into an operationId would shift the underscore
boundary at lookup and miss its own key. Fix in `registerActionTools`:
normalize `sig.name` with the same helper so registration and lookup
always agree on the canonical form. `sanitizeOperationId` strips the
characters that produce `---` in practice, so this is theoretical
hardening, not a fix for a known reproducer.

**[MINOR] Same-operationId + same-hostname silent overwrite.** Two
actions sharing both an operationId and a hostname still produced a
silent `Map.set()` overwrite (the new key is identical, so neither the
operationId nor the domain disambiguates). Added a `setKey` helper
inside `registerActionTools` that logs a `[Actions] operationId
collision: ...` warning whenever a key is already present, naming the
overwriting action_id. The silent-overwrite mode from the original bug
cannot reappear under a different disguise without surfacing in the
logs.

**[NIT] processRequiredActions test simulated a runtime crash.**
`mockCreateActionTool` returned a tool with `_call: jest.fn()`, which
resolves to `undefined`. `processRequiredActions` chains
`.then(handleToolOutput).catch(handleToolError)` directly onto that
return, so `undefined.then(...)` threw synchronously and the outer
try/catch funneled the error into `handleToolError`. Creation count
assertions still passed because `createActionTool` runs before the
crash, but the test was silently exercising the failure path. Updated
the global mock to `_call: jest.fn().mockResolvedValue('{"status":"ok"}')`
so the success path runs end-to-end. The assistants regression test
now executes in ~5ms instead of ~90ms, which corroborates that it's
no longer hitting the synchronous throw.

**[NIT] Duplicated rationale comments.** All three call sites carried
multi-line comment blocks restating why we key on the full tool name.
That rationale now lives canonically in `registerActionTools`'s JSDoc;
the inline blocks collapsed to `// See registerActionTools for the
key-shape rationale.` Net -22 lines of comments.

41/41 tests still pass; lint is clean.

* 🐛 fix: Scope tool-name normalization to the encoded-domain suffix

Follow-up to f22228e addressing the Codex P1 on PR #12594.

**Regression.** The previous commit normalized the entire tool name
(`normalizeActionToolName(sig.name)` at registration, full-name
`.replace()` at lookup) to handle operationIds that theoretically
contained `---`. But `openapiToFunction` uses user-supplied
operationIds verbatim and the fallback `sanitizeOperationId` only
strips characters outside `[a-zA-Z0-9_-]`, so specs can legitimately
produce operationIds like `get_foo---bar` and `get_foo_bar` side by
side. Collapsing `---` to `_` across the entire key merged those two
into a single map slot — one silently overwrote the other, and both
tool requests routed to the surviving entry's request builder.

**Fix.** Limit normalization to the encoded-domain portion of the
full tool name, i.e. the substring after the last `actionDelimiter`.
The operationId half is left verbatim, so hyphens-vs-underscores
remain disambiguating. The short-hostname bug (Must-fix #1 from the
original review) is still covered because the `---` → `_` collapse
still happens on the domain suffix where it matters:

```js
const normalizeActionToolName = (toolName) => {
  const delimiterIndex = toolName.lastIndexOf(actionDelimiter);
  if (delimiterIndex === -1) return toolName;
  const prefixEnd = delimiterIndex + actionDelimiter.length;
  const encodedDomain = toolName.slice(prefixEnd);
  return toolName.slice(0, prefixEnd) + encodedDomain.replace(domainSeparatorRegex, '_');
};
```

`registerActionTools` reverts to `sig.name` verbatim — no more
`normalizeActionToolName(sig.name)` ahead of the key build.

**Regression test.** Added
`loadAgentTools distinguishes operationIds that differ only by
---` vs `_`` under `multi-action domain collision regression`. It
loads two actions sharing a hostname whose operationIds are
`get_foo---bar` and `get_foo_bar` respectively, each pointing at a
different path (`/foo-bar`, `/foo_bar`), and asserts that each tool
resolves to its own request builder. Verified the test fails against
f22228e (`hyphenTool` resolves to `/foo_bar` — the sibling's builder)
and passes with this commit.

42/42 tests pass; lint clean.
2026-04-13 09:08:06 -04:00
Airam Hernández Hernández
277fdd2b43
🪪 feat: Optimized Entra ID Group Sync with Auto-Creation (#12606)
* feat: implement optimized Entra group sync with auto-creation

## Changes

### MUST FIX (Critical Issues) - RESOLVED

1. **BUG FIX: Prevent unintended user removal from existing groups**
   - ISSUE: db.syncUserEntraGroups() was called with only missing groups, causing removal
     from all existing Entra groups (full bidirectional sync behavior)
   - SOLUTION: Replaced with db.upsertGroupByExternalId() for each missing group followed
     by single bulkUpdateGroups() to add memberships (race-safe, idempotent)
   - BENEFIT: User memberships correctly maintained for mix of existing + new groups

2. **JSDoc @throws contradiction**
   - ISSUE: JSDoc declared function throws, but implementation catches all errors
   - SOLUTION: Removed @throws from JSDoc - function is best-effort
   - BENEFIT: Prevents unnecessary try/catch in caller code

3. **Missing test for group creation flow**
   - ISSUE: Auto-creating missing Entra groups had no test coverage
   - SOLUTION: Added regression test for mix of existing + new groups scenario
   - BENEFIT: Prevents future regressions on critical path

### SHOULD FIX (Important Improvements) - RESOLVED

4. **E11000 race condition handling**
   - SOLUTION: Upserts are idempotent and race-safe by design
   - BENEFIT: Concurrent logins no longer race each other

5. **Direct Mongoose access instead of db layer**
   - SOLUTION: Added findGroupsByExternalIds() helper to userGroup.ts
   - BENEFIT: Centralized data access, easier to add tenant scoping

6. **Serial DB round-trips on login path**
   - ISSUE: 40+ queries for user with 20 new groups
   - SOLUTION: Promise.all() for parallel upserts + single bulkUpdate
   - BENEFIT: ~10x performance improvement

7. **Graph API 429/503 throttling unhandled**
   - SOLUTION: Retry logic with exponential backoff (1s, 2s delays)
   - BENEFIT: Temporary API issues no longer cause permanent membership loss

8. **Sequential batch requests slow**
   - ISSUE: 200 groups = 10 batches × 200ms = ~2s sequential
   - SOLUTION: Promise.all() with concurrency limit (5 parallel batches)
   - BENEFIT: ~400ms total time

## Minor Fixes

- Removed dead code check
- PII removal: user._id instead of user.email in logs
- ES6 shorthand fixes
- Style consistency (blank lines)
- Projection optimization

## Verification

✅ npm run build - success
✅ npm run test:api - 61/61 passing (+ new regression test)
✅ npm run lint - no errors
✅ All feedback from danny-avila resolved

* docs: better JSDoc for the syncUserEntraGroupMemberships method

---------

Co-authored-by: Airam Hernández Hernández <airam.hernandez@intelequia.com>
2026-04-13 08:50:52 -04:00
Danny Avila
55840286d4
🔬 fix: Scope Web Search Results to Own Turn (#12631)
* 🔬 fix: Scope Web Search Results to Own Turn in WebSearch Component

Fix duplicated search results when multiple web_search tool calls occur
in a single response. Each WebSearch instance now displays only its own
turn's results instead of aggregating all turns.

* test: Add WebSearch turn-scoping tests

Cover the regression-prone turn isolation logic: verify each WebSearch
instance renders only its own turn's sources when sharing a SearchContext,
test the attachments-first priority path and the searchResults fallback,
and assert component states (cancelled, error, streaming, complete).

* fix: Use getAllByText for duplicate sr-only + visible text in tests

WebSearch renders status text in both an sr-only aria-live region and
a visible span, causing getByText to fail with multiple matches.

* refactor: Make ownTurn a string to avoid repeated conversions
2026-04-12 18:38:26 -04:00
Danny Avila
546f006e42
💬 feat: Serialize GitNexus Deploys and Post Completion Comments on PR Commands (#12623)
Three related changes that tighten the GitNexus CI/CD loop.

Serialized deploys
- Previous concurrency group was keyed by head ref with cancel-in-progress,
  which let deploys targeting different refs (e.g. main push + PR command)
  run in parallel. That's a data race: the prune-stale-indexes step
  computes active_names up front, so deploy A rsyncing
  /opt/gitnexus/indexes/LibreChat-pr-12580 can collide with deploy B
  pruning the same folder based on a pre-rsync view of the active set.
- Collapse to a single global group gitnexus-deploy with
  cancel-in-progress: false. All deploys queue behind one another.
  A rsync/docker-compose restart is never killed mid-operation.
  The 20-minute job timeout bounds queue depth.

PR completion feedback
- Add a "index complete" comment step in gitnexus-index.yml that
  fires only when inputs.pr_number is set (i.e. the run came via the
  /gitnexus command). Posts success or failure with a link to the
  run and whether embeddings were generated.
- Add a "deploy complete" comment step in gitnexus-deploy.yml that
  handles both trigger paths: workflow_run from a native PR auto-index
  (PR number recovered from the matrix entry whose runId matches the
  trigger run), and workflow_dispatch from the index workflow's bot-
  fallback path (PR number passed through as a new inputs.pr_number).
- Plumb inputs.pr_number through the bot-fallback dispatch in
  gitnexus-index.yml so the deploy workflow knows where to comment
  for command-triggered runs.
- Only comments on the PR that asked for the index, never broadcasts.

Workflow rename
- Drop the "DigitalOcean" suffix from the deploy workflow's display
  name and filename. The platform is still DO (.do/gitnexus/ still
  holds the compose + caddy config) but the workflow itself is
  platform-agnostic in form and the suffix was visual noise.
- File renamed gitnexus-deploy-do.yml -> gitnexus-deploy.yml.
- Concurrency group and all cross-references updated in lock-step.
- permissions at deploy job level now includes pull-requests: write
  so the completion comment can post.
2026-04-11 18:15:56 -04:00
Danny Avila
8cb5c62fa1
🔗 chore: Dispatch GitNexus Deploy When Index Is Bot-Triggered (#12621)
* fix: dispatch deploy from index when triggered by github-actions[bot]

GitHub Actions suppresses workflow_run events for workflow runs whose
triggering actor is GITHUB_TOKEN (to prevent recursive chains). This
means when gitnexus-pr-command.yml uses `gh api workflow_dispatch`
to kick off gitnexus-index.yml, the downstream gitnexus-deploy-do.yml
workflow_run trigger never fires — the PR command indexes the PR but
the new artifact never makes it onto the droplet.

Add a final step in gitnexus-index.yml that dispatches the deploy
workflow directly via API, but ONLY when the triggering actor is
github-actions[bot]. User-triggered runs (push, pull_request, manual
workflow_dispatch from the UI) continue to rely on workflow_run as
before, so we don't double-deploy.

Requires a new actions:write permission at the workflow level for
this dispatch. contents:read is unchanged.

* fix: resolve main/dev indexes by artifact name, not branch run query

The resolve step was querying listWorkflowRuns filtered by branch=main
and branch=dev, then assuming the latest successful run on each branch
produced the expected gitnexus-index-main / gitnexus-index-dev
artifact. That assumption breaks for /gitnexus index command runs:

The PR command workflow dispatches gitnexus-index.yml with ref=main
(because that's where the workflow file lives) and an input pr_number.
The resulting run has head_branch='main' but uploads its artifact as
gitnexus-index-pr-<N>, not gitnexus-index-main. listWorkflowRuns
returns that run as the "latest success on main", the download step
tries to fetch gitnexus-index-main from it, and the API returns
"no artifact matches any of the names or patterns provided".

Fix: resolve all indexes (main, dev, and PRs) through the same
listArtifactsForRepo path the PR discovery already uses. Looks up
the freshest non-expired artifact by name directly, so the run's
head_branch and event type don't matter — if the artifact exists,
we find it; if not, we warn and move on.

Side benefit: the resolution logic is now shorter and consistent
across branches and PRs.

* fix: paginate open PRs and parallelize artifact lookups

The resolve step was capped at 100 open PRs by github.rest.pulls.list's
per_page ceiling — LibreChat has 200+ open at any given time, so the
tail of the PR queue was silently skipped. On top of that, the inner
artifact lookup loop was serial, so even after pagination the resolve
step would take 40-60 seconds on a busy repo (one API call per PR).

- Replace the single-page rest.pulls.list call with github.paginate,
  which follows the Link header across pages and returns the full
  open-PR set regardless of count.
- Drop the 100-PR truncation warning that was a known-limitation
  notice for exactly this case.
- Batch the per-PR artifact lookups into groups of 10 via Promise.all.
  200 PRs now take ~10 seconds instead of ~60, and the burst stays
  well within the authenticated rate limit (5000/hr).
- Add a final core.info summary showing how many of the open PRs
  actually had a servable index artifact, so the log is useful for
  debugging why a specific PR isn't showing up on the droplet.
2026-04-11 14:02:00 -04:00
Danny Avila
990763cbee
🧠 feat: Enable GitNexus Embeddings for Dev Branch and PR Indexes (#12620)
* feat: auto-enable embeddings for dev and PR indexes too

Previously only main branch pushes got --embeddings; dev and
contributor PRs ran graph-only and relied on BM25 search. Semantic
search on those indexes silently returned empty, which defeats the
whole point of serving them to MCP clients.

New logic: every automatic trigger (push to main/dev, pull_request
from contributors) enables --embeddings. Only workflow_dispatch
still respects the explicit input toggle, so operators can run a
fast graph-only re-index when they don't need fresh vectors.

Cost: adds ~3-5 minutes per index run. Acceptable tradeoff for
having semantic search work across all served branches + open PRs
instead of just main.

* refine: gate PR embeddings on unit-test path relevance

Previous version auto-enabled --embeddings on every contributor PR,
which cost ~3-5 min of extra CI per index even on PRs that couldn't
benefit from semantic code search (docs, config, workflow files,
i18n strings, etc.).

New logic mirrors the backend-review.yml and frontend-review.yml
path filters — if a PR doesn't touch api/, client/, or packages/
it won't trigger unit tests and it doesn't need embeddings. The
check queries the GitHub API for the PR's changed file list via
`gh api repos/.../pulls/<N>/files` (paginated for very large PRs)
and enables embeddings only when at least one path matches.

main/dev pushes still always embed. workflow_dispatch still respects
the explicit input toggle, which also covers the /gitnexus index
[embeddings] PR command.

The contributor gate at the job level is unchanged — non-contributor
PRs are still skipped entirely regardless of paths.

* feat: /gitnexus command works for non-contributor and fork PRs

The command workflow already gated on the commenter's author
association (not the PR author's), so a contributor commenting
/gitnexus index on an outside contributor's PR passes the auth
check. But the downstream index workflow checked out the PR's
raw head SHA, which only exists in the fork for cross-repo PRs —
actions/checkout fetches from the base repo's origin and fails.

Switch the command workflow to dispatch with refs/pull/<N>/head
instead of the SHA. GitHub mirrors every PR's head into the base
repo as this ref regardless of whether the PR is from a fork, so
the checkout always resolves.

End result: a contributor can type `/gitnexus index embeddings`
on any PR — including one opened by a first-time contributor from
a fork — and the index (with embeddings, if requested) is built
and served. The contributor takes responsibility for the trust
boundary by typing the command.

Updated the relevant header/inline comments in both workflows so
the next maintainer understands the refs/pull/<N>/head choice and
the commenter-based gating.

* refine: /gitnexus index defaults to embeddings on

A contributor typing the command has already chosen to spend ~5
minutes of CI on a full re-index; they wouldn't invoke the command
just to get a BM25-only result. Flip the default so the short form
`/gitnexus index` produces an embeddings-enabled index.

Modifier semantics:
  /gitnexus index              -> embeddings ON (new default)
  /gitnexus index embeddings   -> embeddings ON (explicit, no-op alias)
  /gitnexus index fast         -> embeddings OFF (opt-out)
  /gitnexus index graph-only   -> embeddings OFF (alias)
  /gitnexus index no-embeddings-> embeddings OFF (alias)

The previous `embeddings` modifier is preserved as a no-op alias so
anyone who learned the earlier form still gets what they expected.
2026-04-11 13:35:29 -04:00
Danny Avila
39fb93f6c4
🏗️ chore: Set Up Docker Buildx for GitNexus Image GHA Cache Export (#12619)
The first GHCR build on main failed with:
  ERROR: Cache export is not supported for the docker driver.

The default docker driver on ubuntu-latest runners can't export
cache to type=gha. docker/setup-buildx-action@v3 without a driver
argument defaults to docker-container, which supports both
cache-from and cache-to. Gated on the same condition as the build
step so it only runs when an image rebuild is actually needed.
2026-04-11 13:18:17 -04:00
Danny Avila
8eab39bc8f
🌊 feat: Add GitNexus DigitalOcean Pipeline with PR Index Serving (#12612)
* feat: migrate GitNexus deployment from Fly.io to DigitalOcean droplet

Fly.io's 1GB machine was pegged at ~900MB memory with load spiking to
2.7 under even modest query load. Moving to a 2GB+ DO droplet that can
take advantage of existing credits.

Architecture change: indexes no longer baked into the image. Instead,
a long-lived image (built only when .do/gitnexus/ changes) is pulled
from GHCR, and the deploy workflow rsyncs .gitnexus/ data into
/opt/gitnexus/indexes/<name>/ on the droplet and restarts only the
gitnexus container. Caddy stays running so TLS certs don't churn.

- Add .do/gitnexus/Dockerfile (same native-addon + extension patch
  layers as the Fly variant, but no COPY indexes/ step)
- Add .do/gitnexus/docker-compose.yml with gitnexus + caddy services
  on an internal bridge network, 1.8GB memory limit, healthcheck
- Add .do/gitnexus/Caddyfile with automatic HTTPS for the configured
  subdomain and bearer token auth for all routes except /health
- Add .do/gitnexus/entrypoint.sh that registers every index mounted
  at /indexes/<name>/.gitnexus at container start, then runs
  gitnexus serve bound to 0.0.0.0 (internal docker network only)
- Add .do/gitnexus/install-extensions.js for LadybugDB FTS/vector
  extension pre-install (workaround for upstream bug)
- Add .github/workflows/gitnexus-deploy-do.yml that builds the image
  only on Dockerfile/entrypoint changes, pushes to GHCR, rsyncs the
  index artifacts to the droplet, and restarts the gitnexus container
- Remove .fly/gitnexus/ and .github/workflows/gitnexus-deploy.yml —
  Fly app will be destroyed after DO deploy is verified working

Required new secrets: DO_HOST, DO_USER, DO_SSH_KEY. GITNEXUS_DOMAIN
and API_TOKEN live in /opt/gitnexus/.env on the droplet itself.

* refactor: prefix deploy secrets with GITNEXUS_ for namespace isolation

Rename DO_HOST -> GITNEXUS_DO_HOST, DO_USER -> GITNEXUS_DO_USER, and
DO_SSH_KEY -> GITNEXUS_DO_SSH_KEY so the secrets are clearly scoped
to the gitnexus deploy and don't collide with any other DigitalOcean
secrets LibreChat might add later.

* feat: serve PR indexes alongside main/dev and add /gitnexus command

The index workflow was already building and uploading per-PR indexes
(gitnexus-index-pr-<N>) for contributor PRs, but the deploy workflow
only consumed main and dev artifacts. PR indexes were sitting in
storage doing nothing. This wires them all the way through to the
live MCP server, with proper cleanup when PRs close.

Deploy workflow changes:
- Drop the branches filter on workflow_run so PR index completions
  also trigger deploys (PR indexes are already contributor-gated
  upstream in gitnexus-index.yml via author_association)
- Resolve all open PRs via the GitHub API, look up each one's latest
  non-expired gitnexus-index-pr-<N> artifact, and serve whichever
  ones exist. PRs without an index artifact are skipped — we don't
  retroactively index anything.
- Per-ref concurrency group so rapid pushes to the same PR coalesce
  but different refs still deploy in parallel
- After rsyncing active indexes, prune any /opt/gitnexus/indexes/
  folder that isn't in the active set. Safety net for missed PR
  close events.

New workflow: gitnexus-cleanup-pr.yml
- Fires on pull_request closed (merged or not)
- SSHs to the droplet, removes /opt/gitnexus/indexes/LibreChat-pr-<N>,
  restarts the gitnexus container

New workflow: gitnexus-pr-command.yml
- Listens for issue_comment events where body starts with /gitnexus
- Contributor gated via author_association
- Supports: /gitnexus index           — index with defaults
           /gitnexus index embeddings — index with --embeddings
- Dispatches gitnexus-index.yml with the PR number and head SHA,
  reacts to the comment with a rocket emoji

Index workflow changes:
- New dispatch inputs pr_number and pr_ref for command-driven runs
- Checkout step uses inputs.pr_ref when set so the PR's head commit
  is analyzed instead of the default branch
- Artifact naming falls back through pr_number -> pull_request number
  -> ref_name, keeping existing behavior for push/PR events
- Concurrency group switches to pr-<N> when dispatched by the command
  so re-runs on the same PR debounce correctly

* chore: remove Fly variant reference from Dockerfile header

The Fly variant no longer exists in the repo, so the comparison
comment is meaningless. Rewritten as a standalone description of
the image's design.

* review: resolve 15 findings from review audit

Critical
- Drop the unused caddy binary from the gitnexus image. Caddy runs in
  its own container in this architecture; installing it inside the
  gitnexus image added ~40-60MB for no reason and contradicted the
  Dockerfile header comment.

Major
- Replace ssh-keyscan TOFU with a GITNEXUS_DO_KNOWN_HOST secret.
  Both deploy and cleanup workflows now pin the droplet's host key
  from a stored value instead of silently trusting whatever the host
  presents at deploy time. Fails the workflow if the secret is empty
  so no one accidentally regresses to TOFU.
- Gate gitnexus-cleanup-pr.yml to same-repo PRs via
  github.event.pull_request.head.repo.full_name == github.repository.
  Fork PR closes no longer produce failed runs when secrets are
  withheld by GitHub. The deploy workflow's stale-folder prune step
  remains the safety net for any fork-contributor indexes.
- Fail fast in entrypoint.sh when main/dev index registration errors.
  Previously `|| echo WARN` swallowed failures so a broken index
  passed the docker healthcheck and the deploy was marked green
  while queries returned empty. PR indexes stay best-effort
  (a corrupt PR index shouldn't take the whole server down).
- Authenticate the droplet with GHCR on every deploy using
  GITHUB_TOKEN, so private GHCR packages work without documentation
  detours or manual docker login on the host. Bootstrap comments
  explain the flow.
- Switch docker-compose caddy.depends_on from the short-form
  (service_started) to service_healthy so Caddy doesn't route to a
  gitnexus container that's still starting (500ms-60s window after
  recreation).

Minor
- Guard the HEAD~1 diff with `git rev-parse --verify HEAD~1` so the
  first-commit and workflow_run-from-PR cases default to rebuild
  instead of silently skipping a legitimately-changed image.
- Move `packages: write` off the workflow-level permissions and onto
  the build-image job. deploy no longer inherits unnecessary GHCR
  write access.
- Skip the SSH session in cleanup-pr.yml when no gitnexus-index-pr-<N>
  artifact ever existed for the PR. Eliminates ~95% of no-op SSH
  round-trips on a busy repo (docs-only PRs, paths-ignored PRs, etc).
- Reload Caddy in-place after config upload with `caddy reload`,
  falling back to force-recreate on reload failure and `compose up`
  on first-time bootstrap. Picks up Caddyfile or env changes without
  losing TLS certs.
- Replace `sleep 5` post-deploy with a real readiness poll against
  docker's health status. Fails the workflow if gitnexus doesn't
  report healthy within 120s, so a broken startup surfaces in CI
  instead of being silently marked green.
- Warn when listPulls hits the 100-item per_page ceiling so a future
  growth spurt past 100 open PRs doesn't silently drop indexes.

Nit
- Tighten NODE_OPTIONS --max-old-space-size from 1536 to 1280MB,
  giving KuzuDB's C++ heap ~512MB of room under the 1792MB cgroup
  limit instead of ~256MB.
- Rewrite the stale "headroom for Caddy" comment in entrypoint.sh
  (Caddy lives in a separate container now).
- Restore load-bearing comments in install-extensions.js explaining
  the @ladybugdb/core path layout and the throwaway-db cache-priming
  pattern.
- Parameterize the docker-compose image reference as
  ${GITNEXUS_IMAGE:-ghcr.io/danny-avila/librechat-gitnexus:latest}
  so forks or pinned version tags can override via /opt/gitnexus/.env.

Deferred
- Finding 12 (memory headroom) addressed partially via the heap cap
  reduction; full profiling of KuzuDB C++ allocations under query
  load deferred to post-deploy monitoring.

* review: resolve 8 follow-up findings from second review pass

Security
- F1: pipe GHCR token via SSH stdin instead of expanding it into the
  remote command string. Previously `"echo '$GH_TOKEN' | docker login"`
  expanded the token locally before SSH sent it as an argument, so the
  live token was briefly visible in /proc/<pid>/cmdline on the droplet
  to any process running as deploy or root. New form uses
  `printf '%s' "$GH_TOKEN" | ssh ... "docker login --password-stdin"`
  so the token only travels through the encrypted SSH stdin pipe.

Reliability
- F2: add json-file log rotation (50m x 3 files) via a YAML anchor
  shared by both services. Default Docker logging is unbounded and
  would eventually fill the 60GB droplet disk.
- F4: set memswap_limit=1792m to match mem_limit. Without this, Docker
  lets the container silently spill onto host swap when KuzuDB's C++
  heap overruns the 1792m RAM budget, turning sub-second graph queries
  into multi-second ones with no alert. Hard OOM-kill is preferable —
  unless-stopped restarts the container, the deploy health poll
  catches it, the failure is explicit.
- F5: extend the post-deploy health poll from 24 iterations (120s) to
  36 iterations (180s) so it clears Docker's own unhealthy-detection
  ceiling (start_period 60s + retries 3 * interval 30s = 150s). A
  container that legitimately takes 125s to warm up would previously
  fail the deploy at 120s while Docker would still report it as
  "starting".

Operability
- F3: document the `--no-deps` escape hatch in the compose file header
  so an operator can restart Caddy during a gitnexus outage without
  being trapped by the service_healthy dependency (e.g. emergency
  Caddyfile fix while gitnexus is thrashing).
- F7: rewrite the misleading service_healthy comment. The old text
  said it prevents 502s "after a restart", implying continuous
  protection. Clarified that depends_on only governs initial compose
  up ordering — during force-recreates Caddy briefly routes to a
  starting gitnexus and the deploy's health poll is the actual guard.
- F6: add `shopt -s nullglob` before the prune loop so an empty
  /opt/gitnexus/indexes directory is an explicit no-op instead of
  relying on the quirk that `rm -rf "*"` (with literal "*") silently
  succeeds. Next reader won't have to recognize the bash default.
- F8: soft-fail PR artifact downloads when the artifact disappeared
  between resolve and download. Main/dev artifact failures stay fatal
  because a missing main/dev index is a real deploy failure, but a
  deleted PR artifact no longer aborts the whole deploy.

* review: resolve 3 NITs from third review pass

- F1: rewrite the printf '%s' comment. The previous version claimed
  docker login --password-stdin rejects trailing newlines, which is
  inaccurate — docker login strips whitespace. The real reason for
  printf over echo is byte-exact output and portability, and the
  token-in-process-table security rationale is already documented
  in the preceding sentences.

- F2: when a PR artifact download soft-fails, the PR's name stays
  in active_names so the prune step keeps the droplet's existing
  copy instead of wiping it (stale > empty). Make this transition
  visible by spelling it out in the :⚠️: message.

- F3: fencepost fix in the health poll. The previous loop ran 36
  iterations and claimed "180s" in the comment, but the final
  iteration exits without a trailing sleep, so the real ceiling
  was 35 * 5s = 175s. Extended to 37 iterations (36 sleeps * 5s
  = 180s) so the comment matches reality.
2026-04-11 13:04:46 -04:00
Daniel Lew
b1fee80de4
📑 fix: Alias Mimetype text/x-markdown to text/markdown (#12608)
text/x-markdown is a deprecated version of a markdown mimetype, but
we're seeing that sometimes users still send this mimetype. This
change allows these files to be uploaded as text/markdown.
2026-04-11 08:23:04 -04:00
Danny Avila
70c91f8afd
🩹 chore: Correct lbug-adapter Path for GitNexus Vector Extension Patch (#12609)
The published gitnexus npm package compiles pool-adapter.ts to
dist/mcp/core/lbug-adapter.js, not dist/core/lbug/pool-adapter.js
as I guessed in the previous PR. The sed patch failed the Docker
build because the target file didn't exist.

Point the patch at the correct compiled path and add a grep -c
verification after sed to confirm the replacement actually landed.
2026-04-10 13:59:41 -04:00
Danny Avila
711747a5a0
🔎 fix: Install LadybugDB Extensions and Patch Vector Load for GitNexus Search (#12607)
Two upstream GitNexus 1.5.3 bugs combine to break query() in serve mode:

1. pool-adapter.ts calls LOAD EXTENSION fts but never INSTALL fts. The
   CI-produced .gitnexus/ artifact doesn't include the extension cache
   (~/.kuzu/extension/), so LOAD silently fails in the fresh container
   and all BM25/FTS searches return empty.

2. pool-adapter.ts only loads the FTS extension — it never loads the
   vector extension. Every CALL QUERY_VECTOR_INDEX in semanticSearch
   fails, so hybrid search's semantic leg returns empty too.

Combined, query() returns empty even for exact function names because
both the BM25 and semantic legs of RRF merging have zero inputs.
Meanwhile context()/cypher()/route_map() still work because they use
plain Cypher with no extensions.

Workaround:
- Add install-extensions.js that runs INSTALL fts and INSTALL vector
  against a throwaway database during Docker build, populating
  ~/.kuzu/extension/ with the cached extension binaries
- Sed-patch pool-adapter.js at build time to also LOAD EXTENSION vector
  alongside the existing FTS load, wrapped in try/catch
- Update deploy workflow to copy install-extensions.js into the build
  context
2026-04-10 13:41:55 -04:00
Danny Avila
a121ae3dea
🩺 fix: Capture GitNexus Serve Output and Add Startup Health Check (#12599)
* fix: capture gitnexus serve output and add startup health check

gitnexus serve was backgrounded with no log capture, so crash
reasons were invisible in Fly logs. Now pipes output to stdout
and waits up to 30s for the server to be ready before starting
Caddy, with early exit if the process dies.

* fix: install newer libstdc++ for LadybugDB native addon

@ladybugdb/core prebuilt binary requires GLIBCXX_3.4.32 (GCC 13+)
but node:24-slim ships Bookworm's libstdc++6 which only has 3.4.31.
Pull libstdc++6 from Debian Trixie to satisfy the runtime dependency.

* fix: separate build tools from libstdc++ upgrade to avoid conflict

Installing Trixie's libstdc++6 alongside Bookworm's g++ fails because
the Trixie libc6 transitive dep conflicts with Bookworm's libc6-dev.
Split into two RUN steps: compile native addons first, remove g++,
then upgrade libstdc++ from Trixie with no conflicting packages.

* fix: name repo as LibreChat and add dev branch deploy support

- Use REPO_NAME build arg (default: LibreChat) as the WORKDIR so
  gitnexus registers the index with a proper name instead of "repo"
- Deploy workflow now triggers on both main and dev branch index runs
- Dev branch registers as "LibreChat-dev", main as "LibreChat"
- workflow_dispatch gains a branch selector input

* feat: serve both main and dev indexes from one container + auto-embed main

- Dockerfile now copies multiple indexes from indexes/<name>/.gitnexus
  and registers each with gitnexus, so one server handles both branches
- Deploy workflow downloads latest successful main + dev artifacts in
  parallel and bundles them into a single deploy
- list_repos returns LibreChat and LibreChat-dev; queries target either
  via the repo parameter
- Main branch pushes auto-enable --embeddings for semantic search;
  dev and PRs remain graph-only for speed (opt-in via dispatch input)
- Bump index job timeout to 25m to account for embedding generation

* fix: raise Fly machine memory to 1GB + add swap + cap Node heap

The 512MB machine was getting OOM-killed when gitnexus serve's default
--max-old-space-size=8192 over-committed memory during query spikes.

- Bump VM memory 512mb -> 1gb
- Add 512MB swap file to absorb transient spikes
- Cap Node heap at 768MB via NODE_OPTIONS so it stays within the
  machine's real capacity and leaves headroom for Caddy and the OS
2026-04-10 12:35:11 -04:00
Danny Avila
4f133f8955
✨ v0.8.5-rc1 (#12569) 2026-04-09 20:06:31 -04:00
Danny Avila
f128587bb7
📦 chore: bump axios, @librechat/agents (#12598)
* chore: bump @librechat/agents to v3.1.64

* chore: update axios to version 1.15.0 across multiple packages
2026-04-09 19:48:21 -04:00
UnicronBE
46b529a86a
📩 fix: Restore Primary Action Button Visibility in Light Mode (#12591)
The default <Button> variant (bg-primary / text-primary-foreground)
renders invisible in light mode on the Agent Marketplace 'Start Chat'
button and the Grant Access dialog 'Save Changes' button, making these
primary actions undiscoverable without hovering.

Switch these three affirmative-action buttons to variant='submit',
which uses the hardcoded 'bg-surface-submit text-white' combination
already defined in the Button component specifically to avoid the
contrast issues of the default variant (see the comment above the
'submit' variant in packages/client/src/components/Button.tsx).

No visual change in dark mode; in light mode the buttons now render
as the green submit color with white text, matching the semantic
intent of the action.

Co-authored-by: Timothy Look <timothy.look@pmv.eu>
2026-04-09 18:45:54 -04:00
Marco Beretta
1a83f36cda
📌 feat: Add Pin Support for Model Specs (#11219)
* feat: Enhance favorites functionality to support model specs

* refactor(FavoritesList): reorder imports based on lenght

* feat: improve favorite modelSpec controller; refactor: useIsActiveItem hook

* refactor: consolidate Favorite type, harden controller, add tests

- Add canonical TUserFavorite type in data-provider, replace three
  duplicate definitions (data-service, data-schemas, store/favorites)
- Consolidate FavoritesController spec validation into single block,
  add return on 500 paths, add maxlength to mongoose sub-schema
- Fix import order in FavoritesList.tsx, merge namespace type imports
  in FavoriteItem.tsx and FavoritesList.tsx
- Add focus-visible:ring-inset on pin buttons to prevent ring clipping
- Add explicit return type and JSDoc on useIsActiveItem hook
- Use props.type for narrowing consistency in FavoriteItem getTypeLabel
- Add 22 backend tests for FavoritesController (spec validation,
  typeCount exclusivity, persistence, GET path)
- Add 40 frontend tests: useFavorites spec methods, useIsActiveItem
  observer lifecycle, ModelSpecItem pin button, FavoriteItem all three
  type branches, FavoritesList spec rendering

* fix: address PR review findings for pin model specs

- Harden backend validation to reject partial cross-type fields
  (e.g. spec+endpoint, agentId+model without endpoint)
- Add stale-spec auto-cleanup in FavoritesList mirroring agent cleanup
- Add type="button" to pin buttons in ModelSpecItem/EndpointModelItem
- Fix import order violations in EndpointModelItem and ModelSpecItem
- Remove hollow test, dead key prop, inline trivial helpers
- Fix misleading test description, add onSelectSpec to test mock
- Add return to controller success responses for consistency
- Add 6 backend tests for partial cross-type field validation

* fix: guard stale-spec cleanup against unloaded startupConfig

Prevents race condition where spec favorites are incorrectly deleted
on cold start before startupConfig has loaded. Mirrors the existing
agentsMap === undefined guard pattern used for stale agent cleanup.

Also adds tests for stale-spec cleanup persistence and fixes namespace
import pattern in FavoritesList.spec.tsx.

* fix: replace nested ternaries with if/else in FavoriteItem

Resolves ESLint no-nested-ternary warnings for name and typeLabel
derivations.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-09 18:37:25 -04:00
Danny Avila
daa8f0ea6b
📂 fix: Respect supportedMimeTypes Config in File Picker Accept Filter (#12596)
* fix: respect supportedMimeTypes config in file picker accept filter

The browser file picker's accept attribute was hardcoded by provider
identity, ignoring the endpoint's supportedMimeTypes from fileConfig.
Users who configured permissive MIME types (e.g., '.*') still saw a
restrictive filter in the upload dialog.

Add isPermissiveMimeConfig utility that detects wildcard patterns in
the endpoint's supportedMimeTypes. When permissive, the file picker
accept attribute is set to empty (unrestricted). Non-permissive
configs retain the existing provider-based defaults.

Closes #12589

* fix: address review findings for isPermissiveMimeConfig

- Use non-standard MIME namespace probe (x-librechat/x-probe) so
  category-wildcard patterns like ^application\/.*$ no longer
  false-positive as permissive
- Add single-line JSDoc to isPermissiveMimeConfig
- Use !== undefined instead of != null (fileType is never null)
- Add endpointFileConfig to dropdownItems useMemo deps to prevent
  stale closure when config changes without endpoint change
- Add tests for broad application and multi-category patterns

* fix: wrap handleUploadClick in useCallback to satisfy exhaustive-deps

handleUploadClick is captured inside the dropdownItems useMemo but was
not in its dependency array. Wrap it in useCallback with
endpointFileConfig.supportedMimeTypes as the sole dependency, then
reference the stable callback in the useMemo deps.
2026-04-09 17:43:53 -04:00
Danny Avila
81275ff0e0
⏱️ refactor: User Job Tracking TTL and Proactive Cleanup to Redis Job Store (#12595)
* refactor: Add user job tracking TTL to RedisJobStore

- Introduced a new TTL for per-user job tracking sets, set to 24 hours, to enhance job management.
- Updated RedisJobStoreOptions interface to include userJobsSetTtl for configuration.
- Modified job creation and deletion methods to manage user job sets effectively, ensuring proper expiration and cleanup.
- Enhanced comments for clarity on the new TTL functionality and its implications for user job tracking.

* fix: Address review findings for user job tracking TTL

- Remove redundant `del(userJobsKey)` in `getActiveJobIdsByUser` that
  raced with concurrent `createJob` on other replicas (Redis auto-deletes
  empty Sets after SREM)
- Guard `userJobsSetTtl: 0` from silently destroying tracking sets
  (`EXPIRE key 0` deletes the key on Redis 7.0+)
- Extract `deleteJobInternal` so `cleanup()` reuses the already-fetched
  userId instead of issuing a redundant HGETALL per stale job
- Add integration tests for TTL behavior, proactive SREM, configurable
  userJobsSetTtl, and TTL refresh on repeated createJob

* fix: Address follow-up review findings for RedisJobStore

- Use deleteJobInternal in cleanup() terminal-but-in-running-set path
  to ensure userJobsKey SREM is not skipped
- Clear local caches in deleteJob before the fallible getJob call so
  they are cleaned even on transient Redis errors
- Add proactive SREM tests for aborted and error terminal statuses
- Add test for tenant-qualified user tracking key format

* fix: Preserve completedTtl for non-running jobs in cleanup()

The cleanup() terminal-status branch should only remove tracking set
membership, not delete the job hash. deleteJobInternal bypasses the
completedTtl window that updateJob already applied, causing clients
polling for final status to lose the job data early.
2026-04-09 17:42:54 -04:00
Danny Avila
cc8ce15c38
📂 fix: Enable Hidden File Upload for GitNexus Index Artifact (#12597)
upload-artifact@v4 defaults include-hidden-files to false,
which silently skips the .gitnexus/ directory (dotfile).
Scoped to the .gitnexus/ path so only index files are affected.
2026-04-09 17:02:51 -04:00
github-actions[bot]
0d97f7354a
🌍 i18n: Update translation.json with latest translations (#12588)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-09 15:26:37 -04:00
Danny Avila
7ef03391b5
📦 chore: bump nodemailer to v8.0.5 (#12587) 2026-04-09 09:59:57 -04:00
Danny Avila
452af50eff
🧮 fix: Atomize Redis Event Sequence Counters for Multi-Replica Deployments (#12578)
* fix: atomize Redis event sequence counters for multi-replica deployments

Replace in-memory sequenceCounters Map with shared atomic Redis counter
(INCR via Lua script) so all replicas share an authoritative sequence
source. Make syncReorderBuffer async to read current sequence from Redis
instead of defaulting to 0 on non-publishing replicas.

Closes #12575

* fix: harden emitChunk error handling and syncReorderBuffer race safety

Wrap getNextSequence inside emitChunk's try/catch so a transient Redis
failure is logged-and-swallowed, preserving the non-fatal chunk emission
contract. In syncReorderBuffer, replace pending.clear() with selective
discard of stale entries (seq < currentSeq) followed by
flushPendingMessages, preventing loss of chunks that arrived during the
async GET window.

* fix: address review findings — harden error paths, validate types, DRY tests

- Wrap syncReorderBuffer in try/catch in GenerationJobManager.subscribe()
  so a Redis GET failure degrades gracefully instead of crashing SSE reconnect.
- Validate eval return type in getNextSequence; throw on unexpected type
  instead of silently computing NaN/-1 and dropping all messages.
- Add NaN guard to parseInt in syncReorderBuffer for corrupted Redis values.
- Consolidate two streams loops in destroy() into a single pass.
- Extract createMockPublisher to shared helpers/publisher.ts (DRY).
- Add tests for eval failure and unexpected eval return type in emitChunk.
- Document performance tradeoff (2x RTT per emit) and TTL rationale.

* test: add cross-replica integration tests for sequence desync fix (#12575)

Three real-Redis tests that reproduce the exact multi-replica failure:

- Late subscriber on a different replica syncs to the shared counter
  and receives chunks immediately (no 500ms force-flush).
- Multiple subscribe/unsubscribe cycles across replicas maintain
  correct sequence alignment on every reconnect.
- Shared counter key is cleaned up when the stream is destroyed.

* fix: close syncReorderBuffer race, stop destroy() from nuking shared keys

- Replace selective prune with Math.min(currentSeq, minPendingSeq) so
  chunks that arrive in pending during the async GET window are preserved
  instead of incorrectly pruned. Since unsubscribe() already clears
  pending, any entries at sync time are live messages from the current
  subscription and must not be discarded.
- Remove DEL from destroy() — the sequence key is shared across replicas
  and a shutting-down instance must not nuke it for active publishers.
  Keys expire naturally via their 1-hour TTL; cleanup() handles
  single-stream lifecycle teardown.
- Add race-condition test: pauses GET, injects a message into pending
  during the window, resolves GET, asserts the chunk is delivered.
- Add emitDone/emitError eval failure tests to cover the asymmetric
  error contract (chunk swallows, done/error propagates).
- Add explicit MockPublisher return type to helpers/publisher.ts.

* fix: context-aware syncReorderBuffer to prevent duplicate delivery, silence test logs

The Math.min(currentSeq, minPending) fix from the prior commit correctly
handles the cross-replica race (chunk arriving during async GET), but
causes duplicate delivery in same-replica mode: onAbort subscribes to
the Redis channel during createJob, so chunks published before
subscribe() may arrive via pub/sub AND earlyEventBuffer. The Math.min
logic then flushes the pub/sub copy as a "live" message.

Fix: add a clearPending parameter to syncReorderBuffer. The manager
passes true when earlyEventBuffer was replayed (same-replica: pending
entries are duplicates → clear them) and false/undefined when it was
not (cross-replica: pending entries are live → preserve via Math.min).

- Silence winston logger in stream test files via logger.silent = true,
  following the existing pattern in data-schemas/prompt.spec.ts.
- Remove sequence key DEL from destroy() — shared across replicas,
  a shutting-down instance must not nuke the counter for active
  publishers. Keys expire via TTL; cleanup() handles stream teardown.

* fix: share publisher client in cross-replica tests for Redis Cluster compat

The cross-replica tests created publisherB via (ioredisClient as Redis)
.duplicate(), which produces a plain Redis connection to a single node.
In Redis Cluster, GET/EVAL on this client can't follow MOVED redirects,
so syncReorderBuffer reads null → nextSeq=0 → chunks are buffered.

Fix: share ioredisClient as the publisher for both replicas. It's the
correct Cluster-aware client and handles slot routing automatically.
Only subscriber connections need to be separate (pub/sub requirement).

* fix: increase cross-replica test timeouts and use polling for CI cluster

Replace fixed 300ms delivery waits with polling (50ms intervals, 2s max)
to handle Redis Cluster's cross-node pub/sub broadcast latency in CI.
Increase subscription activation wait from 100ms to 500ms to match the
pattern used by existing same-instance tests.

* chore: silence winston logs in remaining stream test files

Add logger.silent = true to GenerationJobManager, RedisJobStore, and
collectedUsage test files, matching the pattern already applied to
RedisEventTransport and reconnect-reorder-desync tests.

* fix: remove flaky cluster pub/sub tests, fix log suppression for resetModules

Remove two cross-replica transport-level integration tests that are
inherently non-deterministic in Redis Cluster: cluster pub/sub fan-out
is async across nodes, so pre-subscribe PUBLISHes can arrive at the
subscriber's node after SUBSCRIBE takes effect, causing random +/- 1
message counts. The core logic is already covered by deterministic unit
tests (mock publisher) and GenerationJobManager integration tests
(end-to-end with earlyEventBuffer). The cleanup test is retained.

Switch log suppression from logger.silent (which doesn't survive
jest.resetModules) to jest.spyOn(console, 'log').mockImplementation()
for files that use resetModules (GenerationJobManager, RedisJobStore,
collectedUsage). The logger.silent approach remains for files that
don't use resetModules (RedisEventTransport, reconnect-reorder-desync).

* fix: replace Lua EVAL+TTL with plain INCR, preserve live chunks during same-replica sync

P1: syncReorderBuffer with clearPending=true was unconditionally clearing
pending, dropping NEW chunks (seq >= currentSeq) from ongoing generation
that arrived via pub/sub during the async GET. Fix: selectively prune
only entries with seq < currentSeq (duplicates of earlyEventBuffer) and
flush remaining live entries.

P2: The 1-hour TTL on sequence keys could expire mid-stream during long
quiet periods (e.g., slow tool calls), restarting the counter from zero
and causing subscribers to silently drop all subsequent messages. Fix:
remove the Lua EVAL+EXPIRE script entirely and use plain Redis INCR —
no TTL. Keys are cleaned up explicitly by cleanup()/resetSequence()
when streams end. Orphaned keys from crashed processes are a few bytes
each, negligible compared to the production risk of TTL expiry.

- Update mock publisher helper: eval → incr
- Remove "unexpected eval type" tests (not applicable to incr)
- Update remaining eval error tests to reference incr

* fix: re-arm flush timeout after syncReorderBuffer when gaps remain

syncReorderBuffer clears flushTimeout before processing, but if pending
still has gaps after flushPendingMessages, no timeout is re-armed.
Without new messages arriving to trigger scheduleFlushTimeout, those
buffered entries sit indefinitely — stalling the stream after reconnect.

* chore: address final review — fix stale docs, rename clearPending, tighten tests

Fix all 10 findings from final review pass:

F1: Fix stale TTL comment in destroy() — no TTL exists after EVAL removal
F2: Fix stale EVAL reference in emitChunk JSDoc → INCR + PUBLISH
F3: Fix syncReorderBuffer JSDoc — describes old broken behavior, not
    the current selective prune
F4: Rename clearPending → pruneStaleEntries for clarity at call sites
F5: Add publish-not-called assertion to INCR failure test
F6: Replace Math.min(...spread) with explicit loop to avoid heap alloc
F7: Remove resetSequence from IEventTransport interface (no external
    callers; implementation detail only)
F8: Consolidate cleanup/resetSequence overlap — cleanup() owns the DEL,
    resetReorderBuffer() (now private) handles buffer state only
F9: Fix import order in reconnect test (value before type imports)
F10: Export MockPublisher interface from helpers/publisher.ts

* chore: fix stale resetSequence references and hadBufferReplay JSDoc

Two comments referenced resetSequence() which was removed in the F7/F8
refactor (now private resetReorderBuffer(), which doesn't DEL the key).
Only cleanup() deletes the Redis key. Also update hadBufferReplay JSDoc
to say "prune stale entries" instead of the pre-refactor "clear pending".

* chore: grammar

* fix: use earlyReplayCount as prune cutoff instead of Redis counter

The boolean pruneStaleEntries flag used currentSeq (from Redis GET) as
the prune threshold, but INCR can advance the counter past a live
chunk's seq during the GET window. Example: earlyEventBuffer held seqs
0-4, generation emits seq 5 during GET, INCR advances counter to 6,
GET returns 6, prune condition 5 < 6 deletes the live chunk.

Fix: pass the earlyEventBuffer replay count (5) as the prune cutoff.
Entries with seq < earlyReplayCount are true duplicates; entries at or
above are live regardless of what the Redis counter reads. After
pruning, the unified Math.min(currentSeq, minPending) logic handles
both same-replica and cross-replica paths correctly.

Add a targeted test that exercises this exact race: pauses GET, emits
seq 5 during the window, resolves GET with counter=6, asserts seq 5
is preserved (would have been dropped with the old boolean approach).

* fix: pass earlyReplayCount for skipBufferReplay path to prune pub/sub duplicates

When skipBufferReplay is true (resume scenario), earlyEventBuffer events
are delivered via the resume sync payload, not replayed directly. But
earlyReplayCount was left at 0, so syncReorderBuffer treated all pending
entries as live — meaning delayed pub/sub copies of those buffered
events could be delivered again, duplicating content.

Fix: capture earlyEventBuffer.length before the skip/replay branch so
the count is always passed to syncReorderBuffer regardless of delivery
method. Seqs 0..earlyReplayCount-1 are pruned as duplicates whether
they were replayed or delivered via sync payload.

* fix: keep nextSeq monotonic in syncReorderBuffer after async GET

handleOrderedChunk can deliver in-order messages and advance nextSeq
during the async GET window. If those messages leave pending empty,
the unconditional nextSeq = currentSeq could regress nextSeq below
its already-advanced value, reopening a delivered gap and causing
subsequent messages to be buffered until force-flush.

Fix: wrap both nextSeq assignments with Math.max(nextSeq, ...) so
syncReorderBuffer never moves the delivery frontier backward.

* fix: cap nextSeq at earlyReplayCount, add 24h safety TTL, add post-GET race test

Finding 1 (CRITICAL): When earlyReplayCount > 0 and pending is empty,
nextSeq was set to currentSeq — but INCR can advance the counter past
a live chunk whose pub/sub hasn't arrived yet. Cap at earlyReplayCount
instead (what was actually delivered), so in-flight chunks are not
skipped. Adds test for the message-arrives-AFTER-GET-resolves scenario.

Finding 3: Add a 24-hour safety-net TTL set once on first INCR only
(val === 1), never refreshed. This caps orphan lifetime from crashed
processes without risking mid-stream counter resets.

Finding 6: Replace setTimeout(100) in cleanup test with polling loop.

Finding 9: Fix variadic DEL mock + add expire mock for the new TTL.

Revert P2 fix (earlyReplayCount for skipBufferReplay path) — when
skipBufferReplay is true, the resume sync payload delivers everything
up to currentSeq, so syncReorderBuffer should trust the Redis counter
as the frontier, not the buffer length.

* chore: log expire failures consistently with other fire-and-forget errors
2026-04-09 09:57:54 -04:00
Danny Avila
632ffbcb87
🧬 fix: Merge Custom Endpoints by Name Instead of Replacing Entire Array (#12586)
* fix: Merge Custom Endpoints by Name Instead of Replacing Entire Array

The DB base config's `endpoints.custom` array was wholesale-replacing
the YAML-derived array, causing YAML endpoint additions to be silently
lost after the first admin panel save. Add path-aware array merging
to `deepMerge` so keyed arrays (matched by `name`) are merged item-by-item
instead of replaced.

* fix: Harden mergeArrayByKey — deduplicate, sanitize, and prevent mutation

- Remove redundant sourceOrder array; iterate Map.keys() instead to
  prevent duplicate entries when source contains repeated names.
- Sanitize override-only appended items through deepMerge to enforce
  UNSAFE_KEYS prototype-pollution protection on all code paths.
- Shallow-copy unmatched base items to prevent mutation leak-back.
- Add post-OVERRIDE_KEY_MAP remapping note to ARRAY_MERGE_KEYS JSDoc.
- Add tests: duplicate source names, base mutation safety, multi-priority
  sequential merging of the same custom endpoint.

* fix: Add inline comments and test for keyless source items

- Document keyless item drop behavior in mergeArrayByKey with inline
  comment and matching test case.
- Add last-write-wins comment to deduplication test assertion.
- Clarify path semantics in mergeArrayByKey target-iteration comment.

* fix: Relocate keyless-item comment and test target-side preserve

- Move keyless-item comment to the if-guard where the skip happens and
  clarify that target-side keyless items are preserved, not dropped.
- Add test verifying base items without a name field are kept in output.
2026-04-09 09:38:26 -04:00
github-actions[bot]
72cdeb0437
🌍 i18n: Update translation.json with latest translations (#12583)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-09 07:45:13 -04:00
Marco Beretta
b8158a613e
🧑‍🎨 refactor: Prompts/Sidebar styles for improved UI Consistency (#12426)
* style(sidebar): polish button styles, icon sizes, and nav separator

- Match new chat button style to sidebar toggle

- Unify icon sizes and stroke weights across sidebar icons

- Add separator between new chat and nav links

feat(prompts): inline prompt editing with route-based navigation

- Show prompts dashboard inline when editing/creating from chat

- Use route-based navigation instead of state-driven view switching

- Strip inline view to form-only without duplicate sidebar

style(prompts): unify borders, backgrounds, and admin controls

- Unify borders to border-medium and remove opaque backgrounds

- Add admin/advanced controls to sidebar

- Match chat background, move advanced toggle to versions panel

refactor(prompts): consolidate list actions into dropdown with compact layout

- Unify prompt list to single ChatGroupItem with all actions

- Consolidate actions into dropdown menu

- Distinct dropdown icons with status icon tooltips

- Remove rename action, use Trash icon matching convo items

refactor(prompts): inline-edit title with click-to-edit and save indicator

- Click title text to enter edit mode with pencil icon on hover

- Clean title input with inline save status indicator

- Uniform h-9 header height, smaller title, icon-only save status

- Remove separate edit icon button and confirm/cancel controls

style(prompts): sticky click-to-edit pill with surface-primary background

fix: preserve AuthContext identity across Vite HMR updates

createContext() was re-executed on every HMR module replacement, creating
a new context object that disconnected the existing provider from its
consumers. useAuthContext() would throw because useContext(newContext)
returned undefined while the provider was still on the old context.

Stash the context object in import.meta.hot.data so it survives HMR
re-execution. Dead-code-eliminated in production builds.

feat: add advanced prompts editor toggle to Chat settings

- Add AdvancedPrompts switch in Settings > Chat that maps the
  promptsEditorMode enum (simple/advanced) to a boolean toggle
- Preserve existing behavior: switching to simple forces alwaysMakeProd
- Add focus styling to PromptName input (border-medium on focus,
  outline on focus-visible, no ring)
- Add localization keys: com_nav_advanced_prompts,
  com_nav_advanced_prompts_desc
- Various Prompts UI refinements

style(sidebar): update nav icons and reorder links for clarity

- Prompts: MessageSquareQuote → NotebookPen (distinct from chat bubble)
- Agent Builder: Blocks → Bot (no longer identical to Assistants)
- Memories: Database → BrainCircuit (brain + AI circuit)
- Parameters: Settings2 → SlidersHorizontal (tuning-specific)
- Reorder: Chat → Prompts → Agents → Assistants → Memories →
  Bookmarks → Files → Parameters → MCP (frequency and grouping)

refactor(prompts): remove AdvancedSwitch component

The simple/advanced toggle is now in Settings > Chat, making
the standalone AdvancedSwitch redundant. Remove the component,
its barrel exports, and all render sites (PromptForm,
DashBreadcrumb, PromptsView).

style(prompts): tighten panel layout and spacing to match sidebar conventions

- Remove top margin from form wrapper, add mt-2 to header row
- Reduce header gaps and remove flex-wrap for tighter alignment
- Shrink prompt title from text-lg/h-9 to text-base/h-8
- Add pl-2 inner padding to PromptName input and static button
- Compact versions panel and deploy button container padding
- Reduce editor header and content spacing
- Compact mobile versions panel header

fix(prompts): rebuild mobile versions panel with proper transitions

- Add background (bg-surface-primary-alt) and border-l so content
  doesn't bleed through
- Replace conditional render with CSS opacity/translate so both
  overlay fade-out and panel slide-out animate on close
- Use inert attribute to disable focus when panel is hidden
- Replace raw × character with X icon from lucide-react
- Use size-icon button variant for close instead of text sm
- Use Tailwind classes (w-80, translate-x-full) instead of inline
  style object for width and transform
- Remove unused sidePanelWidth constant
- Overlay stays in DOM with pointer-events-none when hidden

chore: remove stale tooling artifacts

Remove .bg-shell/manifest.json and .gsd symlink that were
accidentally committed — these are local dev tooling files.

fix: resolve review issues in PromptName and ChatGroupItem

- Fix Escape/onBlur race in PromptName via cancelledRef guard
- Clear existing timer before creating new one to prevent status flicker
- Guard delete mutation against empty group id
- Attach menuButtonRef to MenuButton for proper focus restoration after preview dialog closes

fix: address review findings across prompts UI and sidebar

PromptName:
- Fix Enter key double-save: set skipBlurRef before inline save to prevent
  onBlur from re-firing saveName on input unmount
- Add isError prop to distinguish mutation failure from success, preventing
  false 'saved' checkmark on error
- Remove dead cn ternary that always evaluated to opacity-100
- Remove unused cn import

PromptForm:
- Pass isError from updateGroupMutation to PromptName
- Remove dead default arg (= {}) on function component

ChatGroupItem:
- Stabilize handleDelete via ref pattern to prevent memo-defeating
  recreation on every render (deleteGroup is a new ref each render)
- Disable delete button during mutation to prevent double-fire

ExpandedPanel:
- Restore <a> element for NewChatButton to fix middle-click (open in
  new tab) regression caused by anchor-to-button migration

DashGroupItem:
- Delete dead file (no longer exported or imported anywhere)

fix: address review findings across prompts UI and sidebar

- PromptName: render error state (red X) when save fails, extract
  shared commitName helper to deduplicate blur/Enter save paths
- ChatGroupItem: navigate away after deleting the active prompt in
  sidebar view; use context-aware route prefix (/prompts vs /d/prompts)
  for edit and card-click navigation
- InlinePromptsView: redirect to /c/new when user lacks prompts access
  instead of rendering a blank screen
- Remove dead ManagePrompts component and its barrel exports (no
  remaining consumers after GroupSidePanel cleanup)

fix: remove duplicate showThinking Recoil atom key

The 'showThinking' key was defined in both store/settings.ts (Recoil)
and store/showThinking.ts (Jotai). Only the Jotai atom is consumed;
the stale Recoil duplicate causes 'A key option with a unique string
value must be provided' at startup.

refactor: remove /d/prompts dashboard route and dead code

The prompts UI now lives at /prompts/* inline under the chat layout.
The old /d/prompts/* dashboard route, its layout (PromptsView), and
its breadcrumb (DashBreadcrumb) are no longer used.

- Delete PromptsView and DashBreadcrumb (zero consumers)
- Delete BackToChat button (zero consumers)
- Replace /d/prompts/* child routes with a redirect to /prompts/new
- Add /prompts index route that redirects to /prompts/new
- Update all /d/prompts navigation to /prompts:
  - ChatGroupItem: always use /prompts prefix
  - NoPromptGroup: navigate to /prompts
  - CreatePromptForm: fallback navigate to /prompts/:id
  - CreatePromptButton: simplified to /prompts/new (no dual-path)
- Strip GroupSidePanel of dashboard-only breadcrumb nav, recoil
  state clearing, and useDashboardContext dependency

refactor: remove dead Dashboard code and unused translation keys

- Delete DashboardContext provider (zero remaining consumers)
- Simplify DashboardRoute layout to a plain Outlet
- Remove commented-out file/vector-store route blocks
- Change catch-all redirect from /d/files to /c/new
- Remove 7 unused translation keys (com_nav_toggle_sidebar,
  com_ui_back_to_chat, com_ui_dashboard, com_ui_delete_prompt_name,
  com_ui_global_group, com_ui_prompt_renamed, com_ui_rename_prompt,
  com_ui_rename_prompt_name)

fix: add Babel plugin to transform import.meta.hot for Jest

`babel-plugin-transform-import-meta` handles standard properties (url,
filename, dirname, resolve) but not Vite's `hot` property. Jest runs in
CommonJS where `import.meta` is unavailable, so `import.meta.hot` in
AuthContext.tsx (added for HMR preservation) causes a SyntaxError that
breaks 27 test suites.

Add a small Babel plugin that replaces `import.meta.hot` with `undefined`
during Jest transforms, making the HMR guard blocks dead-code in tests.

fix: address review findings — inert typing, accessibility, and cleanup

- Add React type augmentation for `inert` attribute (React 18 compat)
- Replace spread hack `{...{ inert: }}` with direct prop in all 3 files
- Add `aria-hidden` to mobile overlay in PromptForm for screen readers
- Simplify deleteGroupRef pattern to direct mutation call
- Remove unused `useEffect` import and stale useMemo dependency
- Add clarifying comment for skipBlurRef mechanism in PromptName

fix: mobile UX for marketplace and prompts views

- Remove mobile new chat button from chat history section
- Add OpenSidebar entry points to marketplace and prompts views on mobile
- Move marketplace admin settings to a compact mobile top row
- Restructure prompt forms to surface category selector beside sidebar toggle
- Make versions panel slide content like the main sidebar and drop redundant borders
- Collapse versions button to icon-only on mobile
- Remove theme selector from prompt panel navigation

* fix: address PR review findings for prompts refactor

- Preserve prompt ID in /d/prompts/:id → /prompts/:id redirect
- Gate PreviewPrompt and VariableDialog behind isChatRoute to avoid
  mounting dead dialogs in dashboard mode
- Add onError handler to useDeletePromptGroup and close dialog on
  success
- Use useId() instead of hardcoded labelId in AdvancedPrompts
- Extract shared lazy loader for InlinePromptsView routes

* fix: complete review fixes for prompts refactor

- Move OGDialog (delete) inside isChatRoute gate with other dialogs
- Use useId() for both Switch id and label id in AdvancedPrompts
- Add com_ui_prompt_delete_error i18n key for actionable error context
- Drop no-op useCallback on handleDelete (unstable deleteGroup dep)

* refactor: hoist promptPath to module-scope constant

Eliminates stale-closure lint concern in dropdownItems useMemo and
removes the unnecessary dep array entry from onCardClick.

* refactor(sidebar): update icons and reorder links for clarity

- Replace Blocks icon with OpenAIMinimalIcon for the Assistant Builder link.
- Update Memories icon from BrainCircuit to Brain.
- Reintroduce Prompts link conditionally based on access permissions.
- Change Conversations icon from MessageSquare to MessagesSquare for consistency.

* refactor(sidebar): update icons and improve file attachment link

- Replace NewChatIcon with SquarePen in the NewChatButton for better visual consistency.
- Change AttachmentIcon to Paperclip in the file attachment link for clarity.

* refactor(sidebar): update file attachment icon for consistency

- Replace Paperclip icon with AttachmentIcon in the file attachment link for improved clarity and visual consistency.

* refactor(admin-settings): remove unused button and streamline dialog integration

- Eliminate the Admin button and its associated icon from the AdminSettings component for a cleaner interface.
- Simplify the confirm dialog integration by directly using the OGDialog without the button trigger.

* fix: context HMR issue

* style(prompts): enhance component structure and accessibility

- Update AutoSendPrompt button class for improved styling.
- Refactor List component to streamline loading and empty states.
- Ensure FilterPrompts handles context gracefully with null checks.
- Modify GroupSidePanel to prevent rendering without context.
- Simplify PromptsAccordion layout for better readability.
- Adjust CategoryIcon fallback behavior for undefined categories.

* refactor(useMCPServerManager): clean up import statements

- Remove duplicate import of MCPServerInitState for better clarity and organization.
- Adjust import order to maintain consistency with project structure.

* refactor(GroupSidePanel): restructure layout for improved readability and accessibility

- Adjust the structure of the GroupSidePanel component to enhance layout clarity.
- Move the PanelNavigation component into a more appropriate position within the hierarchy.
- Ensure consistent styling and behavior based on the isChatRoute condition.

* style(GroupSidePanel): adjust padding for improved layout consistency

- Update padding in the GroupSidePanel component to enhance visual alignment and readability.
- Ensure consistent styling across the component for a better user experience.

* fix(Conversations): add cache clearing and row height recomputation on search query change

- Implement useEffect to clear cache and recompute row heights when the search query changes.
- Enhance performance and responsiveness of the Conversations component during search operations.

* refactor(GroupSidePanel, PromptsAccordion): simplify layout and improve styling

* chore: import order

* fix: redirect users without CREATE permission from /prompts/new

Users with USE but not CREATE permission were seeing a blank page at
/prompts/new because InlinePromptsView only checked USE access.
CreatePromptForm's internal redirect was bypassed by the onSuccess
prop always being passed. Add CREATE check in InlinePromptsView so
the redirect happens before CreatePromptForm mounts.

* fix: restore dropdown actions for all routes and handle non-creator landing

- Remove isChatRoute gate on dropdown menu so preview, edit, and
  delete actions are available on the prompts management route
- Un-gate PreviewPrompt and OGDialog (delete) since both are
  triggered from the now-always-visible dropdown
- Keep VariableDialog gated behind isChatRoute (chat submission only)
- Show EmptyPromptPreview for non-creators at /prompts/new instead
  of redirecting to /c/new, so they stay in the prompts section
  with sidebar access to browse existing prompts

* fix: add isPublic to TPromptGroup type

The database schema (IPromptGroup in data-schemas) has isPublic but
the shared TPromptGroup type in data-provider was missing it,
causing a TS2339 error in ChatGroupItem.

* fix: prevent duplicate rename and restore name on error in PromptName

- Block re-entry to edit mode while a save is in flight by guarding
  the click handler with isLoading/saveStatus checks
- Reset newName to the prop value when mutation fails so the UI
  doesn't display the unsaved name after the error icon clears

* fix: address review findings across prompts refactor

- Consolidate duplicate usePromptGroupsContext() calls in PromptForm
- Remove invalid aria-labelledby (text string, not ID) from
  AutoSendPrompt checkbox that is already aria-hidden
- Remove useMemo wrapping trivial `disabled ?? false` in ToolsDropdown
- Remove dead context spread in PromptsAccordion (GroupSidePanel
  reads context internally)
- Wrap search cache-clear effect in requestAnimationFrame to match
  favorites effect pattern in Conversations
- Use Set for O(1) lookups in MCPSelect server filtering
- Fix unnecessary JSX expression wrapper on string literal in
  CreatePromptButton Link

* style(PromptTextCard): update icon classes for improved accessibility

- Add 'text-text-secondary' class to Check and Copy icons for better visibility and consistency in the PromptTextCard component.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-09 00:02:31 -04:00
Dustin Healy
55fc37ff49
🔐 fix: Add Tenant Context to Admin OAuth Callback Routes (#12579)
* 🔐 fix: Add Tenant Context to Admin OAuth Callback Routes

* fix: add tenant context to admin local login route
2026-04-08 20:04:15 -04:00
Danny Avila
01a1bc1689
📊 experimental: Add GitNexus CI/CD and deployment configuration (#12577)
* feat: Add GitNexus CI/CD and deployment configuration

- Introduced a Dockerfile for building the GitNexus application with necessary dependencies and configurations.
- Added a Caddyfile to set up a reverse proxy with bearer token authentication for secure access to GitNexus.
- Created an entrypoint script to validate the API token and start both GitNexus and Caddy services.
- Configured Fly.io deployment settings in fly.toml, including health checks and service parameters.
- Established GitHub Actions workflows for deploying the GitNexus index and managing deployments to Fly.io.

* fix: use npx instead of bunx for native addon compatibility

bunx skips node-gyp lifecycle scripts, so @ladybugdb/core's native
.node binary never gets compiled/downloaded. npx handles this correctly.
2026-04-08 16:17:19 -04:00
Danny Avila
96312aa4fd
🎯 fix: Use Resolved Provider for Agent Token Lookup on Custom Endpoints (#12574)
* fix: Use resolved provider for agent token lookup on custom endpoints

The providerEndpointMap lookup in initializeAgent used the original
provider name (e.g. "EduGPT") instead of the resolved overrideProvider
("openai"). Since providerEndpointMap only contains 4 built-in
providers, custom providers resolved to undefined, causing
getModelMaxTokens to miss the token map and fall back to 18000 tokens.
With agent instructions + tool schemas consuming most of that budget,
createPruneMessages would strip all messages on the first turn.

* fix: Use correct EndpointTokenConfig type in test

* refactor: Unify test factory, remove non-discriminating test

Address review findings:
- Remove Test 2 ("uses the model real context window") which passed
  with and without the fix due to getModelMaxTokens defaulting to
  openAI when endpoint is undefined (JS default parameter semantics)
- Merge createCustomProviderMocks into createMocks via provider,
  overrideProvider, and useRealTokenLookup parameters
- Hoist jest.requireActual to file scope for shared access

* refactor: Address followup review findings

- Replace loose `maxContextTokens > 18000` assertion with precise
  computed value `Math.round((65536 - 4096) * 0.95)` so the outcome
  assertion is meaningful and self-documenting
- Hoist `customProvider` to describe-level constant `CUSTOM_PROVIDER`
- Document `overrideProvider` semantics and `useRealTokenLookup` in
  factory JSDoc
- Add comment on real `optionalChainWithEmptyCheck` noting its
  zero-handling semantics are load-bearing for the maxContextTokens=0
  test

* style: Use // for inline comment, clarify pipeline assertion role
2026-04-08 15:07:38 -04:00
neuralnotesyt-netizen
622a934a82
🗒️ docs: Update LICENSE.md Year: 2025 -> 2026 (#12554) 2026-04-08 09:12:44 -04:00
github-actions[bot]
c940486a06
🌍 i18n: Update translation.json with latest translations (#12571)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-08 09:12:12 -04:00
Danny Avila
d350c58633
🚫 fix: Hide Delete Account Button When ALLOW_ACCOUNT_DELETION Is Disabled (#12568)
* fix: hide Delete Account button when ALLOW_ACCOUNT_DELETION is false

* fix: add admin bypass, inline env read, and tests for allowAccountDeletion

- Show delete button for admin users even when ALLOW_ACCOUNT_DELETION=false,
  matching the canDeleteAccount middleware's ACCESS_ADMIN bypass
- Move env var read inline in buildSharedPayload() for per-request evaluation
- Add 4 frontend tests for Account conditional rendering
- Add 3 backend tests for allowAccountDeletion config field

* fix: use server-side ACCESS_ADMIN capability check instead of frontend role check

- Replace frontend SystemRoles.ADMIN check with server-side hasCapability()
  in the authenticated config route, matching canDeleteAccount middleware exactly
- Admin bypass now evaluates ACCESS_ADMIN capability per-user in GET /api/config,
  so users with the grant (regardless of role) see the button, and admins
  without the grant do not
- Add 3 authenticated backend tests: without capability, with capability,
  and skip-when-already-enabled
- Simplify frontend to pure config check (no role logic)
- Remove redundant jest-dom import; add inline env var comment

* test: add missing toHaveBeenCalled assertion in ACCESS_ADMIN test
2026-04-07 23:51:23 -04:00
Danny Avila
223065c411
📦 chore: npm audit (#12570)
* 📦 chore: npm audit fix

- Bump `vite` from 7.3.1 to 7.3.2.
- Upgrade `@chevrotain/cst-dts-gen`, `@chevrotain/gast`, `@chevrotain/regexp-to-ast`, `@chevrotain/types`, and `@chevrotain/utils` from 11.1.2 to 12.0.0.
- Update `@hono/node-server` from 1.19.10 to 1.19.13.
- Upgrade `chevrotain` from 11.1.2 to 12.0.0.
- Bump `chevrotain-allstar` from 0.3.1 to 0.4.1.

* 🔧 chore: Remove `serialize-javascript` dependency from `package.json`
2026-04-07 23:08:05 -04:00
Danny Avila
15fc27950d
⚡ refactor: Short-Circuit Config Override Resolution (#12553) 2026-04-07 22:38:08 -04:00
Denis Palnitsky
8ed0bcf5ca
♻️ fix: Reuse Existing MCP OAuth Client Registrations to Prevent client_id Mismatch (#11925)
* fix: reuse existing OAuth client registrations to prevent client_id mismatch

When using auto-discovered OAuth (DCR), LibreChat calls /register on every
flow initiation, getting a new client_id each time. When concurrent
connections or reconnections happen, the client_id used during /authorize
differs from the one used during /token, causing the server to reject the
exchange.

Before registering a new client, check if a valid client registration
already exists in the database and reuse it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Handle re-registration of OAuth clients when redirect_uri changes

* Add undefined fields for logo_uri and tos_uri in OAuth metadata tests

* test: add client registration reuse tests for horizontal scaling race condition

Reproduces the client_id mismatch bug that occurs in multi-replica deployments
where concurrent initiateOAuthFlow calls each register a new OAuth client.
Tests verify that the findToken-based client reuse prevents re-registration.

* fix: address review findings for client registration reuse

- Fix empty redirect_uris bug: invert condition so missing/empty
  redirect_uris triggers re-registration instead of silent reuse
- Revert undocumented config?.redirect_uri in auto-discovery path
- Change DB error logging from debug to warn for operator visibility
- Fix import order: move package type import to correct section
- Remove redundant type cast and misleading JSDoc comment
- Test file: remove dead imports, restore process.env.DOMAIN_SERVER,
  rename describe blocks, add empty redirect_uris edge case test,
  add concurrent reconnection test with pre-seeded token,
  scope documentation to reconnection stabilization

* fix: resolve type check errors for OAuthClientInformation redirect_uris

The SDK's OAuthClientInformation type lacks redirect_uris (only on
OAuthClientInformationFull). Cast to the local OAuthClientInformation
type in handler.ts when accessing deserialized client info from DB,
and use intersection types in tests for clientInfo with redirect_uris.

* fix: address follow-up review findings R1, R2, R3

- R1: Move `import type { TokenMethods }` to the type-imports section,
  before local types, per CLAUDE.md import order rules
- R2: Add unit test for empty redirect_uris in handler.test.ts to
  verify the inverted condition triggers re-registration
- R3: Use delete for process.env.DOMAIN_SERVER restoration when the
  original value was undefined to avoid coercion to string "undefined"

* fix: clear stale client registration on OAuth flow failure

When a stored client_id is no longer recognized by the OAuth server,
the flow fails but the stale client stays in MongoDB, causing every
retry to reuse the same invalid registration in an infinite loop.

On OAuth failure, clear the stored client registration so the next
attempt falls through to fresh Dynamic Client Registration.

- Add MCPTokenStorage.deleteClientRegistration() for targeted cleanup
- Call it from MCPConnectionFactory's OAuth failure path
- Add integration test proving recovery from stale client reuse

* fix: validate auth server identity and target cleanup to reused clients

- Gate client reuse on authorization server identity: compare stored
  issuer against freshly discovered metadata before reusing, preventing
  wrong-client reuse when the MCP server switches auth providers
- Add reusedStoredClient flag to MCPOAuthFlowMetadata so cleanup only
  runs when the failed flow actually reused a stored registration,
  not on unrelated failures (timeouts, user-denied consent, etc.)
- Add cleanup in returnOnOAuth path: when a prior flow that reused a
  stored client is detected as failed, clear the stale registration
  before re-initiating
- Add tests for issuer mismatch and reusedStoredClient flag assertions

* fix: address minor review findings N3, N5, N6

- N3: Type deleteClientRegistration param as TokenMethods['deleteTokens']
  instead of Promise<unknown>
- N5: Elevate deletion failure logging from debug to warn for operator
  visibility when stale client cleanup fails
- N6: Use getLogPrefix() instead of hardcoded log prefix to respect
  system-user privacy convention

* fix: correct stale-client cleanup in both OAuth paths

- Blocking path: remove result?.clientInfo guard that made cleanup
  unreachable (handleOAuthRequired returns null on failure, so
  result?.clientInfo was always false in the failure branch)
- returnOnOAuth path: only clear stored client when the prior flow
  status is FAILED, not on COMPLETED or PENDING flows, to avoid
  deleting valid registrations during normal flow replacement

* fix: remove redundant cast on clientMetadata

clientMetadata is already typed as Record<string, unknown>; the
as Record<string, unknown> cast was a no-op.

* fix: thread reusedStoredClient through return type instead of re-reading flow state

FlowStateManager.createFlow() deletes FAILED flow state before
rejecting, so getFlowState() after handleOAuthRequired() returns null
would find nothing — making the stale-client cleanup dead code.

Fix: hoist reusedStoredClient flag from flowMetadata into a local
variable, include it in handleOAuthRequired()'s return type (both
success and catch paths), and use result.reusedStoredClient directly
in the caller instead of a second getFlowState() round-trip.

* fix: selective stale-client cleanup in returnOnOAuth path

The returnOnOAuth cleanup was unreliable: it depended on reading
FAILED flow state, but FlowStateManager.monitorFlow() deletes FAILED
state before rejecting. Move cleanup into createFlow's catch handler
where flowMetadata.reusedStoredClient is still in scope.

Make cleanup selective in both paths: add isClientRejection() helper
that only matches errors indicating the OAuth server rejected the
client_id (invalid_client, unauthorized_client, client not found).
Timeouts, user-cancelled flows, and other transient failures no
longer wipe valid stored registrations.

Thread the error from handleOAuthRequired() through the return type
so the blocking path can also check isClientRejection().

* fix: tighten isClientRejection heuristic

Narrow 'client_id' match to 'client_id mismatch' to avoid
false-positive cleanup on unrelated errors that happen to
mention client_id.

* test: add isClientRejection tests and enforced client_id on test server

- Add isClientRejection unit tests: invalid_client, unauthorized_client,
  client_id mismatch, client not found, unknown client, and negative
  cases (timeout, flow state not found, user denied, null, undefined)
- Enhance OAuth test server with enforceClientId option: binds auth
  codes to the client_id that initiated /authorize, rejects token
  exchange with mismatched or unregistered client_id (401 invalid_client)
- Add integration tests proving the test server correctly rejects
  stale client_ids and accepts matching ones at /token

* fix: issuer validation, callback error propagation, and cleanup DRY

- Issuer check: re-register when storedIssuer is absent or non-string
  instead of silently reusing. Narrows unknown type with typeof guard
  and inverts condition so missing issuer → fresh DCR (safer default).
- OAuth callback route: call failFlow with the OAuth error when the
  authorization server redirects back with error= parameter, so the
  waiting flow receives the actual rejection instead of timing out.
  This lets isClientRejection match stale-client errors correctly.
- Extract duplicated cleanup block to clearStaleClientIfRejected()
  private method, called from both returnOnOAuth and blocking paths.
- Test fixes: add issuer to stored metadata in reuse tests, reset
  server to undefined in afterEach to prevent double-close.

* fix: gate failFlow behind callback validation, propagate reusedStoredClient on join

- OAuth callback: move failFlow call to after CSRF/session/active-flow
  validation so an attacker with only a leaked state parameter cannot
  force-fail a flow without passing the same integrity checks required
  for legitimate callbacks
- PENDING join path: propagate reusedStoredClient from flow metadata
  into the return object so joiners can trigger stale-client cleanup
  if the joined flow later fails with a client rejection

* fix: restore early oauthError/code redirects, gate only failFlow behind CSRF

The previous restructuring moved oauthError and missing-code checks
behind CSRF validation, breaking tests that expect those redirects
without cookies. The redirect itself is harmless (just shows an error
page). Only the failFlow call needs CSRF gating to prevent DoS.

Restructure: oauthError check stays early (redirects immediately),
but failFlow inside it runs the full CSRF/session/active-flow
validation before marking the flow as FAILED.

* fix: require deleteTokens for client reuse, add missing import in MCP.js

Client registration reuse without cleanup capability creates a
permanent failure loop: if the reused client is stale, the code
detects the rejection but cannot clear the stored registration
because deleteTokens is missing, so every retry reuses the same
broken client_id.

- MCPConnectionFactory: only pass findToken to initiateOAuthFlow
  when deleteTokens is also available, ensuring reuse is only
  enabled when recovery is possible
- api/server/services/MCP.js: add deleteTokens to the tokenMethods
  object (was the only MCP call site missing it)

* fix: set reusedStoredClient before createFlow in joined-flow path

When joining a PENDING flow, reusedStoredClient was only set on the
success return but not before the await. If createFlow throws (e.g.
invalid_client during token exchange), the outer catch returns the
local variable which was still false, skipping stale-client cleanup.

* fix: require browser binding (CSRF/session) for failFlow on OAuth error

hasActiveFlow only proves a PENDING flow exists, not that the caller
is the same browser that initiated it. An attacker with a leaked state
could force-fail the flow without any user binding. Require hasCsrf or
hasSession before calling failFlow on the oauthError path.

* fix: guard findToken with deleteTokens check in blocking OAuth path

Match the returnOnOAuth path's defense-in-depth: only enable client
registration reuse when deleteTokens is also available, ensuring
cleanup is possible if the reused client turns out to be stale.

* fix: address review findings — tests, types, normalization, docs

- Add deleteTokens method to InMemoryTokenStore matching TokenMethods
  contract; update test call site from deleteToken to deleteTokens
- Add MCPConnectionFactory test: returnOnOAuth flow fails with
  invalid_client → clearStaleClientIfRejected invoked automatically
- Add mcp.spec.js tests: OAuth error with CSRF → failFlow called;
  OAuth error without cookies → failFlow NOT called (DoS prevention)
- Add JSDoc to isClientRejection with RFC 6749 and vendor attribution
- Add inline comment explaining findToken/deleteTokens coupling guard
- Normalize issuer comparison: strip trailing slashes to prevent
  spurious re-registrations from URL formatting differences
- Fix dead-code: use local reusedStoredClient variable in PENDING
  join return instead of re-reading flowMeta

* fix: address final review nits N1-N4

- N1: Add session cookie failFlow test — validates the hasSession
  branch triggers failFlow on OAuth error callback
- N2: Replace setTimeout(50) with setImmediate for microtask drain
- N3: Add 'unknown client' attribution to isClientRejection JSDoc
- N4: Remove dead getFlowState mock from failFlow tests

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-03 22:15:08 -04:00
Danny Avila
33ee7dea1e
🔎 fix: Specify Explicit Primary Key for Meilisearch Document Operations (#12542)
* fix: pass explicit primaryKey to Meilisearch addDocuments/updateDocuments calls

Meilisearch v1.0+ refuses to auto-infer the primary key when a document
contains multiple fields ending with 'id'. The messages index has both
conversationId and messageId, causing addDocuments to silently fail with
index_primary_key_multiple_candidates_found, leaving message search empty.

Pass { primaryKey } to addDocumentsInBatches, addDocuments, and
updateDocuments — the variable was already in scope.

Also replace raw this.collection.updateMany with Mongoose Model.updateMany
to satisfy the no-restricted-syntax ESLint rule (tenant isolation guard).

Closes #12538

* fix: resolve additional Meilisearch plugin bugs found in review

Address review findings from PR #12542:

- Fix deleteObjectFromMeili using MongoDB _id instead of the Meilisearch
  primary key (conversationId/messageId), causing post-remove cleanup to
  silently no-op and leave orphaned documents in the index.

- Pass options.primaryKey explicitly to createMeiliMongooseModel factory
  instead of deriving it from attributesToIndex[0] (schema field order),
  eliminating a fragile implicit contract.

- Fix updateObjectToMeili skipping preprocessObjectForIndex, which meant
  updates bypassed content array-to-text conversion and conversationId
  pipe character escaping.

- Change collection.updateMany to collection.updateOne in addObjectToMeili
  since _id is unique (semantic correctness).

- Add primaryKey to validateOptions required keys.

- Strengthen test assertions to verify { primaryKey } argument is passed
  to addDocuments, addDocumentsInBatches, and updateDocuments. Add tests
  for the update path including preprocessObjectForIndex pipe escaping.

* fix: add regression tests for delete and message update paths

Address follow-up review findings:

- Add test for deleteObjectFromMeili verifying it uses messageId (not
  MongoDB _id) when calling index.deleteDocument, guarding against
  regression of the silent orphaned-document bug.

- Add test for message model update path asserting { primaryKey:
  'messageId' } is passed to updateDocuments (previously only the
  conversation model update path was tested).

- Add @param config.primaryKey to createMeiliMongooseModel JSDoc.
2026-04-03 18:01:06 -04:00
Danny Avila
b44ce264a4
📦 chore: Bump mongodb-memory-server to v11.0.1, mermaid to v11.14.0, npm audit (#12543)
* 🔧 chore: Update `mongodb-memory-server` to v11.0.1

- Bump `mongodb-memory-server` version in `package-lock.json`, `api/package.json`, and `packages/data-schemas/package.json` from 10.1.4 to 11.0.1.
- Update related dependencies in `mongodb-memory-server` and `mongodb-memory-server-core` to ensure compatibility with the new version.
- Adjust `tslib` version in `mongodb-memory-server` to 2.8.1 and `debug` to 4.4.3 for consistency.

* chore: npm audit fix

* chore: Update `mermaid` dependency to version 11.14.0 in `package-lock.json` and `client/package.json`

* fix: use deterministic timestamps in convoStructure test

MongoDB 8.x (from mongodb-memory-server v11) no longer guarantees
insertion-order return for documents with identical timestamps.
Use sequential timestamps with overrideTimestamp to ensure buildTree
processes parents before children.
2026-04-03 17:01:11 -04:00
Shahryar Tayeb
2140729a54
🗣️ fix: Prevent @librechat/client useLocalize from Overwriting Host App Language State (#12515)
* directly returns the translation function without managing language state in client package

* chore: remove unused langAtom from packages/client store

* fix: add useCallback to match canonical useLocalize, add guard comment

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-03 15:01:39 -04:00
Daniel Lew
162ac9c253
📝 fix: Properly Restore Draft Text When Switching Conversations (#12384)
Right now, if you have draft text in conversation A, but no draft text in
conversation B, then switching from A -> B inserts the draft from A into B
(oops).

This was caused by a bug in the `restoreText()` logic which did not
restore *blank* text as the saved draft.

Now, it'll always restore whatever is found as a draft (or set to blank if
there is no draft).
2026-04-03 14:08:21 -04:00
Dustin Healy
261941c05f
🔨 fix: Custom Role Permissions (#12528)
* fix: Resolve custom role permissions not loading in frontend

Users assigned to custom roles (non-USER/ADMIN) had all permission
checks fail because AuthContext only fetched system role permissions.
The roles map keyed by USER/ADMIN never contained the custom role name,
so useHasAccess returned false for every feature gate.

- Fetch the user's custom role in AuthContext and include it in the
  roles map so useHasAccess can resolve permissions correctly
- Use encodeURIComponent instead of toLowerCase for role name URLs
  to preserve custom role casing through the API roundtrip
- Only uppercase system role names on the backend GET route; pass
  custom role names through as-is for exact DB lookup
- Allow users to fetch their own assigned role without READ_ROLES
  capability

* refactor: Normalize all role names to uppercase

Custom role names were stored in original casing, causing case-sensitivity
bugs across the stack — URL lowercasing, route uppercasing, and
case-sensitive DB lookups all conflicted for mixed-case custom roles.

Enforce uppercase normalization at every boundary:
- createRoleByName trims and uppercases the name before storage
- createRoleHandler uppercases before passing to createRoleByName
- All admin route handlers (get, update, delete, members, permissions)
  uppercase the :name URL param before DB lookups
- addRoleMemberHandler uppercases before setting user.role
- Startup migration (normalizeRoleNames) finds non-uppercase custom
  roles, renames them, and updates affected user.role values with
  collision detection

Legacy GET /api/roles/:roleName retains always-uppercase behavior.
Tests updated to expect uppercase role names throughout.

* fix: Use case-preserved role names with strict equality

Remove uppercase normalization — custom role names are stored and
compared exactly as the user sets them, with only trimming applied.
USER and ADMIN remain reserved case-insensitively via isSystemRoleName.

- Remove toUpperCase from createRoleByName, createRoleHandler, and
  all admin route handlers (get, update, delete, members, permissions)
- Remove toUpperCase from legacy GET and PUT routes in roles.js;
  the frontend now sends exact casing via encodeURIComponent
- Remove normalizeRoleNames startup migration
- Revert test expectations to original casing

* fix: Format useMemo dependency array for Prettier

* feat: Add custom role support to admin settings + review fixes

- Add backend tests for isOwnRole authorization gate on GET /api/roles/:roleName
- Add frontend tests for custom role detection and fetching in AuthContext
- Fix transient null permission flash by only spreading custom role once loaded
- Add isSystemRoleName helper to data-provider for case-insensitive system role detection
- Use sentinel value in useGetRole to avoid ghost cache entry from empty string
- Add useListRoles hook and listRoles data service for fetching all roles
- Update AdminSettingsDialog and PeoplePickerAdminSettings to dynamically
  list custom roles in the role dropdown, with proper fallback defaults

* fix: Address review findings for custom role permissions

- Add assertions to AuthContext test verifying custom role in roles map
- Fix empty array bypassing nullish coalescing fallback in role dropdowns
- Add null/undefined guard to isSystemRoleName helper
- Memoize role dropdown items to avoid unnecessary re-renders
- Apply sentinel pattern to useGetRole in admin settings for consistency
- Mark ListRolesResponse description as required to match schema

* fix: Prevent prototype pollution in role authorization gate

- Replace roleDefaults[roleName] with Object.hasOwn to prevent
  prototype chain bypass for names like constructor or __proto__
- Add dedicated rolesList query key to avoid cache collision when
  a custom role is named 'list'
- Add regression test for prototype property name authorization

* fix: Resolve Prettier formatting and unused variable lint errors

* fix: Address review findings for custom role permissions

- Add ADMIN self-read test documenting isOwnRole bypass behavior
- Guard save button while custom role data loads to prevent data loss
- Extract useRoleSelector hook eliminating ~55 lines of duplication
- Unify defaultValues/useEffect permission resolution (fixes inconsistency)
- Make ListRolesResponse.description and _id optional to match schema
- Fix vacuous test assertions to verify sentinel calls exist
- Only fetch userRole when user.role === USER (avoid unnecessary requests)
- Remove redundant empty string guard in custom role detection

* fix: Revert USER role fetch restriction to preserve admin settings

Admins need the USER role loaded in AuthContext.roles so the admin
settings dialog shows persisted USER permissions instead of defaults.

* fix: Remove unused useEffect import from useRoleSelector

* fix: Clean up useRoleSelector hook

- Use existing isCustom variable instead of re-calling isSystemRoleName
- Remove unused roles and availableRoleNames from return object

* fix: Address review findings for custom role permissions

- Use Set-based isSystemRoleName to auto-expand with future SystemRoles
- Add isCustomRoleError handling: guard useEffect reset and disable Save
- Remove resolvePermissions from hook return; use defaultValues in useEffect
  to eliminate redundant computation and stale-closure reset race
- Rename customRoleName to userRoleName in AuthContext for clarity

* fix: Request server-max roles for admin dropdown

listRoles now passes limit=200 (the server's MAX_PAGE_LIMIT) so the
admin role selector shows all roles instead of silently truncating
at the default page size of 50.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-03 13:24:11 -04:00
Daniel Lew
936936596b
🔍 fix: only show Searchbar if enabled (#12424)
The search bar was showing even if you have no search capability;
now it respects the `enabled` field.
2026-04-03 12:24:59 -04:00
Danny Avila
ea28dbfa89
🧹 chore: Clean Up Config Fields (#12537)
* chore: remove unused `interface.endpointsMenu` config field

* chore: address review — restore JSDoc UI-only example, add Zod strip test

* chore: remove unused `interface.sidePanel` config field

* chore: restrict fileStrategy/fileStrategies schema to valid storage backends

* fix: use valid FileStorage value in AppService test

* chore: address review — version bump, exhaustiveness guard, JSDoc, configSchema test

* chore: remove debug logger.log from MessageIcon render path

* fix: rewrite MessageIcon render tests to use render counting instead of logger spying

* chore: bump librechat-data-provider to 0.8.407

* chore: sync example YAML version to 1.3.7
2026-04-03 12:22:58 -04:00
Danny Avila
b4d97bd888
🗜️ refactor: Eliminate Unstable React Keys During SSE Lifecycle (#12536)
* debug: add instrumentation to MessageIcon arePropsEqual + render cycle tests

Temporary debug commit to identify which field triggers MessageIcon
re-renders during message creation and streaming.

arePropsEqual now logs 'icon_memo_diff' with the exact field name and
prev/next values whenever it returns false. Filter browser console for
'icon_memo_diff' to see the trigger.

Also adds render-level integration tests that simulate the message
lifecycle (initial mount, streaming chunks, context updates) and
assert render counts via logger spy.

* perf: stabilize MultiMessage key to prevent unmount/remount during SSE lifecycle

messageId changes 3 times during the SSE message lifecycle:
1. useChatFunctions creates initialResponse with client-generated UUID
2. createdHandler replaces it with userMessageId + '_'
3. finalHandler replaces it with server-assigned messageId

Since MultiMessage used key={message.messageId}, each change caused
React to destroy and recreate the entire message component subtree,
unmounting MessageIcon and all memoized children. This produced visible
icon/image flickering that no memo comparator could prevent.

Switch to key={parentMessageId + '_' + siblingIdx}:
- parentMessageId is stable from creation through final response
- siblingIdx ensures sibling switches still get clean remounts
- Eliminates 2 unnecessary unmount/remount cycles per message

Add key stability tests verifying:
- Current key={messageId} causes 3 mounts / 2 unmounts per lifecycle
- Stable key causes 1 mount / 0 unmounts per lifecycle
- Sibling switches still trigger clean remounts with stable key

* perf: stabilize root MultiMessage key across new conversation lifecycle

When a user sends their first message in a new conversation,
conversationId transitions from null/'new' to the server-assigned
UUID. MessagesView used key={conversationId} on the root MultiMessage,
so this transition destroyed the entire message tree and rebuilt it
from scratch — causing all MessageIcons to unmount/remount (visible
as image flickering).

Use a ref-based stable key that captures the first real conversationId
and only changes on genuine conversation switches (navigating to a
different conversation), not on the null→UUID transition within the
same conversation.

* debug: add mount/unmount lifecycle tracking to MessageIcon

Adds icon_lifecycle logs (MOUNT/UNMOUNT) and render count to
distinguish between fresh mounts (memo comparator not called)
and internal re-renders (hook bypassing memo).

Enable: localStorage.setItem('DEBUG_LOGGING', 'icon_lifecycle,icon_data,icon_memo_diff')

* debug: add key and root tracking to MultiMessage and MessagesView

Logs multi_message_key (stableKey, messageId, parentMessageId, route)
and messages_view_key (rootKey, conversationId) to trace which key
changes trigger unmount/remount cycles.

Enable: localStorage.setItem('DEBUG_LOGGING', 'icon_lifecycle,icon_data,icon_memo_diff,multi_message_key,messages_view_key')

* perf: remove key from root MultiMessage to prevent tree destruction

The ref-based stable key still changed during 'new' → real UUID
transition, destroying the entire tree. The root MultiMessage is the
sole child at its position, so React reuses the instance via
positional reconciliation without any key. The messageId prop
(conversationId) naturally resets Recoil siblingIdxFamily state on
conversation switches.

* perf: remove unstable keys from MultiMessage to prevent SSE lifecycle remounts

Both messageId and parentMessageId change during the SSE lifecycle
(client UUID → CREATED server ID → FINAL server ID), making neither
viable as a stable React key. Each key change caused React to destroy
and recreate the entire message component subtree, including all
memoized children — visible as icon/image flickering.

Remove explicit keys entirely and rely on React's positional
reconciliation. MultiMessage always renders exactly one child at
the same position, so React reuses the component instance and
updates props in place. The existing memo comparators on
ContentRender/MessageRender handle field-level diffing correctly.

Update tests to verify: key={messageId} causes 3 mounts/2 unmounts
per lifecycle, while no key causes 1 mount/0 unmounts.

* perf: remove unstable keys from child MultiMessage in message wrappers

Message.tsx, MessageContent.tsx, and MessageParts.tsx each render a
child MultiMessage with key={messageId} for the current message's
children. Since messageId changes during the SSE lifecycle (CREATED
event replaces the user message ID), the child MultiMessage gets
destroyed and recreated, unmounting the entire agent response subtree
including its MessageIcon.

Remove these keys for the same reason as the parent MultiMessage:
each child MultiMessage renders exactly one child at a fixed position,
so positional reconciliation correctly reuses the instance.

* chore: remove MultiMessage key tests — they test React behavior, not our code

The tests verified that key={messageId} causes remounts while no key
doesn't, but this is React's own reconciliation behavior. No unit test
can prevent someone from re-adding a key prop to JSX. The JSDoc comments
on MultiMessage document the decision and rationale.
2026-04-02 22:29:54 -04:00
Danny Avila
fa4a43da21
🔐 fix: Strip code_challenge from Admin OAuth requests before Passport (#12534)
* 🔐 fix: Strip code_challenge from admin OAuth requests before Passport

openid-client v6's Passport Strategy uses `currentUrl.searchParams.size === 0`
to distinguish initial authorization requests from OAuth callbacks. The
admin-panel-specific `code_challenge` query parameter caused the strategy to
misclassify the request as a callback and return 401 Unauthorized.

* 🔐 fix: Strip code_challenge from admin OAuth requests before Passport

openid-client v6's Passport Strategy uses `currentUrl.searchParams.size === 0`
to distinguish initial authorization requests from OAuth callbacks. The
admin-panel-specific `code_challenge` query parameter caused the strategy to
misclassify the request as a callback and return 401 Unauthorized.

- Fix regex to handle `code_challenge` in any query position without producing
  malformed URLs, and handle empty `code_challenge=` values (`[^&]*` vs `[^&]+`)
- Combine `storePkceChallenge` + `stripCodeChallenge` into a single
  `storeAndStripChallenge` helper to enforce read-store-strip ordering
- Apply defensively to all 7 admin OAuth providers
- Add 12 unit tests covering stripCodeChallenge and storeAndStripChallenge

* refactor: Extract PKCE helpers to utility file, harden tests

- Move stripCodeChallenge and storeAndStripChallenge to
  api/server/utils/adminPkce.js — eliminates _test production export
  and avoids loading the full auth.js module tree in tests
- Add missing req.originalUrl/req.url assertions to invalid-challenge
  and no-challenge test branches (regression blind spots)
- Hoist cache reference to module scope in tests (was redundantly
  re-acquired from mock factory on every beforeEach)

* chore: Address review NITs — imports, exports, naming, assertions

- Fix import order in auth.js (longest-to-shortest per CLAUDE.md)
- Remove unused PKCE_CHALLENGE_TTL/PKCE_CHALLENGE_PATTERN exports
- Hoist strip arrow to module-scope stripChallengeFromUrl
- Rename auth.test.js → auth.spec.js (project convention)
- Tighten cache-failure test: toBe instead of toContain, add req.url

* refactor: Move PKCE helpers to packages/api with dependency injection

Move stripCodeChallenge and storeAndStripChallenge from api/server/utils
into packages/api/src/auth/exchange.ts alongside the existing PKCE
verification logic. Cache is now injected as a Keyv parameter, matching
the dependency-injection pattern used throughout packages/api/.

- Add PkceStrippableRequest interface for minimal req typing
- auth.js imports storeAndStripChallenge from @librechat/api
- Delete api/server/utils/adminPkce.js
- Move tests to packages/api/src/auth/adminPkce.spec.ts (TypeScript,
  real Keyv instances, no getLogStores mock needed)
2026-04-02 21:03:44 -04:00
Danny Avila
ed02fe40e0
🪆 fix: Allow Nested addParams in Config Schema (#12526)
* fix: allow nested addParams in config schema

* Respect no-op task constraint

Constraint: Task 2 explicitly forbids code changes
Directive: Keep this worker branch code-identical to the assigned base for this task
Confidence: high
Scope-risk: narrow
Tested: git status --short (clean)

* fix: align addParams web_search validation with runtime

* test: cover addParams edge cases

* chore: ignore .codex directory
2026-04-02 20:38:46 -04:00
Danny Avila
6ecd1b510f
📎 fix: Route Unrecognized File Types via supportedMimeTypes Config (#12508)
* fix: check supportedMimeTypes before routing unrecognized file types

In processAttachments, files not matching the hardcoded mime type
categories (image, PDF, video, audio) were silently dropped. Now
resolves the endpoint's file config and checks the file type against
supportedMimeTypes before routing to the documents pipeline. Files
not matching any config are still skipped (original behavior).

Closes #12482

* feat: encode generic document types for supported providers

Remove restrictive mime type filter in encodeAndFormatDocuments that
only allowed PDFs and application/* types. Add a generic encoding
path for non-PDF, non-Bedrock files using the provider's native
format (Anthropic base64 document, OpenAI file block, Google media
block). Files are already validated upstream by supportedMimeTypes.

* fix: guard file.type and cache file config in processAttachments

- Add file.type truthiness check before checkType to prevent
  coercion of null/undefined to string 'null'/'undefined'
- Cache mergedFileConfig and endpointFileConfig on the instance
  so addPreviousAttachments doesn't recompute per message

* refactor: harden generic document encoding with validation and tests

- Extract formatDocumentBlock helper to eliminate ~30 lines of
  duplicate provider-dispatch code between PDF and generic paths
- Add size validation in generic encoding path using
  configuredFileSizeLimit (was fetched but unused)
- Guard Bedrock from generic path — non-bedrockDocumentFormats
  types are now skipped instead of silently tracking metadata
- Only push metadata to result.files when a document block was
  actually created, preventing silent inconsistent state
- Enable Anthropic citations for text/plain, text/html,
  text/markdown (supported by Anthropic's document API)
- Fix != to !== for Providers.AZURE comparison
- Add 9 tests covering all four provider branches, Bedrock
  exclusion, size limit enforcement, and unhandled provider

* fix: resolve filename type mismatch in formatDocumentBlock

filename parameter is string | undefined but OpenAIFileBlock and
OpenAIInputFileBlock require string. Default to 'document' when
filename is undefined.

* fix: use endpoint name for file config lookup in processAttachments

Agent runs can have agent.provider set to a base provider (e.g.,
openAI) while agent.endpoint is a custom endpoint name. Using
provider for the getEndpointFileConfig lookup bypassed custom
endpoint supportedMimeTypes config. Now uses agent.endpoint,
matching the pattern in addDocuments.

* perf: filter non-Bedrock files before fetching streams

Bedrock only supports types in bedrockDocumentFormats. Previously,
getFileStream was called for all files and unsupported types were
discarded after download. Now pre-filters the file list for Bedrock
to avoid unnecessary network and memory overhead for large
unsupported attachments.

* refactor: clean up processAttachments file config handling

- Remove redundant ?? null intermediaries; use instance properties
  directly in the else-if condition
- Add JSDoc @type annotations for _mergedFileConfig and
  _endpointFileConfig in the constructor

* refactor: harden document encoding and add routing tests

- Hoist configuredFileSizeLimit above the loop to avoid recomputing
  mergeFileConfig per file
- Replace Buffer.from decode with base64 length formula in the
  generic size check to avoid unnecessary heap allocation
- Use nullish coalescing (??) for filename fallback
- Clean up test: remove unnecessary type cast, use createMockRequest
  helper for size-limit test
- Add 14 tests for processAttachments categorization logic covering
  supportedMimeTypes routing, null/undefined guards, standard type
  passthrough, and edge cases

* fix: use optional chaining for checkType in routing tests

FileConfig.checkType is typed as optional. Use optional chaining
to satisfy strict type checking.

* fix: skip stream fetches for unsupported providers, block Bedrock generic routing

- Return early from encodeAndFormatDocuments when the provider is
  neither document-supported nor Bedrock, avoiding unnecessary
  getFileStream calls for providers that would discard all results
- Add !isBedrock guard to the supportedMimeTypes fallback branch in
  processAttachments so permissive patterns like '.*' don't route
  non-Bedrock types into documents that would be silently dropped
- Add test for Bedrock + non-Bedrock-document-type skipping

* fix: respect supportedMimeTypes config for Bedrock endpoints

Remove !isBedrock guard from the generic supportedMimeTypes routing
branch. If a user configures permissive supportedMimeTypes for a
Bedrock endpoint, the upload validation already accepted the file.
The encoding layer pre-filters to Bedrock-supported types before
fetching streams, so unsupported types are handled there without
silently dropping files the user explicitly allowed.
2026-04-01 23:04:43 -04:00
Danny Avila
275af48592
🎯 fix: MCP Tool Misclassification from Action Delimiter Collision (#12512)
* fix: prevent MCP tools with `_action` in name from being misclassified as OpenAPI action tools

Add `isActionTool()` helper that checks for the `_action_` delimiter
while guarding against cross-delimiter collision with `_mcp_`. Replace
all `includes(actionDelimiter)` classification checks with the new
helper across backend and frontend.

* test: add coverage for MCP/action cross-delimiter collision

Verify that `isActionTool` correctly rejects MCP tool names containing
`_action` and that `loadAgentTools` does not filter them based on
`actionsEnabled`. Add ToolIcon and definitions test cases.

* fix: simplify isActionTool to handle all MCP name patterns

- Use `!toolName.includes('_mcp_')` instead of checking only after the
  first `_action_` occurrence, which missed MCP tools with `_action_` in
  the middle of their name (e.g. `get_action_data_mcp_myserver`).
- Reference `Constants.mcp_delimiter` value via a local const to avoid
  circular import from config.ts, with a comment explaining why.
- Remove dead `actionDelimiter` import from definitions.ts.
- Replace double-filter with single-pass partition in loadToolsForExecution.
- Add test for mid-name `_action_` collision case.

* fix: narrow MCP exclusion to delimiter position in isActionTool

Only reject when `_mcp_` appears after `_action_` (the MCP suffix
position). `_mcp_` before `_action_` is part of the operationId and
is valid — e.g. `sync_mcp_state_action_api---example---com` is a
legitimate action tool whose operationId happens to contain `_mcp_`.

* fix: document positional _mcp_ guard and known RFC-invalid domain limitation

Expand JSDoc on isActionTool to explain the action/MCP format
disambiguation and the theoretical false negative for non-RFC-compliant
domains containing `_mcp_`. Add test documenting this known edge case.
2026-04-01 22:36:21 -04:00
Danny Avila
611a1ef5dc
🏖️ fix: Sandpack ExternalResources for Static HTML Artifact Previews (#12509)
* fix: omit externalResources for static Sandpack previews

The Tailwind CDN URL lacks a file extension, causing Sandpack's
static template to throw a runtime injection error. Static previews
already load Tailwind via a script tag in the shared index.html,
so externalResources is unnecessary for them.

Closes #12507

* refactor: extract buildSandpackOptions and add tests

- Surgically omit only externalResources for static templates
  instead of discarding all sharedOptions, preventing future
  regression if new template-agnostic options are added.
- Extract options logic into a pure, testable helper function.
- Add unit tests covering all template/config combinations.

* chore: fix import order and pin test assertions

* fix: use URL fragment hint instead of omitting externalResources

Sandpack's static template regex detects resource type from the URL's
last file extension. The versioned CDN path (/3.4.17) matched ".17"
instead of ".js", throwing "Unable to determine file type". Rather
than omitting externalResources for static templates (which would
remove the only Tailwind injection path for HTML artifacts that don't
embed their own script tag), append a #tailwind.js fragment hint so
the regex matches ".js". Fragments are not sent to the server, so
the CDN response is unchanged.
2026-04-01 22:06:42 -04:00
Danny Avila
cb41ba14b2
🔁 fix: Pass recursionLimit to OpenAI-Compatible Agents API Endpoint (#12510)
* fix: pass recursionLimit to processStream in OpenAI-compatible agents API

The OpenAI-compatible endpoint never passed recursionLimit to LangGraph's
processStream(), silently capping all API-based agent calls at the default
25 steps. Mirror the 3-step cascade already used by the UI path (client.js):
yaml config default → per-agent DB override → max cap.

* refactor: extract resolveRecursionLimit into shared utility

Extract the 3-step recursion limit cascade into a shared
resolveRecursionLimit() function in @librechat/api. Both openai.js and
client.js now call this single source of truth.

Also fixes falsy-guard edge cases where recursion_limit=0 or
maxRecursionLimit=0 would silently misbehave, by using explicit
typeof + positive checks.

Includes unit tests covering all cascade branches and edge cases.

* refactor: use resolveRecursionLimit in openai.js and client.js

Replace duplicated cascade logic in both controllers with the shared
resolveRecursionLimit() utility from @librechat/api.

In openai.js: hoist agentsEConfig to avoid double property walk,
remove displaced comment, add integration test assertions.

In client.js: remove inline cascade that was overriding config
after initial assignment.

* fix: hoist processStream mock for test accessibility

The processStream mock was created inline inside mockResolvedValue,
making it inaccessible via createRun.mock.results (which returns
the Promise, not the resolved value). Hoist it to a module-level
variable so tests can assert on it directly.

* test: improve test isolation and boundary coverage

Use mockReturnValueOnce instead of mockReturnValue to prevent mock
leaking across test boundaries. Add boundary tests for downward
agent override and exact-match maxRecursionLimit.
2026-04-01 21:13:07 -04:00