Commit graph

5230 commits

Author SHA1 Message Date
Marco Beretta
92a8058f02
🛟 fix: Isolate Invalid Skills During GitHub Sync (#14735)
* fix: treat unrecognized SKILL.md frontmatter keys as warnings

An unknown key in one SKILL.md failed that skill outright, and because the
GitHub sync runner marks a source failed on any validation error, a single
stray key took down every other skill in the repository. Syncing
github.com/cloudflare/skills failed entirely because 2 of its 13 skills
carry a `references:` key.

UNKNOWN_KEY is now a warning, so the skill is stored (unknown keys and all)
and the issue is surfaced rather than fatal. `references` joins the allowed
set with a shallow JSON-safety check instead of a strict kind match: real
files use a string, a list of strings, a list of objects, and a map, and
pinning one shape would reintroduce the same failure.

Malformed frontmatter stays fatal: INVALID_TYPE, INVALID_SHAPE and the
non-plain-object check are unchanged.

* fix: skip individual skills instead of failing a whole sync source

Any error inside the discovery or commit loop reached the outer catch and
marked the entire source failed, so one unusable SKILL.md, one oversized
blob, or one duplicate name cost every other skill in the repository.

Each skill now runs inside its own boundary and a failure is recorded
against that skill. Errors that mean nothing else in the run can succeed
(lock loss, GitHub auth failures, rate limiting) still abort the source
rather than being charged to whichever skill hit them first. Skills are
marked seen before the attempt, so the reconcile pass cannot mirror-delete
the previously synced copy of a skill a later run can repair, and duplicate
names now drop the whole colliding group instead of letting tree order pick
an arbitrary winner.

Status gains `partial` (published some, skipped others) plus a capped
sample of the skipped skills with the reason for each. A run that publishes
nothing and skips something is still `failed`, carrying the first skip's
error. The skipped entries name repository paths, so they follow the same
visibility rule as owner/repo/paths; the bare count does not.

Sync warnings are logged too: a background run has no user-facing surface,
so the log is the only place a maintainer sees why an upstream SKILL.md
looks off.

* test: cover skill sync warnings reaching the log

An unrecognized frontmatter key no longer fails the skill, so a background
sync has nowhere to report it except the log. Every mock in this spec
returned an empty warning list, which left that path unexercised.

* fix: describe nested frontmatter values in the shared skill type

`SkillFrontmatterValue` allowed only scalars and string arrays, while the
server has always stored `hooks` and `metadata` as JSON-safe objects, and now
`references` too. A skill carrying any of them could not be represented by
`TSkill`, `TCreateSkill` or `TUpdateSkillPayload` without a cast.

The type stays free of `any` and `unknown`: values remain JSON-safe by
construction, and the server keeps bounding depth, string length and array
size when it validates them.

* fix: protect moved mirrors and rolled-back counts when a skill is skipped

Continuing past a failed skill exposed two problems that aborting the whole
source used to hide.

A moved skill's mirror keeps its old upstream id until the update lands, and
only the new path was marked as seen, so the reconcile pass read the mirror as
stale and deleted the very copy the skip path exists to preserve. The old id is
now marked as seen too.

Deletion counters were incremented when a stale name-conflicting mirror was
removed, but never undone when the following commit failed and the mirror was
restored. The run no longer stops there, so the status persisted a deletion
that did not happen and the reconcile pass counted the restored row again.
Counters are now rolled back when the restore succeeds.

* fix: bound unknown frontmatter values and keep moved mirrors through duplicates

Tolerating an unrecognized key meant its value skipped the shared JSON-safety
check, so a deeply nested or oversized payload was accepted and persisted under
a key nobody validates. The key stays non-blocking; the value is now held to
the same depth, array and string bounds as every structured key.

A skill that moves into a name another discovered skill also claims is dropped
with the rest of its duplicate group before the sync path can reuse its mirror,
which left the still-published copy unmarked and reconciled away. Both paths now
mark the moved mirror through one helper.

* fix: end the source when a skipped skill fails to roll back

A skill that fails and rolls back cleanly is just a skipped skill. One whose
restore or delete also fails leaves a mirror with half-rewritten files or a
half-created row, and the run now continues past it, so the source could report
partial success while that mirror stayed inconsistent and its pre-marked
upstream id kept reconciliation away from it.

Failed rollbacks now raise a source-fatal error carrying the original failure,
which stops the source the way a lost lock or a refused GitHub token does.

* test: cover a skipped skill discovered at the repository root

A repository-level SKILL.md is discovered with an empty path, so this pins
that a skip recorded against it still persists with the rest of the partial
status rather than taking the whole status row down with it.

* docs: describe unknown skill frontmatter warnings

* fix: preserve mirrors after partial skill sync

* fix: preserve skill validation details during sync

* fix: fail sync when mirror identity cannot be restored

* fix: harden skill sync failure boundaries

* fix: preserve skipped skills on fatal sync

* fix: surface skill sync diagnostics and rollback failures

* fix: preserve skill frontmatter extension keys

* fix: reject skill frontmatter keys that collide when normalized

Frontmatter keys are matched case-insensitively against the canonical
key list, so "Name" and "name" both resolve to "name". Every call site
normalized independently, and the last key in iteration order silently
won, meaning the effective value depended on YAML ordering rather than
on anything the author could see.

Centralize the normalization in normalizeSkillFrontmatterKeys and have
it fail when two recognized keys resolve to the same canonical key,
rather than picking one. parse.ts, deployment.ts and the agent handler
now surface that as a parse error; createSkill and updateSkill surface
it as a blocking DUPLICATE_KEY validation issue. Unrecognized keys are
still passed through untouched so extension frontmatter survives.

deriveStructuredFrontmatterFields and both write paths now run on the
normalized map, so a "Disable-Model-Invocation" key derives the same
column a lowercase one does.

* fix: harden github skill sync against dropped requests and failed cleanup

Three failure paths in the GitHub sync could leave a source looking
healthier than it was.

githubJson only handled HTTP-level errors. A fetch that rejected before
producing a response (DNS failure, socket reset, abort) escaped as a
raw TypeError, so the sync reported a generic crash instead of a typed
sync error. Wrap it as GITHUB_REQUEST_FAILED and add that code to the
fatal set, since a source whose requests never complete cannot be
partially synced.

When a synced file failed to persist, the orphaned upload was cleaned
up on a best-effort basis and the cleanup error was only logged. If the
cleanup itself failed, the source still ended with the original error
and left a real orphan behind. Promote that to a rollback failure so
the source reports SYNC_ROLLBACK_FAILED with the triggering error.

Skill warnings were logged inside commitRemoteSkill, before the file
sync and viewer setup that can still roll the skill back. A skill that
never survived publication therefore emitted warnings as though it had.
Return the warnings from the commit and log them once the skill is
fully published.

* fix: report skipped github skills before credential errors

serializeErrorMessage checked isCredentialError first, and that check
matches on the error text. A skipped skill whose path happens to
contain a credential-ish word, for example skills/credential-helper,
was therefore redacted to "GitHub skill sync credentials are not
available" for admins without credential-metadata access, hiding a
parse failure behind a wrong diagnosis.

Check the promoted skipped-skill case first, since it is identified by
error code rather than by text and is the more specific match. The
credential redaction still applies to everything else.
2026-08-12 13:22:06 -04:00
Danny Avila
ae24461146
🚣‍♀️ feat: Smooth Streaming Text Fade-In (#14757)
* 🚣‍♀️ feat: Smooth Streaming Text Fade-In

Adds a native FlowToken-style fade-in for streamed message text, with no
changes to the streaming data path: a per-block rehype plugin wraps newly
arrived words in one-shot CSS fade spans, using document-order character
offsets so already-visible text never re-animates, even when markdown
re-parsing restructures the tree. Words still inside their animation
window replay identical props so in-flight fades are never cut short.

- New Content/animate.tsx: word splitting (Intl.Segmenter for CJK),
  offset-based new-text classification, rehype plugin factory, and an
  AnimatedText component for plain-text reasoning content
- MarkdownBlocks: per-block plugin instance appended to the cached
  rehype plugins only while animating; block memoization untouched
  (bench asserts identical code-block render counts)
- Animation gated on isSubmitting && isLatestMessage and dropped at
  stream end, so settled messages render without wrapper spans
- Skips code, pre, math/KaTeX, artifacts, citations, and MCP UI subtrees
- Smooth streaming toggle (default on) under Settings → Chat → Messages;
  animation disabled under prefers-reduced-motion
- Extends the streaming bench with a fade variant (+9% render time in
  jsdom, 62/62 code-block renders) and adds unit tests for the plugin,
  classification, and AnimatedText

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🔧 chore: Fix Import Order in Markdown and Settings Registry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

*  test: Cover Kana-Only CJK Word Segmentation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🛡️ fix: Harden Streaming Fade for Resume, Concurrency, and Reduced Motion

Addresses Codex review on the smooth streaming fade:

- Hydrated/resumed content becomes the animation baseline: a renderer whose
  first run already exceeds FADE_HYDRATION_THRESHOLD (reconnected stream,
  conversation switch) no longer re-fades the entire accumulated response
- Classification is now transactional under React 18 concurrency: runs are
  staged during render and published via commit() from a layout effect, so
  abandoned renders (interruption, StrictMode double-render) leave no trace
- prefers-reduced-motion now disables the rehype transform and AnimatedText
  in the render gate, not just the CSS animation, so reduced-motion users
  skip the span-wrapping work entirely

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🌏 fix: Refine Fade Hydration Signal, Spaceless Scripts, Collapsed Reasoning

Second Codex review round on the smooth streaming fade:

- Hydration is now an explicit signal instead of a per-block length guess:
  Markdown captures whether substantial content already exists at the render
  where its animate gate flips on, and passes it to each block's plugin. New
  blocks mounting later in the stream always animate regardless of size, and
  resumed content becomes the baseline regardless of block sizes
- Word segmentation now covers all spaceless scripts (Thai, Lao, Tibetan,
  Myanmar, Khmer) in addition to CJK/Hangul, so continuously streamed text in
  those scripts keeps fading instead of freezing after the first window
- Reasoning text no longer runs the word transform while the thinking panel
  is collapsed; expanding mid-stream starts from a hydrated baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🖱️ feat: Hide Streaming Cursors While Smooth Fade Is Active

The word fade itself signals streaming, so the trailing block cursor
(result-streaming) and the pulsing thinking dot (result-thinking) are
now suppressed whenever the smooth streaming fade is enabled. Both
return when the setting is off or the device prefers reduced motion.
Extracts the shared gate into a useSmoothStreaming hook consumed by
Markdown, Reasoning, TextPart, DisplayMessage, EmptyText, and the
legacy loading fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

*  test: Pin Cursor Assertions to Fade-Off State

The pulsing thinking cursor now only renders while the smooth streaming
fade is off, so the tests asserting it set the toggle off first (at file
level where earlier renders would cache the atom's first read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 💫 feat: Restore Start-of-Run Dot With Fade-Matched Treatment

The trailing streaming cursor stays hidden while the fade is active (the
fade itself signals text arriving), but the pre-first-token dot returns:
nothing else tells the user the run started before any text exists.

Restyled to match the word fade rather than the classic size throb — it
fades in on the same 250ms ease-out curve, then breathes on opacity, so
"run starting" and "text arriving" read as one system. Applied only when
the fade is active; with the setting off or reduced motion preferred the
dot keeps its original pulseSize behavior, so the cursor assertions in
the existing suites hold unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 13:21:52 -04:00
Danny Avila
ee8c0abe2d
🪝 feat: Execute Agent Plugin Command Hooks (#14755)
* 🪝 feat: Execute Agent Plugin Command Hooks

Implement the missing PluginHookExecutor boundary so deployment plugins'
ai.librechat/hooks/hooks.json documents execute instead of loading inert:

- Command executor runs handlers as child processes outside the API
  process: Claude-shaped JSON payload on stdin, exit 0 + JSON stdout as
  sanitized hook output, exit 2 blocks with stderr as the reason, minimal
  allowlisted environment plus PLUGIN_ROOT/PLUGIN_DATA, abort-signal kill
- Plugin loading carries the parsed hooks document on the contribution and
  threads hookCapabilities from startup, gated on the operator opt-in
  DEPLOYMENT_PLUGIN_HOOKS (off by default: parsed-but-inert with warning)
- Runs register every ready plugin hook onto the per-run HookRegistry after
  internal policy hooks, with once-per-conversation SessionStart dedup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Harden Plugin Hook Execution Boundary

Address CI and Codex/Copilot review findings on #14755:

- Break the agents -> plugins import cycle: the run seam now reads a
  PluginHookSource wired at startup (mirrors the tool-approval registry)
- Tighten plugin ask decisions to deny unless the run has HITL wiring,
  so an un-resumable interrupt can never strand OpenAI-compatible callers
- Scope cross-run dedup keys by authenticated user and handler identity:
  caller-supplied conversation ids cannot collide across principals, and
  sibling SessionStart handlers all fire; once handlers persist across runs
- Replace a literal NUL byte in source with an escape (file diffed binary)
- Kill the whole detached process group on abort, not just the shell
- Map exit 2 on events without a decision channel to preventContinuation
- Reserve PLUGIN_ROOT/PLUGIN_DATA against allowlist overrides, quote
  PowerShell args, cap captured output by bytes with one-pass decoding,
  and serialize payloads inside the executor's error boundary
- Fix import ordering flagged by the static checks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Close Plugin Hook Policy and Namespace Gaps

Address the second Codex review round on #14755:

- Drop updatedInput from plugin command outputs: hooks in one dispatch all
  receive the original arguments, so a plugin rewrite would reach the tool
  without the approval policy re-evaluating it (host-only now)
- Translate Claude tool aliases (Bash/Write/Edit/Read) to LibreChat runtime
  names in matchers, with reverse payload mapping, so Claude-authored guards
  fire instead of planning ready and never matching
- Key once-only state by declaration position as well as handler contents,
  so sibling declarations with identical handlers stay independent
- Thread sessionStartSource through createRun and mark the HITL resume
  rebuild as 'resume', so SessionStart matchers see the real lifecycle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Translate Regex-Form Claude Tool Aliases

Address the third Codex review round on #14755: alias translation now
substitutes word-bounded tokens, covering regex matchers like ^Bash$ and
^(Write|Edit)$ that the exact-token pass left registered against Claude
names and silently never firing. A regex whose alias sits inside a
character class or escape is rejected as unmapped so it fails loudly at
plan time instead of never running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Scope Alias Translation and Reuse Load-Time Plans

Address the fourth Codex review round on #14755:

- Add the WebSearch -> web_search alias so Claude-authored web-search
  guards fire against the LibreChat built-in
- Apply alias translation only to tool-name events; a StopFailure matcher
  like ^Bash failed$ stays untouched and keeps matching the error text
- Reuse each plugin's load-time hook plan at run registration instead of
  re-planning up to 512 handlers on every chat turn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Translate Aliased Tool Inputs and Harden Hook Domains

- Present aliased tool inputs under Claude field names (file_path,
  old_string, new_string, including nested edits), so Write/Edit/Read
  guards see the fields they check instead of silently allowing
- Derive the alias table from canonical tool-name definitions
  (BashExecutionToolDefinition, CREATE_FILE_TOOL_NAME, Tools.web_search)
  instead of a parallel hand-authored table
- Reject matchers naming Claude built-ins with no runtime equivalent
  (Task, Glob, Grep, WebFetch, ...) as unmapped at plan time instead of
  registering guards that never fire
- Replace per-event Sets and Stop special-cases with an exhaustive
  EVENT_TRAITS record over HookEvent, so new engine events demand
  explicit semantics at compile time
- Move cross-run once-state behind a PluginHookOnceStore seam with a
  least-recently-marked memory default: active conversations refresh
  their keys each turn, so capacity eviction can no longer re-fire a
  conversation that is still in use; the seam admits a shared-cache
  store for multi-replica deployments
- Gate portable-only command handlers at plan time on Windows via a new
  supportsHandler capability (commandWindows or shell powershell
  required) instead of spawning bash that cannot exist
- Kill Windows hook process trees with taskkill /t on abort
- Require declaration indices on execution requests, stamped from the
  plan instead of defaulted at execution time

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Keep Group SIGKILL Escalation Armed After Wrapper Exit

An aborted hook whose descendant ignores SIGTERM could leak that
descendant: the wrapper shell's exit fired close, which cancelled the
scheduled group SIGKILL. The escalation timer is now never cancelled —
it is unref'd and killTree already tolerates a vanished process group,
so a redundant late sweep is harmless while a surviving descendant is
reliably killed at the grace deadline. killGraceMs is configurable on
CommandExecutorOptions, with a regression test driving a trap-protected
descendant past the wrapper's exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Scope Once Retention by Conversation and Reject Clear Source

- Restructure the once store around conversation scopes: registration
  touches the scope every run, so rarely-matching once handlers keep
  their keys while the conversation is active; eviction removes whole
  idle conversations (capacity counts conversations, not keys)
- Reject SessionStart matchers naming the clear lifecycle source at
  plan time — no LibreChat run-construction path emits clear, so the
  handler would plan ready and never fire; wildcard warning text now
  reflects the sources that actually occur
- Make the SIGKILL-escalation regression test real: the surviving
  descendant redirects its stdio away from the captured pipes so the
  wrapper's close fires while it is still alive, exercising the
  window a close-time cancellation would leak

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Bound Alias Tokens by Tool-Name Characters and Host Shells

- Translate Claude aliases (and reject unsupported built-ins) only when
  delimited by characters that cannot appear in a runtime tool name:
  action tool names preserve hyphens, so an alias embedded in a longer
  name like deploy-Bash-v2_action_example_com stays the literal tool
  name instead of being rewritten into a matcher that never fires
- Reject PowerShell-only command handlers on POSIX hosts at plan time
  (and skip them at runtime): bash cannot run PowerShell syntax, so the
  guard would fail open; a handler with both variants still runs its
  portable command
- Handle rejected asynchronous once-store calls: a failed touch logs
  instead of raising an unhandled rejection during run construction,
  and a failed markOnce lookup fails open per the store's documented
  over-fire direction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Probe Group Liveness Before Cancelled or Delivered SIGKILL

The never-cancelled escalation timer could signal a recycled
process-group id when an aborted hook's whole tree exits early in the
grace window. Escalation now probes the group with signal 0: close
cancels the timer only when the group is verifiably empty, and the
deadline re-probes before delivering the group SIGKILL, so surviving
descendants are still reaped while a fully-dead group never receives a
blind late signal. The residual probe-to-signal race is documented as
irreducible without pidfd support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Gate Windows Escalation on Root-Process Liveness

Windows taskkill /t walks the tree from the root process, so once Node
observes the root's exit an escalation pass can reap nothing and a late
forced taskkill could only hit a recycled PID. The liveness gate is now
platform-aware in one helper: POSIX probes the process group with
signal 0, Windows checks the root's observed exit state, and both the
close-time cancellation and the deadline delivery consult it — no
platform retains a blind late signal. Orphaned SIGTERM-ignoring
descendants on Windows are documented as the platform limitation they
are without Job Objects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Scope Payload Namespace to Declarations and Reap Stray Workers

- Reverse name/input translation now applies only to declarations whose
  matcher actually required Claude-alias translation: the plan records
  requiresToolNameTranslation per entry, so a native-authored matcher
  like ^create_file$ receives native tool names and fields instead of
  Claude-shaped payloads its guard never expected
- Coordinate the two dedup layers via a shouldExecute gate on the
  executor: a declaration suppressed by spent once-state declines
  before claiming the per-input dedup slot, so an identical handler
  under an overlapping matcher can still claim it and fire its own
  independent once-key instead of being permanently shadowed
- Reap process groups that outlive a successful hook: a backgrounded
  worker left running after normal wrapper exit gets the same
  term-then-escalate sequence an abort uses, since unsupported async
  handlers mean no lifecycle owns such processes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🧰 chore: Vendor Pocock Codebase-Design and Architecture Skills

Adds mattpocock/skills engineering/codebase-design and
engineering/improve-codebase-architecture (MIT, license included) under
.claude/skills so future sessions share the deep-module vocabulary
(module, interface, depth, seam, adapter, leverage, locality) and the
architecture-review process. Force-added past the /.claude/ gitignore
deliberately; relocate if project skills should live elsewhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 refactor: Extract Process-Tree Reaping Into a Reaper Module

Tree lifecycle — five of the last seven review findings — lived as
event-handler wiring inside runCommand with its invariants in comments.
It now sits behind a two-method seam: createReaper(child, graceMs)
exposes reap() and onClose(), hiding the term-grace-escalate state
machine, the per-platform liveness gates, the recycled-id guards, and
the clean-exit sweep. The executor shrinks to capture-and-parse, and
the reaper is unit-tested directly with real process trees through its
own interface instead of only via whole-executor integration runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Scope Translation Per Alternative and Sweep at Root Exit

- Track which runtime tool names alias translation produced, so a
  mixed-namespace matcher like Bash|create_file presents Claude-shaped
  payloads only for bash_tool invocations while the natively-authored
  create_file alternative keeps native names and fields; a capability
  omitting the produced-names list keeps declaration-wide translation
- Sweep the process tree at root exit as well as close: a backgrounded
  descendant holding the captured pipes delays close until it dies, so
  the exit-time sweep terminates it promptly instead of stalling the
  hook until its timeout aborts
- Pass the primary agent's resolved model and identity into the plugin
  hook context, so SessionStart payloads carry model and agent_type
  instead of always omitting them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Default Wildcard Declarations to the Document Namespace

- Matcherless (or wildcard) tool-payload declarations now inherit the
  hook document's Claude namespace: with no alternatives to carry
  namespace evidence, the plan marks them for declaration-wide reverse
  translation, so a wildcard guard inspecting standard Claude names and
  fields sees Write/file_path instead of silently failing open on
  native payloads; PostToolBatch entries translate the same way
- Recognize aliases delimited by regex metacharacters: dots leave the
  tool-name boundary class (runtime names never contain them — action
  ids underscore domain dots), so ^Bash.*$ translates to ^bash_tool.*$
  instead of registering a guard that never fires
- Expand Claude's ${CLAUDE_PLUGIN_ROOT} spelling in hook commands and
  export it in the child environment alongside PLUGIN_ROOT
- Scope SessionStart once-keys by lifecycle source, so a startup firing
  no longer suppresses the conversation's resume rebuild

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Normalize Claude Structured Hook Output

Stock Claude hooks return decisions under hookSpecificOutput
(permissionDecision/permissionDecisionReason), surface context there,
and use continue:false plus the legacy approve/block decisions — none
of which the sanitizer's native field names recognized, so a guard that
works in Claude silently allowed in LibreChat. Parsed JSON now passes
through a dialect normalizer first: hookSpecificOutput fields map to
decision/reason/additionalContext, continue:false becomes
preventContinuation, approve becomes allow, and block becomes deny on
events that block by denying. Native fields win when both dialects
appear, and the ask-to-deny gate applies to the Claude dialect too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Validate Native Decisions and Slim Once Keys

- Strip malformed native output fields before the dialect merge, so a
  placeholder like {"decision":null} can no longer suppress a valid
  Claude permissionDecision into a silent allow; only recognized
  decision tokens take precedence
- Preserve the caller's working directory in hook payloads: cwd now
  reports the run's session context instead of the plugin installation
  path, which commands already receive as PLUGIN_ROOT and which the
  executor still uses as each process's working directory
- Store a compact sha256 digest instead of the full serialized handler
  in once keys: declarations may carry 32 KB commands and 256 args, and
  the previous key embedded them in every retained conversation scope

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

* 🪝 fix: Validate Decisions Per Event Channel and Control Post-Tool Blocks

- Accept native decision tokens only from the target event's own
  vocabulary: "continue" is valid on Stop but malformed on a tool
  event, where it previously survived validation, blocked the Claude
  dialect merge, and was then dropped by sanitization into a silent
  allow
- Translate a structured "block" on events with no deny channel
  (PostToolUse, PostToolUseFailure, and the other prevent-trait events)
  into preventContinuation with the block reason as stopReason, instead
  of discarding it and returning a reason that controls nothing
- Document why LibreChat runs supply no payload cwd: tool paths address
  a remote code-execution sandbox rather than the API host where hook
  commands run, so no host directory describes the run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWXQZD2WzeAsvee4eRdWWc

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 13:21:15 -04:00
Bas
db43121073
⌨️ fix: allow file upload shortcut when editing (#14764)
Co-authored-by: Bas Schleijpen <bas.schleijpen@surf.nl>
2026-08-12 16:18:57 +02:00
Danny Avila
b2128a7d18
📡 fix: Preserve Redis Abort Terminal Delivery (#14749)
* fix(stream): preserve terminal delivery after Redis fences

* fix(stream): cover Redis abort acknowledgment window

* chore: sort stream timing imports

* fix(stream): follow durable replacement handoffs

* fix(stream): bound replacement handoff retirement

* test(stream): cover handoff deadline drain

* test(stream): cover fenced steer retirement grace

* fix(stream): scope fenced retirement lifecycle

* fix(stream): retire fenced subscribers safely

* test(stream): settle subscription fixtures
2026-08-12 07:33:07 -04:00
Danny Avila
88e08c91e8
🧷 fix: Preserve Elicitation Answers Across Aborts (#14745)
* fix: preserve elicitation answers across aborts

* chore: sort stream type imports

* fix: guard malformed resolved answers

* fix: close abort answer race gaps

* fix: retain exact answers across pauses

* fix: retain answers across resumed pauses

* test: satisfy HITL fixture types

* fix: retain legacy answers through approvals

* fix: preserve answers in reconnect snapshots

* test: type legacy answer fixture

* fix: bind legacy answers to paused content

* test: guard optional resume content

* fix: resume questions without streamed content

* fix: Preserve legacy answers through abort filtering

* test: Narrow reconstructed abort fixture

* test: Type abort transform fixture explicitly

* fix: Isolate answers with missing ask content

* test: Type missing-content ask fixture
2026-08-12 07:32:48 -04:00
Danny Avila
236ee6c1ab
🧭 fix: Re-Anchor Parent Activity Phase Bounds (#14741)
* test: cover parent activity phase finalization

* test(e2e): stabilize parent phase coverage

* fix(agents): reanchor parent activity phase bounds

* fix(agents): preserve delayed tools in activity phases

* test(agents): keep phase slice bounds typed

* fix(agents): preserve sparse activity phase bounds

* test(e2e): read structured phase replies
2026-08-11 10:16:57 -04:00
Danny Avila
ba29a6c5d6
🔐 fix: Preserve Plugin MCP Provenance Through Registry Storage (#14744)
* 🔐 fix: Preserve Plugin MCP Provenance Through Registry Storage

Agent Plugins MCP servers are tagged `source: 'plugin'` by the plugin
loader so `processMCPEnv` returns them verbatim, keeping any `${VAR}` a
plugin declared literal. The registry derived `source` from the storage
tier alone, so every startup server routed through
`addServer(..., 'CACHE')` was retagged `'yaml'` — before inspection and
again before persistence.

That dropped the marker for deployment plugin servers merged into the
startup MCP config, so `processMCPEnv` treated plugin-authored strings as
operator-authored templates and expanded them from `process.env`. A
malicious plugin declaring `Authorization: Bearer ${OPENAI_API_KEY}`
received the host's key at its own endpoint, at both boot-time inspection
and every runtime connection.

`resolveServerSource` now carries an existing plugin marker through
instead of re-deriving it, and is applied at all four tag sites
(`addServer`, `addServerStub`, `inspectServerUpdate`, and config-tier
lazy init, which hardcoded `'config'` and would have re-opened the same
hole). The marker is only honored for operator-loaded tiers: a DB entry
is user-authored and stays `'user'`, so user input cannot claim plugin
provenance to escape the sandboxed placeholder rules.

* 🔒 fix: Address Codex review — config-override provenance & upgrade re-tag

Two follow-ups from the Codex review of the provenance fix, plus a test
cleanup.

P2 — a Config-tier override that shadows a same-name plugin base inherited
the base's `source: 'plugin'` through the merge in `getServerConfig` /
`getAllServerConfigs`, so `processMCPEnv` stopped resolving the operator's
own `${VAR}` placeholders and silently broke their server. New
`overlaySource` helper keeps an operator override on its own trusted
source when the base is plugin-sourced; all other bases still inherit as
before. Fails safe (never a leak), but the regression is real.

P1 — the init fingerprint hashes only the raw MCP config, which already
carried `source: 'plugin'` before the provenance fix, so the hash is
unchanged by it. On a Redis-backed rolling restart with no config change,
followers short-circuit on the stale `INITIALIZED_CONFIG_HASH` and the
old `source: 'yaml'` plugin entries survive with no expiry — the fix
never takes effect. Fold a `REGISTRY_STORAGE_SCHEMA_VERSION` into the
fingerprint so an upgrade forces exactly one cluster-wide re-init.

Also drop unnecessary `as` casts in the provenance tests (declare the
fixture as `ParsedServerConfig`, assert with `toMatchObject`) and add a
regression test for the P2 override case.
2026-08-11 08:55:07 -04:00
Danny Avila
e108955c20
🧷 ci: Enforce Durable Agent Finalization for E2E tests (#14740)
* test: enforce agent generation finalization

* test(e2e): correlate canonical persisted turns
2026-08-11 08:27:50 -04:00
Danny Avila
01e9d119bf
🛻 ci: Move the ESLint Config Sweep Into Its Own Job (#14742)
The full-sweep regression gate lints api+client+packages twice — once under
the PR's config and once under the base ref's — inside the same job as ~20
later steps (data-provider/data-schemas/api builds, config migration tests,
unused-i18n scan, and four depcheck passes), all sharing one 30-minute budget.

Two type-aware sweeps of the whole tree cost more than everything else in that
job combined. When they run long the job hits its timeout mid-sweep, so every
step behind the gate never executes and Static checks reports no result at all
— strictly worse than not running the gate. continue-on-error: true hides this,
because the step never fails; it simply never finishes.

Move the gate to its own job with its own budget so it cannot starve the other
checks, and bound each sweep so an over-budget run degrades to a notice rather
than a failure — an unfinished sweep is no evidence of a regression, and the
gate is advisory about config scope. Behaviour on a sweep that completes is
unchanged: coverage loss and new (file, rule, severity) diagnostics still fail.
2026-08-11 08:26:42 -04:00
Danny Avila
ea6f9e3f4f
🌐 chore: Restore English Localization Source Values (#14743) 2026-08-11 01:22:14 -04:00
Danny Avila
60fe67d27e
🌍 i18n: Update translation.json with latest translations (#14739)
* 🌍 i18n: Update translation.json with latest translations

* fix: restore stable English translation labels
2026-08-11 01:11:56 -04:00
Danny Avila
7347cfc195
🍡 feat: Batched User Questions With A Single Bounded Answer Form (#14737)
* feat: support batched user questions

* test: align batched question fixtures

* fix: harden batched question lifecycle

* test: submit batched HITL answers in e2e

* fix: address batched question review findings

* fix: preserve invoke return typing
2026-08-11 01:06:16 -04:00
Danny Avila
d89b11d34d
🎛️ feat: Adopt Composer Density Tokens (#14730) 2026-08-10 22:57:33 -04:00
Danny Avila
c93609cb82
📸 fix: Guard Screenshot Export Against Main-Thread Freezes (#14733)
* 📸 fix: Guard Screenshot Export Against Main-Thread Freezes

* 🧪 test: Cover Conversation Export Flows End-to-End

* 🧪 test: Stabilize Export Spec CSV and Toast Assertions
2026-08-10 22:47:50 -04:00
Marco Beretta
f7d9f36922
🎨 feat: Refine Client Colors and Sharing Dialogs (#14734)
* Refine client colors and settings interactions

* Align dark dialog theme tokens

* Preserve custom hover themes and badge contrast

* feat: redesign sharing dialogs

* fix: preserve theme compatibility and role menus

* fix: address review findings and static checks
2026-08-10 22:43:57 -04:00
Danny Avila
dfd4d9dd81
🧩 ci: Close Workflow Path-Filter Gaps (#14728)
* 🧩 ci: Close Workflow Path-Filter Gaps

Six trigger-filter gaps found by reading all workflows against the live
dependency graph (AI-1755, codegraph FINDINGS §6l):

- backend-review/frontend-review: root package.json/package-lock.json now
  trigger unit tests — a lockfile-only dependency bump previously ran zero
  backend or frontend unit tests while every test job installs from it
- agents-integration-tests: widen to the three package src trees it builds
  and imports (was only src/agents/**)
- cache-integration-tests: same shape — verified live that
  packages/api/src/flow/manager.ts (imported by mcp/oauth) matched neither
  integration filter
- docker-smoke: plain Dockerfile had no PR-time validation despite shipping
  via dev-images/tag-images; new node-image-smoke job builds it, gated by
  paths-filter to Dockerfile/.dockerignore changes
- dev-images/dev-branch-images: add config/**, skill/**, .dockerignore —
  the single-stage image COPYs the full build context
- static-checks: eslint.config.mjs now re-triggers the lint job (gap carried
  over from eslint-ci.yml in the #14716 consolidation)
- delete generate_embeddings.yml: fired on docs/**, which no longer exists,
  and its docs-root-path pointed at the same missing directory

* 🧩 ci: Address Codex Review Findings

- Build caches: all 26 build-* keys across 8 workflows now lead with
  root package.json + package-lock.json so manifest-only bumps cannot
  restore stale dists (data-provider embeds the root version); unifies
  the split key families (playwright already hashed the lockfile)
- static-checks: config changes now gate on the ESLint config loading
  and applying to representative files, plus a report-only full-tree
  sweep (70 pre-existing errors at dev HEAD block a hard gate for now)
- docker-smoke: the workflow file itself triggers the plain-Dockerfile
  build so job edits are validated
- dev-images/dev-branch-images: re-include skill/**/*.md after !**.md
  so shipped deployment-skill Markdown rebuilds images

* 🧩 ci: Gate Config Lint Sweep on Regression vs Base Config

Second-round codex finding: the report-only sweep swallowed config-wide
breakage in scoped blocks the representative files don't exercise. The
sweep now lints the same tree under the PR's config and the base ref's
config and fails only when the PR's config produces more diagnostics for
some (file, rule) pair — pre-existing debt never fails the gate, and
fixes are never penalized. Base-config unavailability degrades to the
load gate with a notice. Outcome surfaced in the failure summary.

* 🧩 ci: Harden Config Lint Gate per External Review

- Coverage direction: fail when the PR config stops linting files the
  base config covered (set difference on linted files) — a mis-scoped
  ignores previously only removed diagnostics and passed both gates
- Severity-aware fingerprints: (file, rule, severity) so warn->error
  escalations gate on a clean tree for that rule; downgrades still free
- Hard-fail when the base commit is missing so a future shallow-checkout
  change cannot silently disable the gate; annotate fetch-depth: 0
- Tab-separated fingerprint keys (space-in-path proof), --config on
  both sweeps, EXIT trap for the base config copy, comment on why it
  must live at the repo root (flat-config pattern base paths)
- Narrow skill md re-include with !skill/README.md: top-level README is
  documentation-only; runtime skill Markdown still rebuilds images
2026-08-10 17:26:50 -04:00
Danny Avila
9bb599435f
📎 fix: Re-enable Send After File Upload (#14727)
* fix: enable composer send after file upload

* style: sort composer imports
2026-08-10 15:05:22 -04:00
Danny Avila
09cbd54f48
🪆 fix: Rebase Activity Phase Bounds over Sparse Content (#14729)
The aggregator writes content parts at provider-source indexes, which can
skip slots and leave holes in contentParts. Array.prototype.map preserves
those holes and the Map constructor iterates them as undefined, so
rebaseActivityPhaseBounds threw "Iterator value undefined is not an entry
object" at the end of every run with sparse content — deterministic with
parent phase summaries enabled, on both the completion and resume paths.

Build the identity map with an index loop that skips nullish slots. Holes
must stay out of the map: one undefined key would falsely match every hole
in previousParts as a retained part and corrupt the rebased bound.
2026-08-10 15:05:10 -04:00
Danny Avila
a3cec67e08
🪆 feat: Add Parent Activity Phase Summaries (#14721)
* feat: add activity phase summaries

* fix: preserve activity phase lifecycle semantics

* fix: satisfy activity phase type checks

* fix: simplify activity phase status mapping

* style: format activity phase changes

* fix: rebase activity phase bounds after shaping

* fix: link activity phase trace ancestry

* fix: reconcile activity phase bounds

* style: format activity phase reconciliation test

* style: align activity phase assertion

* fix: retain reasoning across commentary

* fix: preserve activity phase boundary state

* fix: detect renderable phase children

* test: type parallel phase assertion

* chore: bump agents SDK for activity phases

* fix: retain unphased lane reasoning

* fix: preserve tool group expansion across phases

* style: format phase expansion regression

* fix: preserve phase interaction state efficiently

* perf: skip sparse phase segment holes

* perf: partition phase segments with offsets

* fix: preserve phase boundaries and cursor state

* test: align activity phase regressions with CI

* test: keep phase context mock hoist-safe
2026-08-10 13:41:37 -04:00
Danny Avila
c3a429ddcd
🎨 feat: Add Versioned Theme Foundation (#14709)
* 🎨 feat: Add Versioned Theme Foundation

* 🧩 fix: Keep Theme-Aware Chip Actions Consistent

* 🎛️ fix: Preserve Default Theme Geometry

* 🪪 fix: Keep Theme Identity in Sync

* 🧭 docs: Define Theme Styling Policy

* 🧹 chore: Sort Theme Imports

* 🧵 fix: Preserve Theme Compatibility Contracts

* 🛡️ fix: Harden Theme Compatibility Boundaries

* 🧵 fix: Publish Theme Appearance Preset

* 🐳 fix: Include Theme Preset in Docker Build

* 🪢 fix: Preserve Legacy Theme Compatibility

* 🧭 fix: Harden Theme Lifecycle Boundaries

* 🧱 fix: Align Theme Appearance Defaults

* 🧬 fix: Record Persisted Theme Provenance

* 🧭 fix: Preserve Theme Transition State

* 🧷 fix: Preserve Legacy Theme Contracts
2026-08-10 13:41:03 -04:00
Danny Avila
7fc62023eb
🧷 fix: Safely Recover Runtime MCP OAuth Rejections (#14684)
* fix runtime MCP OAuth recovery

* style: sort LC-008 imports

* fix: single-flight runtime OAuth handlers

* fix: retain transport OAuth failures for recovery

* fix(mcp): preserve OAuth recovery connections

* test(mcp): type request-scoped config fixture

* fix(mcp): harden shared OAuth recovery

* fix(mcp): bound OAuth recovery escalation

* style(mcp): sort OAuth integration imports

* fix(mcp): harden OAuth recovery boundaries

* fix(mcp): abort shared recovery waiters

* fix(mcp): bound request OAuth recovery phases

* fix(mcp): close OAuth recovery ownership gaps

* fix(mcp): retry borrowers closed by OAuth recovery

* fix(mcp): drain borrowers before OAuth reconnect

* fix(mcp): preserve eviction across OAuth recovery

* fix(mcp): unify OAuth recovery leases

* fix(mcp): serialize cache reuse with recovery

* fix(mcp): make recovery checkout atomic

* test(mcp): use numeric config timestamp

* fix(mcp): reacquire recovery checkouts

* fix(mcp): retain shared recovery disposal

* fix(mcp): restart checkout after recovery takeover

* fix(mcp): close recovery lifecycle gaps

* refactor(mcp): deepen OAuth recovery lifecycle

* fix(mcp): harden OAuth lifecycle disposal

* style(mcp): sort OAuth lifecycle imports

* fix: lease MCP OAuth lifecycle edges

* fix(mcp): isolate shared OAuth flows from aborts

---------

Co-authored-by: Dennis Schenk <dennis@gridonic.ch>
2026-08-10 10:38:34 -04:00
Oliver Faust
26bcbb713c
📁 fix: Consistent Export Filenames Across Formats (#14708)
* 📁 fix: Consistent Export Filenames Across Formats

Conversation exports produced different filenames per format: txt/md/csv go
through export-from-json, whose default formatter replaces only the first
whitespace run (non-global regex), while png/json downloads keep spaces.
Normalize the filename once before export so every format yields
Word1_Word2_Word3.ext; the preset JSON export shared the same defect.

* 🧹 chore: fix import order via sort-imports
2026-08-10 10:36:41 -04:00
Dustin Healy
87a8b9aa12
📡 feat: Route Web Search and Scrape Egress through the SSRF-safe Agent (#14606)
* feat(web-search): route outbound search and scrape requests through the SSRF-safe agent

Build the SSRF-safe agents at the web-search tool-assembly site and pass them into the
search tool config so outbound search and scrape connections are validated at connect
time against their resolved IP, on every hop including redirects, consistent with the
other outbound clients.

Add allowedAddresses to webSearchSchema, reusing allowedAddressesSchema, so self-hosters
can permit a deliberately-private search or scrape endpoint (for example a private
SearXNG instance). The field is resolved directly from the webSearch config at the
createSearchTool call site, not through loadWebSearchAuth, because it is config and not
an auth credential. webSearchSchema is flat (providers are chosen by enums, not by
counting keys), so the field is inert with respect to provider selection.

Document the field and its operator warning in librechat.example.yaml, and assert the
wiring in handleTools.test.js: the SSRF-safe agents are threaded into the search tool
config, allowedAddresses is passed through when set, and omitting it still threads the
agents with no exemptions.

TODO awaits @librechat/agents release with the httpAgent hook: this consumes optional
httpAgent/httpsAgent fields on the search-tool config that are not yet in a published
@librechat/agents. package.json is intentionally left at the current version; bump it to
the release that ships the hook before this lands. Validated locally against a revendored
@librechat/agents build, not a published release.

* 🛡️ fix: Apply allowedAddresses to the Web Search SSRF Preflight

The connect-time SSRF agent already honors webSearch.allowedAddresses, but
loadWebSearchAuth ran the isSSRFUrl preflight without it, so an admin-permitted
private search or scrape URL was stripped before the agent could ever use it.

Thread allowedAddresses and the URL's effective port through isSSRFTarget and
resolveHostnameSSRF so the exemption is consistent across both SSRF layers.

* 🛡️ fix: Validate Web Search Destinations and Defer to Configured Proxies

Handing agents to createSearchTool covered only the connect-time DNS lookup,
which Node skips for IP-literal hosts, and a configured proxy connects on our
behalf without running that check. A literal private target such as
http://169.254.169.254 could therefore reach the network.

Route every resolved web-search destination through the existing
applySSRFSafeAgentIfDirect contract so a blocked literal target throws before
any request is made, and withhold the agents when a proxy owns egress, since one
agent pair is shared by every provider and a direct-connect agent on a proxied
connection would break the request while asserting protection the proxy's
network context cannot provide.

* 🛡️ fix: Keep Web Search SSRF Agents Under a Proxy and Restore Pooling

Withholding the agents whenever a proxy was configured removed protection from
every direct and NO_PROXY destination in exchange for preventing a failure that
cannot occur: for an https target Axios substitutes its own CONNECT tunnel, so
the injected agent is never used for the proxy connection. Only a plaintext http
target keeps our agent and repoints it at the proxy, and only a proxy whose
hostname resolves private then trips the connect-time check.

Always pass the agents and exempt the proxy endpoint instead, deriving host:port
from the same PROXY, HTTP_PROXY, and HTTPS_PROXY resolution the rest of LibreChat
uses so the proxy hop stays reachable while destinations remain guarded. Axios
already applies NO_PROXY per request, so bypassed routes keep enforcement with no
extra logic.

Drop the load-time destination validation. It duplicated the isSSRFTarget
preflight for user-provided URLs, rejected admin values that were previously
legal, and threw from inside loadTools, where both loader wrappers swallow the
error and drop every tool for the turn rather than degrading web search alone.

Build the agents with keepAlive and cache them per exemption list. A bare
http.Agent does not pool, so the previous code replaced the pooled global agents
for every search, scrape, and rerank call and allocated a fresh pair per turn.

* 🛡️ fix: Reject IP-Literal Private Targets on Web Search Connections

Node resolves nothing for a literal host, so the connect-time lookup never saw
one: a destination or a redirect target given as http://169.254.169.254 reached
the network. Redirect hops pass through the same createConnection, so checking the
literal there covers both cases and removes the need for a maxRedirects control
that createSearchTool cannot accept.

Gate it behind blockLiteralHosts so only web search opts in. A caller that
reaches a proxy or a deliberate private service by literal address must exempt it
first, and the merged consumers of createSSRFSafeAgents have no such exemption, so
enabling this everywhere would break configurations that work today.

* 🛡️ fix: Keep IPv6 Brackets on Derived Proxy Exemptions

The exemption parser accepts an IPv6 entry only as [ipv6]:port, so stripping the
brackets produced fd00::1:3128, which carries three colons and is dropped as
malformed. An IPv6 proxy therefore stayed unexempted and the connect-time check
rejected it, failing every web-search request routed through it.

Use the URL hostname as parsed, which already carries the brackets.

* 🛡️ fix: Exempt Proxies Configured Through ALL_PROXY

Axios resolves a proxy through proxy-from-env, which falls back to all_proxy in
either case after <protocol>_proxy, so ALL_PROXY on its own is enough to route a
request through a proxy. Exemptions were derived from PROXY, HTTP_PROXY, and
HTTPS_PROXY only, leaving such a proxy unexempted and rejected with ESSRF.

Derive the exemptions from the full set of variables that can put a proxy in
front of these requests instead. The installed proxy-from-env 2.1.0 reads no
npm_config variables, so those are deliberately not included.

* 🛡️ fix: Drop the Unearned PROXY Exemption and Harden the Web Search Guard

Nothing on this path consumes PROXY: Axios resolves proxies through
proxy-from-env, which reads only <protocol>_proxy and all_proxy, and web search
never calls applyAxiosProxyConfig. Exempting it therefore granted a bypass rather
than preserving a working route, and a user-settable search URL that redirects to
that address reached it and returned the body. Remove PROXY and proxy, and skip a
socks endpoint for the same reason, since Axios cannot proxy through one.

Tolerate a non-array allowedAddresses instead of spreading it, which threw out of
loadTools and dropped every tool for the turn. The YAML path is schema-validated
but the admin override path merges without parsing, so the value is reachable.

Separate cache keys with NUL rather than a newline, so an entry containing a
newline cannot collide with two separate entries, and bound the cache. Give the
agents the idle timeout the global agents carry, which keepAlive alone did not
restore. Reject a unix socket, which carries no host to validate. Also treat
fec0::/10 site-local as private, matching the fe80::/10 handling beside it.

Exercise the real resolver in handleTools.test.js rather than mocking it, so the
wiring test now fails if the agents it threads do not actually block a private
target.

* 🛡️ fix: Derive Proxy Exemptions Through Axios's Own Resolver

Unioning every populated proxy variable exempted addresses that never carry a
request. proxy-from-env picks a protocol-specific variable before all_proxy and
lowercase before uppercase, so an ignored value became a trusted host:port that a
redirect onto a direct route could reach. It also normalizes a scheme-less value
such as proxy.internal:3128 to an http URL, where parsing the raw string yielded
an empty hostname and no exemption at all, breaking the proxy hop.

Resolve through getProxyForUrl, the entry point Axios itself calls, so precedence,
scheme normalization, and NO_PROXY match exactly and cannot drift. NO_PROXY
covering everything now yields no exemption, since nothing is proxied. Declared
locally rather than adding a types package, alongside the existing declaration in
the same directory.

Also revert the fec0::/10 site-local change. domain.spec asserts that boundary
deliberately to prove the fe80::/10 mask does not over-reach, and the shared
address schema still classifies fec0 as public, so a runtime block there would
leave operators unable to configure the exemption. It belongs with those two
together, not in this PR.

* 🛡️ fix: Resolve Proxy Exemptions Against the Real Destinations

Resolving against placeholder probe hosts applied destination-specific NO_PROXY
rules to a host nobody dials. With NO_PROXY matching the probe domain but not a
real provider, no exemption was derived even though Axios still proxied the actual
request, so the agent rejected the private proxy hop with ESSRF.

Resolve per configured destination instead, passing the values loadWebSearchAuth
already resolved. Only plaintext http destinations are considered, since for an
https destination Axios substitutes its own CONNECT tunnel and never uses the
injected agent for the proxy connection, which is also why provider defaults need
no exemption: every one of them is https.

* 🛡️ fix: Accept Embedded-IPv4 IPv6 Forms in the Address Exemption Schema

The runtime guard blocks 6to4, NAT64, and Teredo addresses whose embedded IPv4 is
private, but the schema's local copy recognized only ULA, link-local, and the
dotted IPv4-mapped form, so an entry such as [64:ff9b::a00:1]:8080 was dropped as
a public literal. An operator reaching a private endpoint that way could not
configure the exemption at all.

Mirror hasPrivateEmbeddedIPv4 in the schema helper, which the surrounding comment
already asks to keep in sync. Public embedded addresses stay rejected, since an
exemption there has no defensive purpose.
2026-08-10 10:33:54 -04:00
Ravi Kumar L
7cf4c3f73f
🧪 test(e2e): add Bombadil property exploration (#14462)
* test(e2e): add Bombadil property exploration

* fix(e2e): address Bombadil review feedback
2026-08-10 02:11:06 +02:00
Danny Avila
54d7f04d71
🪶 feat: Resolve Explicit Subagents Lazily (#14714)
* feat: resolve explicit subagents lazily

* fix: satisfy lazy subagent type checks

* test: persist lazy subagent mutation through model API

* style: format lazy subagent persistence test

* fix: log lazy subagent depth limit failures

* fix: harden lazy subagent resolution

* fix: Yield during lazy cancellation test

* test: Synchronize lazy cancellation setup

* style: Format lazy cancellation test
2026-08-09 19:23:45 -04:00
Danny Avila
6bff5ba148
🧬 fix: Normalize Legacy MCP Null Headers (#14720)
* fix: normalize legacy MCP null headers

* Move TokenExchangeMethodEnum import to a new position
2026-08-09 09:07:29 -04:00
Marco Beretta
8da51562f5
🧜 feat: Open Mermaid Diagrams as Artifacts with SVG/PNG Export (#14713)
* feat: open Mermaid diagrams in the artifact panel with SVG and PNG export

Mermaid diagrams previously rendered inline only, and the artifact panel
routed every artifact through Sandpack even when no bundler was needed.

- Route Mermaid artifacts to a direct renderer in ArtifactTabs, moving the
  Sandpack path into a lazily loaded SandboxArtifactTabs so opening a
  diagram no longer pulls in the bundler chrome or the startup config.
- Add an inline artifact card that opens the diagram in the panel instead
  of rendering the same diagram twice.
- Add SVG and PNG export from both the inline diagram and the panel
  header, with size-capped canvas scaling and background compositing.
- Lazy-load the artifact panel in Presentation and ShareArtifacts.
- Accessibility: label the panel as a dialog on mobile with a focus trap,
  make the mobile resize handle keyboard operable, restore focus to the
  opener on close, and honor prefers-reduced-motion.
- Fix the generated Sandpack wrapper to serialize diagram source instead
  of interpolating it into a template literal.
- Cover the new paths with unit tests and a cross-browser Playwright spec.

* fix: keep Mermaid artifact identity and render state per diagram

Addresses three review findings on the Mermaid artifact panel.

Mermaid fences do not consume a code-block index, so every diagram in a
message received the same `mermaid-${blockIndex}` and therefore the same
Recoil artifact key: expanding one overwrote the other, and both cards
read as selected. Mermaid fences now carry their own index sequence,
seeded per markdown block the same way the code and artifact counters
are, so the id stays stable across streamed tokens.

The panel renderer is keyed by artifact id, so switching directly
between two diagrams can no longer carry the previous render, its
dimensions, or its export payload across the boundary while the new
source debounces. Editing an open diagram still does not remount.

The preview Refresh action drives the Sandpack client, which a Mermaid
preview never populates, so it only covered the panel with a spinner.
It is hidden for Mermaid, which offers its own retry on render failure.

Also drops com_ui_mermaid_export_preparing and com_ui_mermaid_source,
which no longer have call sites, fixing the unused-i18n-keys check.

* fix: bind Mermaid preview and export to the artifact on screen

Three further review findings, all on state outliving what it describes.

The editor reset in ArtifactTabs only lands after commit, so the render
that switched artifacts still passed the previous artifact's editor text
to the freshly keyed renderer, which mounted showing (and exporting) the
diagram just navigated away from. Editor text is now ignored until the
reset catches up. SandboxArtifactTabs carried the same pattern and gets
the same guard.

Switching to the code tab unmounts the preview, but the export payload
survived it, so the toolbar kept exporting a diagram that was no longer
on screen and no longer matched an edited source. The renderer now
withdraws its payload on unmount, and the export action is scoped to the
preview tab.

The diagram canvas mounts only once there is a diagram to show, so the
ResizeObserver ran against a null ref while the placeholder was up and
never saw the real element. Wide diagrams were fitted to the default
700px and clipped in narrower panels. Observation now re-runs when the
canvas appears.

* fix: scope Mermaid artifact ids to the content part

Each content part renders its own markdown tree, so the per-message
Mermaid counter restarts at zero in every part. Diagrams sitting either
side of a tool call therefore both resolved to
`mermaid-artifact-${messageId}-mermaid-0`: one registration overwrote
the other and both cards shared a selection state. The part index the
message context already carries now takes part in the scope.

* fix: keep the Mermaid export menu reachable in fullscreen

The artifact panel gained a fullscreen mode on dev, which re-roots the
panel into the fullscreen element and portals the copy and version
popovers there so they stay visible. The Mermaid export menu portals to
the body, so once these branches met it opened outside the fullscreen
element and rendered invisible. It now takes the same portal target.

* fix: heal Mermaid registrations and cap PNG canvases after rounding

Two findings from the latest review pass.

Closing the panel unmounts Artifacts, whose useArtifacts cleanup wipes
artifactsState while the inline cards stay on screen. The Mermaid card
never observed that, so reopening one card restored only itself and any
other expanded diagram vanished from the version navigator until it was
clicked again. It now subscribes to its own slice and re-registers when
the entry goes missing, matching the self-heal ToolArtifactCard already
documents. The write is a no-op when the entry matches, so it settles.

Rounding each PNG side independently could carry the product back over
the 16.7M pixel budget the scale was picked to satisfy: 3129x50000
resolved to 1025x16374, which is 16,783,350 pixels and enough for a
browser enforcing the area limit to reject toBlob outright. Rounding
down cannot exceed the budget, since the bounding scale is derived from
it.
2026-08-09 09:02:42 -04:00
Marco Beretta
152dcf4721
🔗 feat: Shared Conversation Badge and Stable Share Links (#14712)
* feat: improve shared conversation links

* test: Cover Shared Link Lifecycle

* test: Cover Shared File Snapshots

* fix: address review findings on shared links

Stop double-decoding the conversation search term. Express already decodes
req.query, so the route's extra decodeURIComponent threw URIError on any term
containing a bare percent sign and mangled percent-escape-looking text. The
sidebar already sent the term raw, so this failed there too.

Advance a share's stored target to its branch tail when an update omits one.
Updating from the conversation list could not resolve the tail and reused the
stored target verbatim, silently republishing the same snapshot instead of the
turns added since.

Require revalidation on shared files. Updates now keep the shareId, so the file
URL no longer changes and a cached response could outlive a revoked share-files
choice; an ETag over the pinned snapshot fields keeps unchanged files on 304.

* fix: keep the shared badge across conversation cache replacements

isShared is derived per list request and absent from single-conversation
payloads, so rename, pin, and the SSE conversation updates dropped it when they
swapped a server response into the sidebar cache, hiding the badge until an
unrelated list refetch. Carry the cached value forward in updateConvoInAllQueries
so every replacing caller is covered, while an explicit value still wins.

* test: mock syncStaticTools in server boot specs

initializeMCPs now calls syncStaticTools when no MCP servers are configured, but the server boot specs stub ~/server/services/Config without it. Post-listen initialization threw, hit process.exit(1), and took the jest worker down until it exceeded the retry limit.

* fix: address codex findings on the shared DataTable and file ETag

Keep the published DataTable export bound to the legacy component and ship the design-system table as VirtualizedDataTable, so external consumers of @librechat/client keep the props they compile against.

Fold the snapshot's stored location into the shared-file ETag, so a re-published output that keeps its size and revision but moves its object no longer revalidates to a stale 304.

Auto-fill the table when a first page is too short to overflow its container, since pagination is otherwise only reachable through the scroll handler.

* fix: re-scope share grants before publishing and retry stalled auto-fill

Move the shared-link ACL expiration write ahead of the content update. The shareId survives an update, so a failed ACL write after the write-through left the new messages and file snapshot readable at the same URL while the owner saw a 500.

Retry a rejected auto-fill fetch up to three times: an unscrollable table has no scroll event to fall back on, and the sentinel alone would strand it on the first page.

* fix: follow regenerated branches and pin forks to the payload they saw

advanceTargetToBranchTail only walked descendants, so a target replaced by a regeneration (a sibling, not a child) left the update parked on the obsolete branch and published none of the turns that followed. A childless target now hops once to the newest sibling the conversation continued under.

A shareId survives an update, so an owner republishing between a viewer's load and their Continue click would resolve targetMessageIndex against different messages. The fork request now carries the payload's updatedAt and is rejected with 409 when it no longer matches; the viewer gets the current version pulled in and can retry.

* fix: keep table sorting and legacy backfills from breaking share flows

Restore the union formatting a local lint-staged prettier collapsed in data-provider types, which broke the CI lint run.

Header clicks now toggle direction instead of cycling through an unsorted state, which the controlled tables translated straight back into the default and made one direction unreachable.

Re-arm the auto-fill guard on a sort change: a re-sorted first page arrives with the same row count, and the guard would otherwise suppress paging on a container that still cannot scroll.

Lazy fileSnapshots backfills no longer touch updatedAt. That timestamp is the revision a viewer's fork is validated against, so a legacy share's first read would have made the Continue click that followed it fail with a 409.

* fix: break pagination ties by id and reset share state per conversation

Both list cursors marked a page boundary with values that repeat: conversations by (sort field, updatedAt) and shared links by the sort field alone. Imported chats share a title and a timestamp, so every row tied with the boundary was skipped. Both now carry the boundary row's _id and sort by it last, and the shared-links cursor is an opaque composite the route still validates before querying.

The share dialog outlives a switch between conversations, so a link with files disabled left the next conversation's dialog showing the switch off and quietly published without files. The stored choice now falls back to the enabled default, and a stale link no longer sits in the copy field.

* fix: keep titleless shared links in the paginated list

A share has no title default, and BSON orders a missing title before every string, so encoding the boundary as an empty string skipped the remaining untitled links when sorting Name ascending and re-admitted all of them descending.

The cursor now carries the boundary's null rather than flattening it, and because $lt and $gt are type-bracketed against a string, descending adds an explicit clause for the untitled tail that a string comparison can never reach.

* style: sort share method imports

* fix: fail closed on orphaned share targets and guard snapshot backfills

getMessagesUpToTarget walked levels from the roots and returned everything it had accumulated when the target was never reached. An imported or partially deleted branch whose parent is missing therefore published the whole conversation instead of the selected branch; the walk now returns nothing unless it actually reaches the target.

A lazy backfill wrote fileSnapshots unconditionally, so a viewer's first read of a legacy link could land after a republish and restore the snapshot it replaced, re-authorizing the stable URL of a file the owner had just removed. The write is now conditional on the link still having no snapshot, and the stored one wins any race.

Regenerating a message above the shared tail leaves the whole stored branch childless, so the target walk now climbs to the closest ancestor the conversation continued under instead of stopping at the stored tail's own siblings.

Changing the search or sort also returns the table's viewport to the top, since the query holds the previous rows while it refetches.

* fix: page through titleless rows on both sides of the cursor

The route validator still required a string primary, so the composite cursor the data layer issues for a titleless boundary came back as a 400 and the shared-links table stopped at that page.

Conversations had the same type-bracketing gap the shared links just closed: a name-sorted page could not reach conversations with no title, since a comparison against a string never matches a missing field. The cursor now carries the null and the filter spells out the titleless clauses for both directions.

* fix: keep the share badge read-only and refresh rows on cell changes

ensureLinkPermissions re-granted the owner ACL entry on every call, so rendering the header's shared badge turned ordinary navigation into a permission write. It now checks for the grant first and only migrates a link that still lacks one.

A fork carrying a positional target but no revision falls back to the whole share, since nothing proves which payload the index was counted against.

The memoized table row compared row data and selection only, so a cell rendering external state (the archived list's pending Restore, for one) kept its stale rendering until the row object itself moved; rows now also compare a marker that moves with the column definitions.

* fix: keep the shared badge honest when a delete fails or a link remains

A failed delete left the conversation looking unshared: the optimistic snapshot covered only the shared-link queries, not the conversation caches the badge reads. The cleared conversations are now restored with the rest.

A conversation can hold one link per target message, so clearing the badge on delete is a guess. The conversation list is invalidated once the mutation settles, letting the server decide from the links that are actually left.

* fix: refetch every cached conversation page after deleting a link

The invalidation was pinned to page zero, so a conversation cached further down the sidebar kept the badge the optimistic update had already cleared even when another targeted link survived.

* fix: treat a failed page fetch as a failed auto-fill

React Query resolves fetchNextPage with an error result instead of rejecting, so the rejection handler never ran: the guard stayed armed on the unchanged row count and an unscrollable table could never reach the next page.

* refactor: move the share request helpers into the typed backend

Cursor validation, page-size clamping and the shared-file cache validator were plain backend logic sitting in the legacy JS route. They now live in packages/api with their own tests, and the route keeps only the Express-side wiring: reading query params, mapping domain error codes to status codes, and writing the response.

Also carries the requested file choice into the cache entry the create and update mutations synthesize, since the response never echoes it and the dialog reads a resolved entry with no choice as the enabled default.

* fix: hold auto-fill while the replacement page is in flight

A search or sort swap keeps the previous rows and hasNextPage on screen while the new first page loads, so the re-armed auto-fill asked for page two against a query that was still fetching its first. An infinite query runs one fetch at a time, so that request could cancel or interfere with the one already out. Both tables now pass their fetching state and the guard waits for it.

* fix: stop advertising links a deployment no longer serves

The sidebar badge rendered from the derived flag alone, so a deployment that turned shared links off still told owners a link was live while the public routes were unregistered.

The share dialog called the link a snapshot, but the payload populates the referenced message documents on every request: an edit to an already-shared message is visible immediately, and Update only adds newly referenced ones. The copy now says that.

Scroll pagination inspects a resolved error the way auto-fill already does, since React Query reports a failed page that way instead of rejecting.

* a11y: gate the shared conversation label on the feature flag

The icon stopped rendering when a deployment turns shared links off, but the row still announced the conversation as shared to screen readers. Both now read the same condition.

* fix: accept long title cursors and stop badge work the feature disables

The cursor cap was tight enough that a Name-sorted page ending on a long title produced a nextCursor the next request rejected, stranding the rest of the list. It now sits well clear of anything the server can issue.

The conversation list skipped straight into the shared-link lookup even where ALLOW_SHARED_LINKS is off, paying a round trip on the sidebar's first page for a badge that is never rendered.

A regeneration is newer than what it replaced, so only a newer sibling counts: an older one that still has follow-ups is the branch the target was regenerated away from, and resuming there published turns the target had excluded.

* fix: hold scroll pagination while a replacement page loads

Resetting the viewport to the top after a search or sort change fires a scroll event, and the retained previous rows still report another page, so the handler asked for page two of a query that was still loading page one.

* fix: keep the legacy share migration ahead of the owner-grant shortcut

A legacy row keeps its marker until every grant it needs exists, so an owner grant on its own is not proof the migration finished. Reading the marker first means a half-migrated public link still gets its public grant, while a fully migrated one keeps the read-only settled path the badge lookup depends on.
2026-08-09 08:14:54 -04:00
Danny Avila
5c939d129b
🔌 feat: Add Agent Plugins (Experimental) (#14704)
* 🔌 feat: Add Agent Plugins v1.0.0 Support

Implements the Agent Plugins 1.0.0 specification so LibreChat can load
portable plugin packages: a `plugin.json` manifest, `skills/` holding Agent
Skills, `mcp.json` describing MCP servers, and reverse-domain extension
directories.

- Validate the closed `plugin.json` schema, selecting rules from `$schema`
  without retrieving it. Unknown top-level fields and a non-object
  `extensions` field are reported and ignored; every other violation rejects
  the plugin.
- Enforce plugin-root containment through realpath, including for paths whose
  leaf does not exist, and apply the narrowest failure boundary per component.
- Map `mcp.json` onto LibreChat MCP options across stdio, Streamable HTTP, and
  legacy HTTP+SSE, bypassing the config loader's `${VAR}` process-env
  expansion so plugin values never resolve against the server environment.
- Expand only `${PLUGIN_ROOT}` and `${PLUGIN_DATA}`, once and non-recursively,
  in `args`, `env` values, and `cwd`; supply both variables to the subprocess
  after configured `env`, and reject entries that declare them.
- Discover skills from the immediate children of `skills/` only, reusing the
  deployment skill loader so plugin skills are ordinary deployment skills with
  a distinct id namespace.
- Read LibreChat's `ai.librechat` extension directory and hand
  `hooks/hooks.json` to the Claude hook compatibility layer.
- Load operator-installed plugins from `DEPLOYMENT_PLUGINS_DIR` at startup,
  merging their skills into the deployment skill registry and their MCP
  servers into the app config. Plugins never displace a configured server or
  deployment skill.
- Add `cwd` to the stdio MCP transport, which the specification requires and
  LibreChat did not previously support.

Component failures stay isolated: a malformed `mcp.json`, an invalid skill, or
a bad hooks document never prevents the rest of a plugin from loading.

* 🔒 fix: Contain Agent Plugins config at the runtime boundary

Review of #14704 surfaced that every real finding sat where the loader's
output crosses into LibreChat's existing runtime, not in the specification
logic. The loader deliberately left plugin placeholders literal, but
downstream layers re-processed the same fields and undid it.

- Mark plugin MCP configuration with `source: 'plugin'` and return it verbatim
  from `processMCPEnv`. Without this a remote plugin could declare
  `Authorization: Bearer ${OPENAI_API_KEY}` and receive host credentials at its
  own origin. The gate reads the configuration rather than a caller-supplied
  flag, so no future call site can reintroduce the leak by omitting it.
- Skip `preProcessGraphTokens` for plugin configuration as well; it resolves
  placeholders into headers, url, and args on the same path.
- Reject plugin server names that change under `normalizeServerName`. Tool keys
  embed the normalized name while request-time resolution uses the raw name, so
  an unstable name published tools that nothing could resolve.
- Reject `__proto__`, `constructor`, and `prototype` as server names, and merge
  plugin servers with `Object.defineProperty` and an own-property conflict
  check, so a package cannot reach a prototype setter or collide with an
  inherited member.
- Enforce manifest-name uniqueness before components are accepted; two packages
  sharing a name would share one `PLUGIN_DATA` directory.
- Isolate a failed data-directory creation to the single plugin instead of
  rejecting the whole scan.
- Prefix rejected-plugin diagnostics with the directory, which is the only
  identifier a package without a valid manifest has.
- Type extension namespace contents as JSON rather than `unknown`, and correct
  the header field-value comment to name obs-text.

Verified end to end from the built package: a plugin declaring an environment
placeholder in a header reaches the transport with the placeholder intact while
operator-authored configuration still resolves normally.

* 🔇 fix: Report Agent Plugin hooks that will not run

The loader reads `ai.librechat/hooks/hooks.json`, but nothing registers the
resulting plan, and startup supplies no hook capabilities. A package declaring
hooks was therefore accepted in silence, leaving an operator to believe the
hooks ran.

Detect the document when no capabilities are registered and report it as
unsupported, so the limitation is visible in startup diagnostics rather than
inferred from behavior that never happens.

* 🧯 test: Restore MCP startup test mocks

Carries the two mock additions from #14711 so this branch can prove itself
green. `initializeMCPs` now calls `syncStaticTools`, which the server startup
specs do not stub, so they fail on every branch that has not picked this up.
Drops out of the rebase once #14711 lands.

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-09 08:10:22 -04:00
Marco Beretta
82de3bc422
🚦 ci: Reduce GitHub Actions Runner Pressure (#14716)
* ci: reduce GitHub Actions runner pressure

* ci: enforce static check path exclusions
2026-08-09 07:41:00 -04:00
Danny Avila
1bd4455c2d
🧭 fix: Make MCP Catalog Redis Cluster-Safe (#14717)
* fix: make MCP catalog Redis startup cluster-safe

* fix: stabilize Redis readiness gate

* fix: type Redis readiness export

* style: apply canonical import order
2026-08-09 06:59:29 -04:00
Marco Beretta
92d4705f79
🧭 refactor: make the side panels behave the same way (#14695)
* style: unify chat input tool badge styling

Every tool badge repeated max-w-fit and its own hand-written checked-state
colour triplet. Move max-w-fit into CheckboxButton's base classes, where
tailwind-merge still lets a consumer override it, and collect the accent
colours into a single map so the palette lives in one place.

Artifacts repeated the amber triplet a second time on its dropdown button;
that now reads from the same map.

* feat: add feedback when resetting model parameters

The button did nothing visible on click, so with parameters already at
their defaults it looked broken. Spin the icon a full turn on press and
announce the change politely, matching the Agent Builder panel which
already announced but had no visual counterpart.

The animation replays on consecutive clicks via a reflow, and is gated
behind motion-reduce.

* fix: keep the prompt editor open when inserting a special variable

Opening the variables menu moved focus out of the textarea, whose blur
handler exits edit mode, so the prompt snapped back to its rendered
preview as if it had been saved.

Guard the blur against focus landing inside a menu, since Ariakit focuses
the menu itself on open, and hand the menu a finalFocus target so focus
returns to the textarea on close. Without the latter the editor stayed
open but unfocused, which quietly broke click-away-to-exit.

* feat: create prompts from a dialog instead of a dedicated page

Prompts now open a dialog from the sidebar, matching how skills and MCP
servers are created, and /prompts/new is gone. The dialog reuses the
existing form rather than duplicating it, with a flag to drop the
page-level chrome that has no place in a modal.

Three things the modal exposed:

- Radix locks pointer events on the body, so the portaled category and
  special-variable menus rendered but could not be clicked. They now
  render inline when hosted in a dialog, as SetKeyDialog already does.
- The floating labels notch out the page surface, which left a visible
  chip against the dialog background in dark mode. The surface is now
  passed in rather than hardcoded.
- Creating gave no indication anything was happening; the button now
  shows a spinner and blocks repeat submits.

Create buttons for both prompts and skills use the submit variant, since
both perform a write.

* style: match prompt action button sizes

The share button sat at 36px next to a 40px Use Prompt button in the
preview. Drop the size override so it takes the icon variant's default,
and bring its row-mates in the editor header along so that row stays
uniform.

* feat: load prompts by scrolling instead of paging

The query was already cursor-based; the nav hook was slicing it back into
one page at a time behind Prev/Next buttons. Flatten the loaded pages and
let the existing scroll hook fetch as the list nears its end.

useNavScrolling only fetched from a scroll event, so a first page that
did not overflow its container produced no event and the rest of the list
was unreachable. It now tops up until the list actually scrolls, which is
why zooming in used to 'fix' it.

* feat: pin panel admin settings and scroll only the panel content

Each side panel scrolled as a whole, so its filter row and toggles slid
away with the list and the scrollbar spanned the full height. Give every
panel a fixed header, a scrolling content region, and a footer that holds
the admin settings.

The skills panel gains the standard filter input in place of its title
and toggle-to-search icon; it also rendered admin settings twice, once
from the filter row and once from the accordion.

Memories drops its client-side paging, which only sliced already-loaded
data, in favour of scrolling the full list.

* fix: repair the skills create menu and icon-only dropdowns

The create menu was built on Dropdown, which is a select rather than an
action menu, and Dropdown applies its className to the popover as well as
the trigger. Sizing the trigger therefore shrank the menu itself to 36px
and clipped both entries. Rebuild it on DropdownPopup, which is what the
rest of the app uses for action menus.

Dropdown's icon-only trigger also kept its horizontal padding and laid the
icon out in a full-width flex row, leaving too little room so the icon
flex-shrank to roughly half its width. That affected every icon-only
consumer, including the prompts category filter.

* fix: correct the gap above the MCP server URL field

The fieldset grouping the connection sections carried display: contents,
which removes its box and with it the margin that space-y puts on it. The
first section inside sat flush against the description while every other
gap kept its 16px.

* refactor: unpin a favorite in one click

The row's overflow menu held a single Unpin entry, so opening it was pure
overhead. Show the unpin button directly instead.

Its hover surface matched the row's own hover colour exactly, so hovering
changed nothing; it now uses a surface that differs in both themes, with
a border carrying the contrast in light mode where the surfaces are close.

Adds the tests for unpinning, which had none.

* fix: stop prompt skeletons stacking on top of the loaded list

The groups were rendered outside the loading branch, so a refetch with
data already cached drew three skeletons above the existing rows instead
of leaving the list alone. The three states are now mutually exclusive.

* feat: add PanelContent to standardize side panel loading states

Each panel decided for itself whether to draw a spinner, a skeleton, or
nothing, and some replaced the whole panel rather than just the list.
PanelContent owns the scroll region and the loading/empty/content
decision so a panel cannot invent a fourth pattern.

It takes isLoading rather than isFetching on purpose: a refetch that
already has rows on screen should leave them alone.

* feat: give the side panels row-shaped loading skeletons

Each panel now loads with a skeleton built from the row it stands in for,
rather than a spinner or nothing: the memory card's key and token pill,
the MCP server's icon over name and description, the bookmark's icon and
count, the prompt card's block.

Memories previously replaced the entire panel while loading, so the
filter you had just typed into disappeared. The skeleton is now confined
to the content region and the header stays put.

Loading also moves out of the list components, which had each grown their
own copy of it, and into the shared PanelContent.

* feat: show a loading state in the bookmarks panel

Bookmarks had no loading state at all: it rendered straight into its
empty state while fetching, so it flashed 'no bookmarks' before the list
appeared. Thread isLoading through and give it the same header, scrolling
content and skeleton as the other panels.

* style: tighten the favorite row and unpin button

Even padding on the row, the unpin button sitting a little closer to the
edge, and no border until it is hovered.

* feat: scroll the bookmarks list instead of paging it

Bookmarks were already fetched in full, so the pager was slicing data
that was sitting in memory. Render the whole list and let it scroll, the
same as the other side panels.

It also removes a latent drag bug: rows were reordered by their index in
the unsliced array while the list rendered a page slice, so dragging on
any page past the first moved the wrong row.

* feat: load skills by scrolling instead of capping the list

The skills panel fetched a single page of 50 and never asked for more, so
a 51st skill was unreachable. Switch it to the cursor-paginated infinite
query that already existed alongside it and wire the shared scroll hook,
matching prompts and the other side panels.

The list and its rows only ever read summary fields, so they now take
TSkillSummary and the response no longer needs casting through unknown.

* fix: stop mocking real modules as virtual in specs

Seven specs mocked @librechat/client and librechat-data-provider with
`virtual: true`, which is for modules that do not exist on disk. These
do, so the flag keyed each mock to a path derived from the spec's own
directory rather than the module's resolved id. The component under test
resolves the real id, so whether it got the mock depended on the module
id cache of whichever worker picked the file up.

UploadSkillDialog was the one that bit: when the mock missed, the real
Radix dialog rendered and portaled its content to the body, so every
assertion reading from the render container failed with the input
"not rendered" while it sat in a portal a few nodes away.

* test: give the lazy bookmark chunk room to load

Waiting for BookmarkNav means waiting for babel to transform its whole
module graph on first require, which does not fit in waitFor's default
second when the transform cache is cold or the machine is busy. The
failure looked like a missed re-render but was just an import in flight.

* build: recycle jest workers before the OS kills them

Coverage maps accumulate for the life of a worker, so a full client run
pushes workers past a gigabyte and the OS kills one, failing whichever
suite it was holding at the time. Capping idle worker memory also cut
the wall clock, since the run no longer swaps.

* fix: give the dialog prompt labels a real backdrop

Floating labels notch out the surface behind them so the input's border
does not run through the text. The dialog variant asked for `bg-background`,
which no longer maps to anything and computes to transparent in both
themes, leaving the border visible through the label. `bg-surface-primary`
is what OGDialogContent actually paints.

* fix: resolve side panel review findings

Send the removed prompt create page to a tombstone route so a stale
/prompts/new cannot render a blank form or fetch the id "new".

Drive the list footer spinner from isFetchingNextPage alone; the old
showLoading flag was set on scroll and only cleared by a later scroll,
so it stuck on after the last page.

Retry the scroll auto-fill through a ResizeObserver: the fill bailed
whenever the panel had no layout yet and nothing asked again once it
got one. A collapsed sidebar keeps its panel mounted and laid out, so
gate fetching on the sidebar being expanded rather than draining the
catalog behind an invisible panel.

Gate the MCP admin footer on the admin role, matching the memories,
prompts and skills panels; the bordered bar rendered empty for
everyone else.

Replay the reset icon spin by remounting the icon. Toggling the class
list lost the animation to the re-render that setConversation causes.

Announce panel loading from a live region carrying its own text. The
skeleton rows and the spinner are both aria-hidden, so labelling the
region left nothing for a screen reader to read out.

Cover the scroll hook, the panel content primitive and the prompt
create dialog with unit tests, and point the prompts e2e spec at the
dialog rather than the deleted page.

* chore: remove unused translation keys

com_ui_pagination and com_ui_select_or_create_prompt lost their last
callers when the prompt list moved to infinite scroll and the empty
prompt preview was dropped. Only the English file is touched; the
other locales are generated externally.

* Fix nav pagination retry loop

* Fix prompt field IDs and skills pagination

* Fix prompt dropdown ARIA IDs

* test: stub syncStaticTools in the server bootstrap specs

initializeMCPs now calls syncStaticTools from services/Config when no MCP
servers are configured. Both bootstrap specs mock that module wholesale, so
the call threw, the post-listen handler ran process.exit(1), and the Jest
worker died four times over before the suite was reported as failing to run.
2026-08-08 23:15:46 -04:00
Marco Beretta
667d97d668
⛶ feat: add fullscreen artifact previews (#14585)
* feat: add fullscreen artifact previews

* fix: harden artifact fullscreen behavior

* fix: address artifact fullscreen review feedback

* test: use standalone Recoil type import

* fix: portal fullscreen artifact menus safely

* fix: raise fullscreen artifact menu portal

* fix: keep fullscreen artifact tooltips visible
2026-08-08 23:15:04 -04:00
Danny Avila
ccb43bf32b
test(mcp): mock static tool startup sync (#14711) 2026-08-08 23:12:07 -04:00
Danny Avila
ed9542ed75
🧬 fix: Rebind Request Context After Remote Agent Auth (#14685) 2026-08-08 14:27:22 -04:00
Danny Avila
1bccc2bc18
📡 fix: Refresh MCP Tools After List-Changed Notifications (#14686)
* fix(mcp): handle dynamic tool list changes

Co-authored-by: Pascal Garber <pascal@artandcode.studio>

* test(mcp): fix CI validation

* fix(mcp): keep dynamic tool catalogs live

* fix(mcp): harden dynamic catalog lifecycle

* test(mcp): use typed startup connection

* test(mcp): isolate dynamic e2e fixtures

* fix(mcp): refresh tools after reconnect

* fix(mcp): close dynamic catalog cache gaps

* test(mcp): update OAuth connection mocks

* fix(mcp): preserve app snapshot ownership

* style(mcp): sort connection imports

* fix(mcp): close review race conditions

* fix(mcp): preserve cache ownership edges

* fix(mcp): harden recovery lifecycle

* fix(mcp): guard tool-less app refresh

* fix(mcp): fence distributed cache races

* fix(mcp): retire stale connection state

* fix(mcp): keep tool snapshots authoritative

* fix(mcp): fence stale app tool publications

* style(mcp): sort repository test imports

* test(mcp): mock empty startup publication

* fix(mcp): preserve app publication generations

* fix(mcp): harden publication recovery races

* fix(mcp): address tool catalogs by runtime config

* fix(mcp): load scoped catalogs for assistant writes

* fix(mcp): harden catalog publication recovery

* fix(mcp): serialize forced connection replacement

* fix(mcp): serialize ordinary creation with replacements

* fix(mcp): harden catalog fallback boundaries

* fix(mcp): close lifecycle fencing gaps

* fix(mcp): preserve catalog authority on failures

* fix(mcp): compensate failed catalog mutations

* fix(mcp): fence catalog refresh ordering

* style(mcp): sort agent loader imports

* fix(mcp): cancel stale connection creation

* fix(mcp): fence catalog coordination

* fix(mcp): close catalog race windows

* fix(mcp): harden cross-pod catalog fencing

* fix(mcp): close catalog lifecycle edges

* style(mcp): sort assistant imports

* fix(mcp): reject stale recovery authority

* fix(mcp): restore static catalog on every startup

* fix(mcp): order app catalog publications

* style(mcp): sort catalog revision imports

* fix(mcp): separate catalog allocation and commit fences

---------

Co-authored-by: Pascal Garber <pascal@artandcode.studio>
2026-08-08 13:50:21 -04:00
Danny Avila
ef38f362ec
📦 chore: bump @librechat/agents to v3.4.2 and npm audit (#14702)
* 📦 chore: bump `@librechat/agents` to version 3.4.2

* 📦 chore: bump `mermaid` to version 11.16.1 and update related dependencies

* 📦 chore: bump `js-yaml` to version 4.3.1 in package-lock and data-provider

* 📦 chore: bump `nanoid` to version 3.3.18 in package.json and package-lock.json across multiple packages

* 🔧 fix: Remove stray `api/tsconfig.json` breaking e2e `~` alias

An empty `api/tsconfig.json` was accidentally committed with the agents bump.
Playwright's require hook resolves path aliases from the nearest path-config,
checking `tsconfig.json` before `jsconfig.json` in each folder, so the empty
file shadowed `api/jsconfig.json` — the only place `"~/*": ["./*"]` is defined.

Every e2e spec that calls `cleanupUser` then failed on
`Cannot find module '~/cache/getLogStores'` from `api/models/index.js`.

- delete the stray file and gitignore it so tooling can't re-commit it
- register `module-alias` in `cleanupUser` so backend requires resolve
  regardless of which path-config Playwright happens to find

* 📦 chore: bump `@librechat/agents` to version 3.4.3 in package.json and package-lock.json
2026-08-08 12:24:06 -04:00
Danny Avila
493ffced46
🪝 feat: Add Claude Hook Compatibility Layer (#14412)
* feat: add Claude hook compatibility layer

* fix: satisfy API declaration build

* fix: normalize hook matchers and conditions

* fix: align Claude hook lifecycle semantics

* fix: preserve Claude hook execution semantics

* fix: gate unsupported Claude hook controls

* fix: refine Claude payload and matcher translation

* fix: scope Claude hook conditions to tool events

* fix: preserve mixed Claude hook documents

* fix: honor Claude hook execution controls

* fix: close Claude hook lifecycle gaps

* fix: isolate Claude hook declaration state

* fix: preserve Claude matcher semantics

* fix: handle PreemptBoundary in plugin hook payload switch

`PreemptBoundary` joined `HOOK_EVENTS` in @librechat/agents 3.4.2. It has no
Claude counterpart and is absent from the compatibility EVENT_MAP, so it can
never reach a registered declaration, but the payload switch must stay
exhaustive so a future event fails the build rather than returning undefined.
2026-08-08 07:13:11 -04:00
Marco Beretta
39f5f9d846
perf: Agent List and Model Selector at Scale (#14601)
* perf: cut serial round trips from the agent list query path

The agent list was the slowest path on first page load. Three separate
problems compounded:

- `getListAgentsHandler` chained its reads: two ACL lookups, the avatar
  refresh cache probe and the viewer skill scope all resolved serially
  ahead of the list query, and `attachOwnerContacts` added two more hops
  after it. The four independent reads now resolve together, and the
  avatar refresh runs alongside the list query instead of before it -
  refreshed paths reach the response through `urlCache`, not through
  whatever the list query happened to read. Serial hops per request drop
  from 7 to 4 on a warm cache.

- The avatar refresh loaded the user's whole accessible agent set (up to
  MAX_AVATAR_REFRESH_AGENTS) to discover which entries were S3-backed.
  Scoping the query to `avatar.source` means deployments on any other
  file strategy match nothing instead of walking the full set.

- `fetchAllAgentPages` walked cursor pages at the server's default size
  of 100, and callers consume the flattened result, so every extra page
  was a serial round trip for no benefit. It now requests the server
  maximum. Measured over a 2,860 agent account: 29 requests / 1.65s
  before, 3 requests / 0.29s after.

Also parallelizes the conversation file reads in `initializeAgent`. The
convo file refs and the execute_code thread walk share no inputs, and the
two code-file lookups depend only on `threadFileIds`, so the chain of six
serial reads on every turn collapses to two. This one is time to first
token the user waits through.

* perf: virtualize the model selector agent list

Opening the agents submenu with a large agent set froze the tab and could
kill it outright. With ~10k accessible agents the submenu blocked for over
15 seconds and took the heap from 96MB to 911MB. Four per-row costs were
being multiplied by the full list, which rendered unwindowed:

- `useIsActiveItem` allocated a MutationObserver per row (10,016 of them
  for one dropdown). Replaced with an Ariakit store subscription, which
  needs no observer at all and returns a boolean so a row only re-renders
  when its own active state flips.

- `useFavorites` ran per row, opening a jotai subscription, a query
  subscription and a mutation each time. Hoisted to one call per endpoint.

- Each row rescanned `endpoint.models` to recover `isGlobal`, a field the
  parent had already discarded from the array it was mapping. The parent
  now passes it down from a lookup map.

- The list itself is now windowed above 100 rows. Ariakit's composite only
  knows about mounted rows, so arrow-keying to the window edge previously
  found no next item and let focus escape the nested menu, closing it;
  `handleBoundaryNavigation` scrolls the next index in, waits for it to
  mount, then moves the composite onto it. Navigation inside the window is
  left to Ariakit.

Open drops from >15s to 96ms, mounted rows from 10,028 to ~18, DOM nodes
from 123,346 to ~1,000, and the heap no longer grows. Verified in browser:
arrow keys track 1:1 to index 238 and back, and click selection works.

* perf: serve the model selector from the shared VIEW agent query

The model selector asked for EDIT-scoped agents whenever the marketplace
is enabled, while `useAgentsMap` and `useMentions` asked for VIEW. Since
the cache key includes the params, that was two distinct entries, so first
page load ran the paginated walk twice and held two copies of the whole
agent list in memory. Measured against a 10k agent account: 22 list handler
invocations per page load, now 11.

Collapsing the two by asking for the same permission everywhere would have
changed what the selector shows - under the marketplace the EDIT scope is
what makes it "My Agents", with discovery handled by the marketplace entry.
So the list endpoint now marks each row with `isEditable`, resolved from an
ACL read folded into the existing parallel batch (no extra serial hop), and
the selector filters the shared VIEW response instead of refetching. A
VIEW-scoped list for a user with 2861 visible / 361 editable agents returns
exactly 360 rows flagged editable, matching what the EDIT query returned.

`AgentSelect` deliberately keeps its own EDIT query: it reads `skills` and
`skills_enabled`, which `sanitizeViewerSkillScope` strips from VIEW-scoped
responses. It also only mounts when the builder panel is open, so it is not
part of the first-load cost.

The field is set unconditionally rather than omitted when false so that a
client talking to an older server sees `undefined`, keeps every agent, and
degrades to showing too many rather than none.

* fix: address review findings on the agent list at scale

Three issues from review, all confirmed against the code before fixing.

Avatar refresh no longer runs alongside the list query. `updateAgent` writes
through `findOneAndUpdate` on a `timestamps: true` schema, so refreshing an
avatar advances `updatedAt` — the field `getListAgentsByAccess` sorts and
cursors on. A write landing after the first page's snapshot moved that agent
ahead of the returned cursor, dropping it from every later page and silently
truncating the caller's flattened list. This was a regression introduced when
the two were parallelized; serializing them costs nothing on the common path,
because a cache hit returns without issuing any query, so only the
once-per-30-minutes miss pays for the ordering. The new test asserts the write
lands before the list snapshot and fails against the parallel version.

The virtualized list no longer inserts a focusable grid into the combobox.
`List` spreads its props onto `Grid`, whose defaults are `role="grid"`,
`containerRole="row"` and `tabIndex={0}`; inside Ariakit's listbox that added a
tab stop ahead of any row and put grid/row semantics between the listbox and its
options. All three are now neutralized so focus and ARIA stay with the combobox
items.

The list also resets to the top when the filter changes. `Grid` keeps its scroll
offset across prop changes and clamps an out-of-range offset to
`totalRowsHeight - height`, the end of the shorter list. Scrolling deep and then
searching landed on the tail: measured at row 626 of 667 matches, with only
those rows mounted and reachable by keyboard. Keying the list on the search
value restores row 0.

* fix: declare option position and set size for the virtualized model list

Once the model list is windowed, only the mounted slice exists in the listbox,
so a screen reader infers position and total from ~19 elements instead of the
real set — announcing "3 of 19" partway through 10,014 agents.

Model rows now carry aria-posinset and aria-setsize. The marketplace entry and
any model specs share the same numbering, because they are options in the same
listbox: declaring the values on some options while leaving others to be
inferred from the DOM would make the set internally inconsistent. Both are
omitted entirely when the list is short enough to render unwindowed, where the
DOM holds every option and the implicit values are already correct.

Verified against a 10,014 agent account: the marketplace entry reports 1 of
10015, the first models 2 and 3, and after scrolling to row 4999 the leading
mounted model reports 5001 of 10015 with 19 options in the DOM.

* 🩹 fix: Address Follow-Ups on the Agent List at Scale

Corrects residual issues in the agent-list perf work, all inside its own scope.

- Forward `idOnTheSource` through `PermissionService.findAccessibleResources`
  so `getUserPrincipals` skips the user-document read. The list handler resolves
  three permission sets per request and each was paying its own `User.findById`;
  the auth strategies already normalize the field to a value or null.
- Gate the editable-set lookup on its own predicate instead of borrowing
  `canReturnSkillConfig`. The two answer unrelated questions and only coincide
  today, so redefining the skill flag would have marked every agent editable.
- Log mapping failures in the list response instead of swallowing them.
- Apply the walk page size after the caller's params in `fetchAllAgentPages`.
  A caller limit only changed page size, never what the flattened walk returned,
  so `defaultAgentParams`' `limit: 10` would have turned one request into 301.
- Carry `isEditable` on the agent rows the create and update mutations write
  into the list cache. Mutation responses omit the field, so those rows lost it.
- Document `isEditable` as list-only, ACL-derived, and fail-open on absence.
- Restore the truthiness guard on the thread walk in `initializeAgent`. Widening
  it to `!= null` made an empty `parentMessageId` issue a full-conversation read
  against an anchor that can never match.
- Await `getConvoFiles` directly rather than calling `.then()` on it, restoring
  tolerance for synchronous test doubles.
- Correct the avatar-refresh comment: the projection was never full documents,
  and the real reason to filter is that an unfiltered budget is self-reinforcing.

Tests: both new `initialize` tests and both new backend tests are
mutation-verified; the concurrency test fails under either serialization order.

* fix: preserve ACL isEditable when merging agent mutation responses

Mutation responses omit list-only isEditable. Inferring true from write
success promoted VIEW-only rows into the editable subset for MANAGE_AGENTS
callers who can PATCH agents their ACL marks non-editable.

* fix: sort imports in agent mutations test

ESLint import-order check failed on the isEditable cache-preservation test.

* 🧷 fix: Carry isEditable Onto Duplicated Agent List Rows

`useDuplicateAgentMutation` prepended the raw duplicate response to the cached
list, and mutation responses omit the list-only `isEditable` field. The row
survived the "My Agents" filter only by failing open on `undefined`, so it would
disappear the moment a consumer read the flag strictly.

Duplicating grants the caller ownership, so the new row is editable outright;
this is the create case rather than the merge case `mergeAgentListRow` handles.
Last cache write on this path that did not carry the field.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-07 21:04:55 -04:00
Danny Avila
a5b10c78cf
🛡️ feat: Add Batched MCP Authority Proofs (#14688)
* feat(data-schemas): add MCP authority proof substrate

* feat(api): add default-off MCP authority fences
2026-08-07 12:23:25 -04:00
Marco Beretta
9e6d677751
⚙️ ci: Bump GitHub Actions to Node.js 24 Runtimes (#14689)
* ci: bump GitHub Actions to Node.js 24 runtimes

Clear Node 20 deprecation warnings on runners by moving workflow
actions to majors that declare node24 (checkout, cache, setup-node,
artifacts, Docker buildx/build-push/qemu/login, github-script,
setup-go, Azure login/helm, create-pull-request, axe-linter).

* fix: release leader lock via ioredis on Redis Cluster

@keyv/redis EVAL can surface unhandled MOVED redirects on cluster,
so resign() logged failure and left LeadingServerUUID set. Use ioredis
for leader election SET NX / GET / Lua (same pattern as principals and
concurrency locks) so cluster redirects are retried and resignation
clears the lock.
2026-08-07 11:25:29 -04:00
Danny Avila
c570517768
🐘 test: refresh FerretDB harness, registry-derived models, bulkWrite coverage (#14679)
* fix(data-schemas): refresh FerretDB harness model coverage, fix compile errors, add bulkWrite differentials

Track 2 of the search-stack plan (PLAN.md "FerretDB track"):

- Replace the three hand-rolled 29-model MODEL_SCHEMAS maps in
  multiTenancy/sharding/orgOperations.ferretdb.spec.ts with a shared
  getModelSchemas(mongoose) helper (misc/ferretdb/schemas.ts) derived from
  the live createModels() registry, so coverage tracks all 37 current
  models automatically instead of drifting. Matches the reference pattern
  in misc/documentdb/compat.documentdb.spec.ts.

- Fix the 3 compile-broken specs this uncovered: all three imported a
  `projectSchema` from '~/schema' that no longer exists (superseded by
  `chatProjectSchema`), which `tsc --noEmit` flags as TS2724 but the
  babel-based jest transform silently let through as `undefined`. Removing
  the hand-rolled maps removes the bad import as a side effect; verified
  clean with tsc across misc/ferretdb and misc/documentdb.

- Add misc/ferretdb/bulkWrite.ferretdb.spec.ts: differential specs for the
  five bulkWrite flows the plan names as actually at risk (import via
  bulkSaveConvos/bulkSaveMessages, bulkWriteAclEntries,
  bulkIncrementTagCounts, Transaction.insertMany, file-TTL bulkWrite via
  extendFilesTTL). Each flow runs identical operations against a real
  mongodb-memory-server (always) and, when FERRETDB_URI is set, against
  FerretDB, asserting normalized result equality. Multi-document
  transactions already degrade via the existing supportsTransactions
  probe — not duplicated here.

- Land the Spike A BSON-legibility findings (bson-legibility.md,
  bson-inventory.txt) from the bson-legibility-spike-6e38de worktree so
  decision 2's evidence is in-repo.

Verified against a real FerretDB 2.7.0 + postgres-documentdb 17 stack
(docker compose -f misc/ferretdb/docker-compose.ferretdb.yml): all 10
harness spec files pass individually, including all 10 bulkWrite.ferretdb
tests (5 mongodb-memory-server baselines + 5 FerretDB differentials). Full
packages/data-schemas src/ suite (1907 tests) unaffected.

* 📝 docs: make the BSON projection findings self-contained

The doc was written for readers who already knew the internal shorthand — it
opened on "Spike A executed, Spike B scoped" and referred to Options 1/2/3 and
"the handoff" without ever defining them, so a reader arriving from the repo
could not follow the argument or act on the recommendation.

Reframed around what the document actually investigates: the question is stated
up front, the three candidate mechanisms are named in a table before they are
compared, and the recommendation refers to them by name. No findings, numbers,
or SQL changed.

* 📝 docs: drop internal planning references from spec header

* fix(data-schemas): keep FerretDB harness schema derivation side-effect free

`getModelSchemas()` derived its map by calling `createModels(mongoose)`,
which carried three consequences the harness did not want:

- Model creation applies the tenant-isolation plugin to the module-level
  schema singletons, so every harness read and write inherited middleware
  that throws under `TENANT_ISOLATION_STRICT=true`.
- Registering on the default connection meant the benchmark's own
  `mongoose.connect()` auto-created 37 collections in the URI's base
  database, adding a database and dozens of collections to the very
  catalog metrics it measures.
- The unfiltered registry provisioned app-wide control-plane models
  (`SystemGrant`, `AuditLog`, `SkillSyncCredential`, `SkillSyncStatus`)
  into every org database.

The helper now builds the registry on a throwaway Mongoose instance,
returns schemas rebuilt from their own definition, options, and declared
indexes, skips the four app-wide models (validated against the registry so
a rename fails loudly), and memoizes the result.

Also in this pass:

- The "adds a new collection" migration test used `AuditLog`, which
  provisioning had already created, so it silently reused the production
  model and ignored its proposed schema. It now uses a fixture model absent
  from the registry and asserts the collection is missing beforehand and
  carries the proposed compound index afterwards.
- `bulkWrite` flows run inside `runAsSystem()`; they drive production
  methods unscoped, as a cross-tenant maintenance job does, and otherwise
  fail closed under strict tenant isolation.
- Phase 2's sparse-index assertion pinned a count the User schema no longer
  declares; it now checks that each index type round-trips.
2026-08-07 10:45:58 -04:00
Danny Avila
5ee910e44c
🐋 chore: add opt-in alternative local stack experiments (#14678)
* feat: add search stack PoC infrastructure (Track 1)

Docker Compose stack for PLAN.md's new chat-search architecture:
ferretdb 2.7.0 + its postgres-documentdb 17 backing store (wal_level=logical
for the later CDC spike), a new dedicated chat_search_db (PostgreSQL 17 +
pgvector, non-default credentials, three least-privilege roles per the
Security roles section), and clickhouse (26.3 LTS). vectordb/rag_api are
untouched, per decision 3.

All four image tags verified against their registries via curl (ghcr.io
manifest lookups, Docker Hub tags API) before pinning. Host ports checked
against every existing compose file in the repo to avoid collisions.

The role-provisioning init script and healthcheck script were both actually
run against live containers once Docker became available mid-task: full
Mongo-wire round trip against ferretdb, real INSERT/SELECT proving the
writer's default-privilege grants and the reader's deny-by-default posture
against a throwaway pgvector table, wal_level and pgvector/pg_trgm extension
checks. One bug only surfaced at runtime and is fixed: psql does not
interpolate :'var' inside dollar-quoted DO $$ ... $$ blocks, so role
creation/idempotency uses \gset + \if/\else/\endif instead.

* 🔐 chore: require operator-supplied FerretDB credentials

The chat_search_db and ClickHouse services already refused to start without
operator-supplied passwords; FerretDB's backing PostgreSQL still fell back to
a working ferretdb/ferretdb pair, so the stack booted with a known credential
even though it holds projected chat content. That is the same shape as the
myuser/mypassword default the search plan calls out on the existing vectordb
service.

All four FerretDB credential references now use ${VAR:?} - compose, the
healthcheck script, and the README examples - and .env.example ships
REPLACE_ME placeholders instead of literals.

The differential-test harness at packages/data-schemas/misc/ferretdb keeps its
fixture credentials; it holds only throwaway test data.
2026-08-07 10:40:58 -04:00
Danny Avila
51ed1fab4b
🩹 fix: Keep Edit Action Fully Hidden While Streaming (#14687)
* 🩹 fix: Keep Edit Action Fully Hidden While Streaming

#14677 stopped the row-hover reveal from un-hiding the edit button, but the
pencil still shows as a dimmed ghost mid-generation. The shared Button
primitive sets `disabled:opacity-50`, which compiles to
`.disabled\:opacity-50:disabled` — specificity (0,2,0). The hidden state used a
plain `opacity-0` at (0,1,0), so the disabled style won and painted the icon at
half opacity.

Verified in Chromium against a running instance: only two opacity rules match
the button, and the computed value was 0.5. Switching the hidden state to
`!opacity-0` (Tailwind emits `opacity: 0 !important`) drops it to 0 while the
sibling actions still reveal at 1 on hover.

The existing unit test could not catch this: jsdom applies no stylesheet, so
asserting class names never exercised the cascade. It now asserts the important
modifier specifically, with a comment explaining why a bare `opacity-0` is
insufficient.

* 🧪 test: Browser guard for the hidden edit action

The Jest spec can only assert class names — jsdom applies no stylesheet, so it
could not see `disabled:opacity-50` (0,2,0) outranking `opacity-0` (0,1,0) and
repainting the hidden pencil at half opacity. That is exactly how the ghost
survived #14677 with a green suite.

Asserts computed opacity in a real browser mid-stream, and asserts the sibling
Copy action is at opacity 1 in the same breath so a hover that silently failed
to register cannot make the check pass for the wrong reason. Verified to fail on
the pre-fix build with `Received: "0.5"`, and to pass 3/3 after.
2026-08-07 10:20:23 -04:00
Danny Avila
1596df724a
🫆 chore: Remove Published Credential Defaults (#14680) 2026-08-07 07:25:05 -04:00
Danny Avila
0db511fee8
fix: Restore WCAG AA Contrast for Text Tokens & Hide Edit Action While Streaming (#14677)
*  fix: Restore WCAG AA Contrast for Text Tokens & Hide Edit Action While Streaming

Fixes the unreadable composer placeholder and the edit pencil that appears on
hover mid-generation, plus the sibling token failures found while tracing the
root cause.

Placeholder: #13879 moved the composer from `dark:placeholder-white/60` to the
semantic `placeholder:text-text-tertiary`, but `--text-tertiary` was
`var(--gray-500)` in *both* themes, and #595959 is a dark gray. Dark mode fell
from 5.90:1 to 1.91:1. Fixed at the token (dark -> gray-400, 4.56:1) rather than
the call site: the token has 99 usages and was failing at 1.91-2.77:1 on every
dark surface. The .gizmo dark theme already uses a light gray (#999999) for the
same token, so only the default dark theme carried the inverted value.

Two more instances of the same "token never tuned per theme" bug:
- `--text-warning` was amber-500 in both themes: 2.15:1 in light across 13
  real warning strings. Now amber-700 (5.02:1).
- Light `status-{success,warning,error}` on their own `-subtle` fill measured
  3.58 / 3.07 / 4.41 -- the exact pairing Alert, Badge, Tag and Chip use for
  every status variant. Bumped to the 700 ramp (5.21 / 4.84 / 5.91). Solid
  `bg-status-*` is only used for dots, so nothing renders text on it.

Edit action: `hideEditButton` already covers `isSubmitting` and the button got
`isVisible={false}` -> `opacity-0`, but `group-hover:opacity-100` (0,2,0)
outranks bare `opacity-0` (0,1,0), so hovering the row revealed a disabled
pencil. The reveal classes are now gated on `isVisible`, with
`pointer-events-none` so the hidden button is inert.

Both token sources of truth (style.css and themes/*.ts) were updated and verified
in sync across all 67 tokens.

Tests: new HoverButtons spec covers both hover states; semanticTokens.spec.ts
gains a contrast guardrail over text tokens x surfaces and each status hue
against its subtle fill, verified to fail on the original values.
applyTheme.spec.ts now derives its expectation from the theme object instead of
pinning a hex, so retuning a hue no longer breaks an unrelated plumbing test.

* 🔤 style: Sort imports in HoverButtons spec

CI's changed-files import-order check flagged the new spec; the previous
commit bypassed the lint-staged hook that would have caught it.
2026-08-07 00:52:07 -04:00
Danny Avila
5ff46d8c67
🛟 fix: Stop Agents When Code Resources Cannot Recover (#14651)
* fix: Block Agents When Code Resources Cannot Recover

* fix: Preserve Resource Recovery Failures Across Agent Paths

* fix: Centralize Fatal Agent Initialization

* chore: sort agent imports
2026-08-07 00:33:28 -04:00
Dustin Healy
6d2f29266c
🔑 feat: Refresh-Capable Google Admin OAuth Sessions (#13832)
* 🔑 feat: Refresh-Capable Google Admin OAuth Sessions

Google admin sessions cannot be refreshed today. Three gaps add up to that:
passport.authenticate('googleAdmin', ...) in api/server/routes/admin/auth.js
never sets access_type=offline, so Google omits the refresh_token from its
token response; createOAuthHandler in api/server/controllers/auth/oauth.js
only forwards a refresh token into the admin exchange payload when the user's
provider is 'openid' AND OPENID_REUSE_TOKENS is enabled; and
/api/admin/oauth/refresh is openid-only, calling openid-client.refreshTokenGrant
against the configured OIDC issuer. OpenID admins refresh transparently
because all three are in place for them.

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

* 🔁 fix: Harden Google admin refresh against bot review findings

Five validated findings from the initial bot pass:

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

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

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

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

Tests cover each fix: socialLogin's migration and rejection cases, the
oauth.js Discord-gating case, the userinfo fallback path on missing
id_token, CLAIMS_INCOMPLETE when both id_token and userinfo are absent,
IDP_INCOMPLETE on a non-JSON token body, and the full response shape on
the happy path.

* 🧪 fix: Add updateUser to appleStrategy test mock for socialLogin migration

The shared socialLogin verify callback now invokes `updateUser` when the
email-fallback path discovers a same-provider user with an empty provider
id, persisting the refreshed sub. The Apple strategy test's `~/models`
mock did not stub `updateUser`, so the migration path hit
`TypeError: updateUser is not a function` and failed the
`should handle existing user and update avatarUrl` case in CI shard 1/3.

* 🧹 refactor: Move Google admin refresh into TypeScript @librechat/api helper

Per repo guidance (CLAUDE.md): all new backend code must be TypeScript in
/packages/api, and /api is a thin JS wrapper. The previous commit landed the
Google admin refresh flow as ~120 lines of new JS inside
api/server/routes/admin/auth.js, which violates that. This commit extracts
the flow into a new TS helper at packages/api/src/auth/googleRefresh.ts and
reduces the route handler to a thin dep-wiring wrapper.

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

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

Tests move with the code: packages/api/src/auth/googleRefresh.spec.ts now
owns the helper's behavior (token endpoint, userinfo fallback, ObjectId
guard, USER_ID_MISMATCH/TENANT_MISMATCH/USER_NOT_FOUND/FORBIDDEN, rotated
refresh-token pass-through, GOOGLE_NOT_CONFIGURED, IDP_INCOMPLETE on
non-JSON body, CLAIMS_INCOMPLETE when both id_token and userinfo miss).
The route-level api/server/routes/admin/auth.refresh.test.js drops the
duplicated end-to-end Google cases and keeps a smaller surface: route
delegates to applyGoogleAdminRefresh with the right deps + options, maps
AdminRefreshError to HTTP status/code, falls through to 500 for unknown
errors, and rejects unknown providers with INVALID_PROVIDER.

* 🔁 fix: Tighten Google admin refresh and limit social-login changes

Brutal-review findings on top of the upstream feature work.

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

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

googleRefresh.ts: drop the unreachable `?? ''` defensive coalescing in
`fetchGoogleTokenset`. The `GOOGLE_NOT_CONFIGURED` guard upstream already
narrows `clientId`/`clientSecret` to non-empty strings; the function
takes a narrowed `GoogleAdminRefreshConfiguredOptions` shape and
`applyGoogleAdminRefresh` constructs that shape after the guard.

* 🔒 fix: Apply brutal-review hardening to Google admin refresh

Tighten the Google OAuth refresh flow against all outstanding code review
findings: enforce JWT aud claim verification against the configured clientId
(ISSUER_MISMATCH on mismatch), reject ambiguous googleId matches (limit:2 in
findUsers, USER_ID_MISMATCH when multiple rows match), scope the authInfo
refresh-token carrier to the Google provider only, add TOCTOU re-read defense
after the admin googleId migration write in socialLogin, deduplicate
canAccessAdmin/mintToken closures via buildAdminRefreshClosures shared by both
OpenID and Google refresh paths, document rotation semantics on
AdminExchangeResponse.refreshToken, standardise all log prefixes to
[admin/oauth/refresh], and expand test coverage for all new paths.

* 🔒 fix: Reject refresh for users migrated off the Google provider

The interactive Google admin login path in socialLogin.js already rejects
a user whose provider field is not 'google', returning AUTH_FAILED. Without
a matching guard in the refresh path, a user migrated to OpenID could use
an unexpired Google refresh token to keep minting admin JWTs indefinitely.

Add a PROVIDER_MISMATCH check after resolving the user in both the direct
getUserById branch and the findUsers fallback branch of resolveAdminUser,
mirroring the provider gate the interactive path enforces.

* 🔒 fix: Add ban check and fix domain allowlist on admin OAuth refresh

Two gaps in the /api/admin/oauth/refresh route:

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

Replace getAppConfig({ baseOnly: true }) in the non-tenant isEmailAllowed
closure with getAppConfig({ role: user.role }), which includes DB-layer
overrides from the admin panel. baseOnly returns only YAML-derived config,
so any allowedDomains list maintained entirely through the admin panel was
silently inert on this path. Extract isEmailAllowedForUser as a shared
helper, move it into buildAdminRefreshClosures so both Google and OpenID
refresh paths enforce domain policy consistently, and add isEmailAllowed
to AdminRefreshDeps in the TS package so applyAdminRefresh can invoke it.

* 🔒 fix: Harden admin OAuth refresh against user bans, tenant scope gaps, and cross-tenant migration

Post-identity-resolution ban check: the initial checkBan middleware fires before the
refresh token is exchanged and req.user is populated, so it can only evaluate IP bans.
After applyGoogleAdminRefresh/applyAdminRefresh resolves the user identity, we now
synthesize req.user and re-run checkBan against the resolved user's id before emitting
the JWT, so a user-level ban is enforced even from a fresh IP.

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

Cross-tenant email-fallback migration: the Passport verify callback fires before
tenantContextMiddleware, so findUser({email}) is unscoped and can return a same-email
user from another tenant. Writing googleId onto that document permanently corrupts
the other tenant's account. Migration is now blocked for users with a tenantId;
single-tenant users are unaffected.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-07 00:10:34 -04:00
Marco Beretta
e3b8e30327
👋 feat: Day-Aware Landing Greeting Schedule (#14633)
* feat: day-aware landing greeting schedule

Replace the branching time-of-day greeting in Landing with a declarative
schedule keyed by weekday and hour. Each slot maps to a translation key,
with an optional personalized variant interpolating the user's name, and
days without a custom schedule fall back to the default one.

The greeting resolves after mount to keep server-rendered markup stable,
arms a single timer for the next slot boundary instead of polling, and
recalculates on tab visibility and window focus so a sleeping machine or
timezone change does not leave a stale greeting on screen.

* feat: rotate landing greeting variants by day

Each schedule slot now holds a pool of variants instead of one line, and the
active variant is chosen from the local calendar day, so the greeting holds
steady across a slot but differs from one day to the next. Day-specific lines
join their day's pool rather than replacing it.

Raise the landing large-text cutoff to 56 characters so a personalized
greeting with a long display name, or a longer translation of one, keeps the
intended size, and add a test pinning every variant under that budget.

* feat: add a dawn greeting slot between late night and morning

04:00 to 07:00 sits between the two moods the schedule had: too late for
"up late", too early for "good morning", and the visitor could be up early
or not yet in bed. Give it its own slot that plays on the ambiguity, and
move the early bird line into it, where the timing actually fits.
2026-08-07 00:05:30 -04:00