* 🧭 fix: Rebase Activity Phase Bounds Onto Compacted Content
`filterMalformedContentParts` compacts the aggregator's content array —
`Array.prototype.filter` skips holes and drops malformed tool calls — but a
parent phase marker's `activity_start_index`/`activity_end_index` still address
the pre-filter positions. The array is routinely sparse: the aggregator writes
parts at provider-source indexes, so a model turn that emits no text before its
tool calls leaves an empty slot.
Every part after a hole therefore shifts left on persistence while the bounds
stay put, so the stored phase claims the wrong range — the final answer is
swallowed into the parent card and the marker's own slot is counted as a child.
The in-run analogue (`rebaseActivityPhaseBounds`) already rebases after
completion-time reshaping; the final compaction had no such step.
Rebase the bounds as part of the compaction, mapping each bound to the number
of retained parts ahead of it. The mapping is monotonic, so `start <= end <=
markerIndex` survives, and an identity mapping leaves untouched arrays — and
their marker objects — exactly as they were. Markers are copied rather than
mutated so the caller's array keeps its own coordinates, which the live stream
and the resume snapshot still address.
Fixes the `activity-phases` e2e failure on dev and the same defect on the two
resume persistence paths.
* 🔌 fix: Stop Replacing the Env Module in the MCPManager Suite
`MCPManager.test.ts` mocked `~/utils/env` with a factory that replaced the whole
module. #14780 then made `~/mcp/utils` read `ALLOWED_BODY_FIELDS` from that
module at module scope, so importing `~/mcp/oauth` -> `handler.ts` ->
`~/mcp/utils` evaluated `undefined.map(...)` and the suite died at import time.
All 111 of its tests have been silently skipped since; the shard has been red on
dev, on this PR, and on release-v0.8.8-rc1.
Spread the real module and keep only the mock that earns its place.
`processMCPEnv` stays a seam: fifteen cases drive it with `mockReturnValue` /
`mockImplementation` to hand the manager a specific processed config, and one
asserts its call count, so making it real would couple these tests to
env-substitution logic. `isPluginSourced` and `MCP_PLUGIN_SOURCE` were dropped —
the factory restated the real implementations verbatim and no test referenced
either, so they were duplication, not a seam.
111 tests now run and pass.
* 🧪 test: Stop Replacing the Env Module in Three More Suites
Same latent trap as the MCPManager suite: a `jest.mock('~/utils/env', ...)`
factory that replaces the whole module. These three pass today only because
their import graphs never reach `~/mcp/utils`, which reads `ALLOWED_BODY_FIELDS`
from that module at module scope — the next module-scope constant added to
`env.ts` would break all three the same silent way.
Each mock is kept only where it earns its place:
- `activityLabels/host.spec.ts` — dropped. `createSafeUser` was never referenced
and the stub returned `undefined` where the real function returns `{}`, so the
mock was strictly less faithful than the real, pure implementation.
- `run-codeTools.test.ts` — dropped. Neither `resolveHeaders` nor
`createSafeUser` was referenced by any case.
- `run-summarization.test.ts` — `resolveHeaders` is now a spy wrapping the real
implementation rather than an identity stub. One case asserts templated header
values go through it, which only means something if the real substitution
actually runs. `createSafeUser` dropped as unreferenced.
103 suites / 2708 tests green across `src/agents`, `src/utils`, and the
MCPManager suite.
* 📝 docs: Describe the Full Contract of filterMalformedContentParts
Per Copilot's review: the public JSDoc still described the function as only
dropping malformed tool calls, while the implementation also compacts empty
slots and rebases parent activity-phase bounds. The detail lived on the private
helper, so callers reading intellisense saw a stale contract.
State what it actually produces, note that compaction is inherent rather than
incidental (the aggregator writes at provider-source indexes, so the array is
frequently sparse), and add an example of a hole moving a phase bound. The
example was verified against the built runtime, not written from memory.
* 📱 fix: Show Quote Popup for Block Selections and on Touch Devices
The "Add to chat" popup never appeared for two whole classes of selection.
Block-granularity gestures (triple-click, double-click then word-drag) park
the selection's far boundary at the start of the next block. For a message's
closing block that boundary sits outside `.message-render` — on the composer
wrapper or the following message row — while selecting no text there, so the
anchor/focus equality check suppressed the popup. Triple-clicking any earlier
paragraph worked, which is what made this look like an edge case. The range is
now clamped to the message before the check, and selections that really do
carry visible text from another message are still refused.
Touch platforms could not reach the feature at all. A long-press, and every
drag of the native selection handles, emits no mouse event whatsoever — only
`selectionchange` — while the popup was shown exclusively from mouseup,
dblclick and keyup. Showing now also hangs off a settle-debounced
`selectionchange`, gated so an in-progress mouse drag still cannot flicker it.
Accepting was broken independently: the tap is also the gesture that dismisses
the selection, unmounting the button before `click` could land, so touch
commits on `pointerdown` instead. The desktop mousedown path is deliberately
unchanged, since preventDefault on `pointerdown` can suppress the
compatibility mousedown that click depends on.
Two UX consequences of the same code: scrolling re-anchors the popup rather
than dismissing it on the first event (the chat auto-scrolls constantly while
streaming, and a mobile URL bar collapsing fires resize), and touch selections
place the button below the text, clear of the OS Copy/Share callout, with a
44px tap target.
Covered by six e2e tests — three desktop, three on an emulated Pixel 5 with a
real touchscreen — each verified to fail against the pre-fix build.
* 🩹 fix: Address Review Findings and Repair the Scroll Specs
The two failing e2e shards were a defect in the specs, not the component.
`scrollMessages` reached for `.scrollbar-gutter-stable` with a document-wide
query, but the nav and side panels carry that class too, so it could grab a
sidebar list that never scrolls — 0px moved, and only in CI, where the nav
renders differently. The scroller is now reached from the message itself, the
way `MessageNav` does it. The specs also centre the selection first and nudge
by a quarter of the visible height, so the gesture cannot scroll the selection
clean out of view and then blame the popup for going with it.
Review findings, all in `QuoteButton`:
Visibility was tested against the window, but the list scrolls inside a bounded
container, so text can sit clipped under the header or the composer while its
un-clipped rect is still inside the window — leaving the popup floating over
unrelated UI. It is now clipped to the nearest scrollable ancestor.
Touch committed on the press, so starting a scroll on the button, or touching
it and thinking better of it, still added the quote. The excerpt is captured on
the press and committed on the release, and only when that release lands on the
button, restoring the cancellation every button is expected to have. Commit on
press existed because the tap dismisses the selection before `click` fires;
capturing the text up front keeps that safe, and an in-flight press is no
longer allowed to unmount its own target.
A visible popup also described the previous selection for up to the settle
window, so a tap while dragging a native selection handle queued the stale
excerpt. It is dropped as soon as a differing selection starts settling.
Finally, `viaTouch` survived from the last press into keyboard-driven
selections on hybrid devices, which could flip the popup into the touch layout;
keydown clears it.
The cancel path is covered by a new touch spec, verified to fail against a
commit-on-press build.
* 🧵 fix: Reconcile Cancelled Presses, Widen Clipping, Steady the Scroll Specs
Second review round, with one finding taken on trust and flagged rather than
claimed as proven.
A cancelled touch press could leave the popup backed by a selection that no
longer existed. A press deliberately keeps the button alive through a
collapsing selection so the release has a target to be judged against, but a
cancel then dropped the press without ever honouring the collapse it had
masked, so a later tap could add a dead excerpt. Ending a press without
committing now rechecks the live selection and dismisses if it went away.
Visibility now intersects every clipping ancestor of the message rather than
stopping at the nearest. This one is precautionary, not a proven fix: the
review that prompted it describes scroll containers *inside* a message (a wide
table, a code block) shadowing the outer chat scroller, but the walk starts
from the message element, so those are descendants and were never in the chain.
Behaviour is unchanged in the current layout — a spec covering a table-cell
selection passes identically with and without it — and it is kept only because
intersecting the whole chain stays correct if the list is ever nested inside a
further-clipped panel. The comment says exactly this.
The scroll specs were the real instability. They now move the selection between
two positions that are both on screen instead of nudging by a pixel count:
blind nudges kept pushing it under the composer, where the popup correctly
hides, and the chat's own auto-scroll made the landing spot unpredictable. They
also target the opening paragraph, since the closing one is the last content in
the conversation and cannot be carried upward from a list already at maximum
scroll.
The reply fixture gained a table so a selection inside a nested scroll container
is exercised, and a spec covers the cancelled press.
15/15 pass locally.
* 🪟 fix: Judge Quote-Popup Visibility From the Selection, on Both Axes
Third review round. All three findings held up, and each now has a spec that
fails without its fix.
Clipping is now measured from the selection rather than from the message, and
on both axes. A wide table or a long code line scrolls inside its own container
— and `overflow-x: auto` makes the computed `overflow-y` auto, so it clips
vertically too — which means scrolling it sideways carries the selected text out
of view while the message never moves. Walking up from the message could not see
those containers at all, and a vertical-only test could not see that motion.
This supersedes the previous round's precautionary widening, which was kept
without evidence; the evidence is now a spec that scrolls a table past its own
selection.
Publishing a settled selection also checks visibility. Nothing is tracked during
the 300ms settle interval, so a scroll inside that window never reached the
re-anchoring path, and the reading was published off-screen and then clamped
into view — stranding the popup over unrelated UI.
The cancelled-press spec now reproduces the ordering it describes. Collapsing
the selection and cancelling in one synchronous block let the asynchronous
`selectionchange` arrive after the press had ended, which is the ordinary path
and passes either way; it now waits for delivery in between, so the collapse
lands while the press is still masking it. Two other specs needed the same
scrutiny: `toBeHidden` is satisfied by an element that does not exist yet, so
the settle spec sits out the interval before asserting, and it scrolls just past
the container edge rather than to the end of the conversation, because a violent
scroll re-renders the messages and drops the selection for unrelated reasons.
The reply fixture's table is now wide enough to overflow sideways.
17/17 pass, and each new spec was re-run against a build with its own fix
reverted to confirm it fails there.
Follow-up to #14774. Its tests cover `AgentClient.recordTokenUsage` in
isolation, so the `BaseClient` half of the fix was unpinned: deleting the
`transactions` property from the call site restored the bug with the suite
still green.
These cases drive `sendMessage` with a real app config on `req` and assert the
resolved value reaches `recordTokenUsage` — disabled, the default when no
config is present, and the balance-enabled override that force-enables it.
Each fails if either half of #14774 is reverted.
They also isolate `options.endpoint` for the block. `options` is shared across
this file, and an endpoint left behind by an earlier case routes the
balance-enabled arrangement into `checkBalance`.
* 🛠️ refactor: Enhance console color handling and improve deleteNodeModules function
* refactor: Use coloredConsole for consistent console output in invite-user.js
* refactor: Convert year to string format in invite user payload
`AgentClient.recordTokenUsage` had no `transactions` parameter, so the setting
never reached `createTransaction`, whose guard reads it from the object it is
handed. `transactions?.enabled === false` saw `undefined` and the write went
ahead.
This path is reached only from `BaseClient`'s fallback branch, when the provider
returns no usable stream usage, so the bulk path masked it wherever usage is
reported. Where it is not, the setting had no effect at all.
* 🫧 refactor: Fading Pre-Response Dot, Legacy Cursor CSS Removed
The pre-response dot keeps its classic 12px shape but now enters on the
streamed-text fade curve (lc-fade-in) and breathes on opacity, replacing
the legacy pulseSize scale throb that read as jittery. One dot for all
users — the interim result-thinking-fade variant and its per-component
class wiring are removed, and the dot goes static under
prefers-reduced-motion.
Clears out the legacy cursor CSS while here: the scaleX/scale3d mixed
keyframes, -webkit- prefixed animation/transform/backface duplicates,
translateZ and will-change layer hacks, subpixel font-smoothing, the
duplicate #0d0d0d fallback, the unused .blink utility, and all three
duplicate @keyframes blink definitions (the auth logo uses Tailwind's
logo-blink; nothing references these).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB
* 📝 docs: Explain the Pre-Response Dot's Custom-CSS Exception
Per AGENTS.md, custom CSS needs a stated reason a shared primitive cannot
express the requirement: the dot is a pseudo-element gated on an ancestor's
`.submitting` state plus its own `:empty`, kept in CSS so it costs no React
renders per streamed token.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB
---------
Co-authored-by: Claude <noreply@anthropic.com>
* 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.
* 🚣♀️ 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>
* 🪝 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>
* 🔐 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.
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.
* 🧩 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
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.
* 📁 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
* 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.
* 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.
* 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.
* 🔌 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>